diff --git a/README.md b/README.md index e968502..453bf44 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ In most cases, the configuration file should be placed inside the root directory ## Extension: lint-staged -kitty ships extension `lint-staged` to allow you to run commands on staged files. +kitty ships extension `lint-staged` to allow you to run commands on git-selected files. By default it operates on staged files. In most cases, you can use `lint-staged` in the hook `pre-commit`: @@ -71,6 +71,14 @@ Or you can manually run kitty lint-staged ``` +You can also target other file states: + +```shell +kitty @lint-staged --status unstaged +kitty @lint-staged --status tracked +kitty @lint-staged --status all +``` + ### Configuration *lint-staged* can be configured in many ways: @@ -122,7 +130,7 @@ Inside the `files` object in format 1 or for whole object of format 2, each valu } ``` -This config will execute `your-cmd` with the list of currently staged files passed as arguments. +This config will execute `your-cmd` with the list of currently selected files passed as arguments. So, considering you did `git add file1.ext file2.ext`, `lint-staged` will run the following command: @@ -130,6 +138,8 @@ So, considering you did `git add file1.ext file2.ext`, `lint-staged` will run th your-cmd file1.ext file2.ext ``` +When using the default staged mode, `lint-staged` will manage the git index for you. When using `--status` (other than `staged`) or `--diff`, `lint-staged` runs on working tree files only: it does not create a backup stash, hide partially staged changes, or update the git index automatically. + > **Note** > Apart from node.js `lint-staged`, we do not pass absolute paths to the commands. Instead, we pass the relative path to the working directory (where lint-staged config is placed) to the command. > If you want we pass absolute paths to the commands, you can prepend `[absolute] ` (note: space is required) before the command. diff --git a/internal/ext/lint-staged/errors.go b/internal/ext/lint-staged/errors.go index d9efc75..4c86fcc 100644 --- a/internal/ext/lint-staged/errors.go +++ b/internal/ext/lint-staged/errors.go @@ -8,7 +8,7 @@ var ( ErrConfigFormat = fmt.Errorf("configuration should be an object or a function") // TODO ErrConfigEmpty = fmt.Errorf("configuration should not be empty") ErrGetBackupStash = fmt.Errorf("get backup stash error") - ErrGetStagedFiles = fmt.Errorf("get staged files error") + ErrGetSelectedFiles = fmt.Errorf("get selected files error") ErrGitRepo = fmt.Errorf("git repo error") ErrIgnore = fmt.Errorf("load ignore rules error") ErrHideUnstagedChanges = fmt.Errorf("hide unstaged changes error") diff --git a/internal/ext/lint-staged/git.go b/internal/ext/lint-staged/git.go index 8b382ef..4f5ff9f 100644 --- a/internal/ext/lint-staged/git.go +++ b/internal/ext/lint-staged/git.go @@ -1,6 +1,7 @@ package lintstaged import ( + "fmt" "log/slog" "os" "path/filepath" @@ -80,28 +81,79 @@ func resolveGitRepo(cwd string) (gitDir, gitConfigDir string, err error) { } func getDiffCommand(diff, diffFilter string) []string { + diffFilter = normalizeDiffFilter(diffFilter) + + // Use `--diff branch1...branch2` or `--diff="branch1 branch2"`. + diffArgs := strings.Fields(strings.TrimSpace(diff)) + + // Docs for -z option: + // https://git-scm.com/docs/git-diff#Documentation/git-diff.txt--z + return append([]string{"diff", "--name-only", "-z", "--diff-filter=" + diffFilter}, diffArgs...) +} + +func normalizeDiffFilter(diffFilter string) string { if diffFilter == "" { // Docs for --diff-filter option: // https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---diff-filterACDMRTUXB82308203 diffFilter = "ACMR" } - // Use `--diff branch1...branch2` or `--diff="branch1 branch2", or fall back to default staged files - var diffArgs []string - if diff == "" { - diffArgs = []string{"--staged"} - } else { - diffArgs = strings.Split(strings.TrimSpace(diff), " ") + return diffFilter +} + +func getStatusDiffCommand(diffFilter string, staged bool) []string { + args := []string{"diff", "--name-only", "-z", "--diff-filter=" + normalizeDiffFilter(diffFilter)} + if staged { + args = append(args, "--staged") } // Docs for -z option: // https://git-scm.com/docs/git-diff#Documentation/git-diff.txt--z - return append([]string{"diff", "--name-only", "-z", "--diff-filter=" + diffFilter}, diffArgs...) + return args +} + +func getSelectedFiles(options *Options, gitDir string) ([]string, error) { + if options.Diff != "" { + return execGitZ(getDiffCommand(options.Diff, options.DiffFilter), gitDir) + } + + switch options.SelectionMode() { + case SelectionModeStaged: + return getStagedFiles(options.DiffFilter, gitDir) + case SelectionModeUnstaged: + return getUnstagedFiles(options.DiffFilter, gitDir) + case SelectionModeUntracked: + return getUntrackedFiles(gitDir) + case SelectionModeTracked: + return getTrackedFiles(options.DiffFilter, gitDir) + case SelectionModeAll: + tracked, err := getTrackedFiles(options.DiffFilter, gitDir) + if err != nil { + return nil, err + } + untracked, err := getUntrackedFiles(gitDir) + if err != nil { + return nil, err + } + + return uniqueStrings(append(tracked, untracked...)), nil + default: + return nil, fmt.Errorf("unsupported selection mode %q", options.SelectionMode()) + } } // getStagedFiles returns a list of staged files in relative path to git root -func getStagedFiles(options *Options, gitDir string) ([]string, error) { - lines, err := execGitZ(getDiffCommand(options.Diff, options.DiffFilter), gitDir) +func getStagedFiles(diffFilter string, gitDir string) ([]string, error) { + lines, err := execGitZ(getStatusDiffCommand(diffFilter, true), gitDir) + if err != nil { + return nil, err + } + + return lines, nil +} + +func getUnstagedFiles(diffFilter string, gitDir string) ([]string, error) { + lines, err := execGitZ(getStatusDiffCommand(diffFilter, false), gitDir) if err != nil { return nil, err } @@ -130,6 +182,41 @@ func getCachedFiles(gitDir string) ([]string, error) { return execGitZ([]string{"ls-files", "-z", "--full-name"}, gitDir) } -func getUncommittedFiles(gitDir string) ([]string, error) { +func getTrackedFiles(diffFilter string, gitDir string) ([]string, error) { + staged, err := getStagedFiles(diffFilter, gitDir) + if err != nil { + return nil, err + } + + unstaged, err := getUnstagedFiles(diffFilter, gitDir) + if err != nil { + return nil, err + } + + return uniqueStrings(append(staged, unstaged...)), nil +} + +func getUntrackedFiles(gitDir string) ([]string, error) { return execGitZ([]string{"ls-files", "-z", "--full-name", "--others", "--exclude-standard"}, gitDir) } + +func uniqueStrings(in []string) []string { + if len(in) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, item := range in { + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + out = append(out, item) + } + + return out +} diff --git a/internal/ext/lint-staged/gitWorkflow.go b/internal/ext/lint-staged/gitWorkflow.go index de5f2d4..1a70377 100644 --- a/internal/ext/lint-staged/gitWorkflow.go +++ b/internal/ext/lint-staged/gitWorkflow.go @@ -20,6 +20,8 @@ type gitWorkflow struct { allowEmpty bool diff string diffFilter string + manageIndex bool + breakReason string logger *slog.Logger partiallyStagedFiles []string @@ -59,10 +61,18 @@ var gitDiffArgs = []string{ } var gitApplyArgs = []string{"-v", "--whitespace=nowarn", "--recount", "--unidiff-zero"} +func (g *gitWorkflow) prepareOK() (bool, string) { + return g.breakReason != "", g.breakReason +} + // Create a diff of partially staged files and backup stash if enabled. // // will set state.hasPartiallyStagedFiles func (g *gitWorkflow) prepare(state *State) (err error) { + if !g.manageIndex { + return nil + } + g.logger.Debug("Backing up original state...") g.partiallyStagedFiles, err = g.getPartiallyStagedFiles() @@ -109,6 +119,12 @@ func (g *gitWorkflow) prepare(state *State) (err error) { if err != nil { return ee.Wrap(err, "cannot create stash") } + + if hash == "" { + g.breakReason = "workspace is clean" + return nil + } + _, err = g.execGit("stash", "store", "--quiet", "--message", stashMessage, hash) if err != nil { return ee.Wrap(err, "cannot save stash") @@ -328,9 +344,13 @@ func (g *gitWorkflow) getBackupStashIndex() (string, error) { // // may add ErrApplyEmptyCommit to state.errors func (g *gitWorkflow) applyModifications(state *State) error { + if !g.manageIndex { + return nil + } + g.logger.Debug("Adding task modifications to index...") - // `matchedFileChunks` includes staged files that lint-staged originally detected and matched against a task. + // `matchedFileChunks` includes files that lint-staged originally detected and matched against a task. // Add only these files so any 3rd-party edits to other files won't be included in the commit. // These additions per chunk are run "serially" to prevent race conditions. // Git add creates a lockfile in the repo causing concurrent operations to fail. diff --git a/internal/ext/lint-staged/main.go b/internal/ext/lint-staged/main.go index f3b16e1..0535147 100644 --- a/internal/ext/lint-staged/main.go +++ b/internal/ext/lint-staged/main.go @@ -18,7 +18,7 @@ func Commands() []*cobra.Command { Aliases: []string{"@lintstaged"}, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - if o.Diff != "" { + if o.Diff != "" || o.SelectionMode() != SelectionModeStaged { o.Stash = false } @@ -32,6 +32,7 @@ func Commands() []*cobra.Command { flags.StringVarP(&o.ConfigPath, "config", "c", "", "path to configuration file") flags.StringVar(&o.Diff, "diff", "", `override the default "--staged" flag of "git diff" to get list of files. Implies "--stash=false"`) flags.StringVar(&o.DiffFilter, "diff-filter", "", `override the default "--diff-filter=ACMR" flag of "git diff" to get list of files`) + flags.StringVar(&o.Status, "status", string(SelectionModeStaged), "select files by git status: staged, unstaged, untracked, tracked, or all") flags.BoolVar(&o.Stash, "stash", true, "enable the backup stash, and revert in case of errors") flags.StringVarP(&o.Shell, "shell", "x", "", "use a custom shell to execute tasks with; defaults to the shell specified in the environment variable $SHELL, or /bin/sh if not set") flags.BoolVarP(&o.Verbose, "verbose", "v", false, "show task output even when tasks succeed; by default only failed output is shown") @@ -46,6 +47,7 @@ type Options struct { ConfigPath string Diff string DiffFilter string + Status string Stash bool Shell string Verbose bool @@ -69,6 +71,10 @@ func Run(options *Options) error { } func validateOptions(options *Options) error { + if err := options.ValidateSelectionMode(); err != nil { + return err + } + if options.Shell == "" { options.Shell = os.Getenv("SHELL") if options.Shell == "" { diff --git a/internal/ext/lint-staged/messages.go b/internal/ext/lint-staged/messages.go index 436d48d..6c12c6a 100644 --- a/internal/ext/lint-staged/messages.go +++ b/internal/ext/lint-staged/messages.go @@ -15,11 +15,11 @@ const ( arrowRight = "→" ) -func skippingBackup(hasInitialCommit bool, diff string) string { +func skippingBackup(hasInitialCommit bool, options *Options) string { var reason string switch { - case diff != "": - reason = "`--diff` was used" + case options.SelectionReason() != "": + reason = options.SelectionReason() case hasInitialCommit: reason = "`--stash=false` was used" default: diff --git a/internal/ext/lint-staged/run.go b/internal/ext/lint-staged/run.go index e17c5b1..66aa831 100644 --- a/internal/ext/lint-staged/run.go +++ b/internal/ext/lint-staged/run.go @@ -49,24 +49,24 @@ func runAll(options *Options) (*State, error) { hasInitialCommit := err == nil // Lint-staged will create a backup stash only when there's an initial commit, - // and when using the default list of staged files by default - ctx.shouldBackup = hasInitialCommit && options.Stash + // and when using the default staged-file selection. + ctx.shouldBackup = hasInitialCommit && options.Stash && options.UsesIndex() if !ctx.shouldBackup { - pp.EYellowPrintln(skippingBackup(hasInitialCommit, options.Diff)) + pp.EYellowPrintln(skippingBackup(hasInitialCommit, options)) } - // get staged files (relative path) - relativeFiles, err := getStagedFiles(options, gitDir) + // get selected files (relative path) + relativeFiles, err := getSelectedFiles(options, gitDir) if err != nil { - ctx.errors.Add(ErrGetStagedFiles) - return ctx, ee.Wrap(err, "cannot get staged files") + ctx.errors.Add(ErrGetSelectedFiles) + return ctx, ee.Wrap(err, "cannot get selected files") } - slog.Debug("Loaded list of staged files in git", "files", relativeFiles) + slog.Debug("Loaded selected files in git", "files", relativeFiles, "label", options.SelectedFilesLabel()) files := NewFiles(ctx, relativeFiles) // If there are no files avoid executing any lint-staged logic if len(files) == 0 { - pp.BluePrintln(info, "No staged files found.") + pp.BluePrintln(info, "No "+options.SelectedFilesLabel()+" found.") return ctx, nil } @@ -89,7 +89,7 @@ func runAll(options *Options) (*State, error) { debugFilesByConfig[c.Path] = cFiles.GitRelativePaths() } - slog.Debug("Grouped staged files by config", "count", usedConfigsCount, "filesByConfig", debugFilesByConfig) + slog.Debug("Grouped selected files by config", "count", usedConfigsCount, "filesByConfig", debugFilesByConfig) } ctx.ignoreChecker, err = NewIgnoreChecker(gitDir) @@ -109,6 +109,7 @@ func runAll(options *Options) (*State, error) { allowEmpty: options.AllowEmpty, diff: options.Diff, diffFilter: options.DiffFilter, + manageIndex: options.UsesIndex(), logger: slog.Default(), // TODO } @@ -139,7 +140,8 @@ func runAll(options *Options) (*State, error) { Run: func(callback tl.TaskCallback) error { return gw.prepare(ctx) }, - PostRun: handleInternalError, + PostRun: handleInternalError, + BreakFlow: gw.prepareOK, }, { Title: "Hiding unstaged changes to partially staged files...", @@ -154,7 +156,7 @@ func runAll(options *Options) (*State, error) { }), }, { - Title: "Running tasks for staged files...", + Title: "Running tasks for selected files...", Run: func(callback tl.TaskCallback) error { if ctx.internalError { callback.Skip("internal error") diff --git a/internal/ext/lint-staged/selection.go b/internal/ext/lint-staged/selection.go new file mode 100644 index 0000000..313e01d --- /dev/null +++ b/internal/ext/lint-staged/selection.go @@ -0,0 +1,70 @@ +package lintstaged + +import "fmt" + +type SelectionMode string + +const ( + SelectionModeStaged SelectionMode = "staged" + SelectionModeUnstaged SelectionMode = "unstaged" + SelectionModeUntracked SelectionMode = "untracked" + SelectionModeTracked SelectionMode = "tracked" + SelectionModeAll SelectionMode = "all" +) + +func (o *Options) SelectionMode() SelectionMode { + if o.Status == "" { + return SelectionModeStaged + } + + return SelectionMode(o.Status) +} + +func (o *Options) UsesIndex() bool { + return o.Diff == "" && o.SelectionMode() == SelectionModeStaged +} + +func (o *Options) ValidateSelectionMode() error { + switch o.SelectionMode() { + case SelectionModeStaged, SelectionModeUnstaged, SelectionModeUntracked, SelectionModeTracked, SelectionModeAll: + // ok + default: + return fmt.Errorf("invalid --status %q (must be one of: staged, unstaged, untracked, tracked, all)", o.Status) + } + + if o.Diff != "" && o.SelectionMode() != SelectionModeStaged { + return fmt.Errorf("--diff cannot be used together with --status=%s", o.SelectionMode()) + } + + return nil +} + +func (o *Options) SelectionReason() string { + if o.Diff != "" { + return "`--diff` was used" + } + if o.SelectionMode() != SelectionModeStaged { + return fmt.Sprintf("`--status=%s` was used", o.SelectionMode()) + } + + return "" +} + +func (o *Options) SelectedFilesLabel() string { + if o.Diff != "" { + return "selected files" + } + + switch o.SelectionMode() { + case SelectionModeUnstaged: + return "unstaged files" + case SelectionModeUntracked: + return "untracked files" + case SelectionModeTracked: + return "tracked changed files" + case SelectionModeAll: + return "changed files" + default: + return "staged files" + } +} diff --git a/internal/ext/lint-staged/selection_test.go b/internal/ext/lint-staged/selection_test.go new file mode 100644 index 0000000..1600995 --- /dev/null +++ b/internal/ext/lint-staged/selection_test.go @@ -0,0 +1,53 @@ +package lintstaged + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOptionsSelectionMode(t *testing.T) { + t.Run("default uses staged selection", func(t *testing.T) { + options := &Options{} + + require.NoError(t, options.ValidateSelectionMode()) + assert.Equal(t, SelectionModeStaged, options.SelectionMode()) + assert.True(t, options.UsesIndex()) + assert.Equal(t, "staged files", options.SelectedFilesLabel()) + }) + + t.Run("custom status uses working tree only", func(t *testing.T) { + options := &Options{Status: string(SelectionModeTracked)} + + require.NoError(t, options.ValidateSelectionMode()) + assert.Equal(t, SelectionModeTracked, options.SelectionMode()) + assert.False(t, options.UsesIndex()) + assert.Equal(t, "`--status=tracked` was used", options.SelectionReason()) + assert.Equal(t, "tracked changed files", options.SelectedFilesLabel()) + }) + + t.Run("diff uses working tree only", func(t *testing.T) { + options := &Options{Diff: "HEAD"} + + require.NoError(t, options.ValidateSelectionMode()) + assert.False(t, options.UsesIndex()) + assert.Equal(t, "`--diff` was used", options.SelectionReason()) + assert.Equal(t, "selected files", options.SelectedFilesLabel()) + }) + + t.Run("invalid status rejected", func(t *testing.T) { + options := &Options{Status: "weird"} + + require.Error(t, options.ValidateSelectionMode()) + }) + + t.Run("diff cannot combine with non default status", func(t *testing.T) { + options := &Options{ + Diff: "HEAD", + Status: string(SelectionModeAll), + } + + require.Error(t, options.ValidateSelectionMode()) + }) +} diff --git a/internal/lib/tl/list.go b/internal/lib/tl/list.go index 503d430..44237c1 100644 --- a/internal/lib/tl/list.go +++ b/internal/lib/tl/list.go @@ -98,9 +98,10 @@ func (tl *TaskList) start(p *tea.Program) (result *Result) { } preventContinue := false + shouldBreak := false for i, task := range tl.tasks { - if preventContinue { + if preventContinue || shouldBreak { task.skip(p) continue } @@ -108,6 +109,10 @@ func (tl *TaskList) start(p *tea.Program) (result *Result) { taskResult := task.start(p) result.SubResults[i] = taskResult + if task.BreakFlow != nil { + shouldBreak, _ = task.BreakFlow() + } + if taskResult.Error { result.Error = true diff --git a/internal/lib/tl/task.go b/internal/lib/tl/task.go index 7b20421..e3cd50f 100644 --- a/internal/lib/tl/task.go +++ b/internal/lib/tl/task.go @@ -16,6 +16,9 @@ type Task struct { PostRun func(result *Result) Enable func() bool + // BreakFlow return true interrupts flow execution + BreakFlow func() (shouldBreak bool, reason string) + Options []OptionApplier id string