Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand All @@ -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:
Expand Down Expand Up @@ -122,14 +130,16 @@ 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:

```shell
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.
Expand Down
2 changes: 1 addition & 1 deletion internal/ext/lint-staged/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
107 changes: 97 additions & 10 deletions internal/ext/lint-staged/git.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package lintstaged

import (
"fmt"
"log/slog"
"os"
"path/filepath"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
22 changes: 21 additions & 1 deletion internal/ext/lint-staged/gitWorkflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ type gitWorkflow struct {
allowEmpty bool
diff string
diffFilter string
manageIndex bool
breakReason string
logger *slog.Logger

partiallyStagedFiles []string
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion internal/ext/lint-staged/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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")
Expand All @@ -46,6 +47,7 @@ type Options struct {
ConfigPath string
Diff string
DiffFilter string
Status string
Stash bool
Shell string
Verbose bool
Expand All @@ -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 == "" {
Expand Down
6 changes: 3 additions & 3 deletions internal/ext/lint-staged/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 14 additions & 12 deletions internal/ext/lint-staged/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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)
Expand All @@ -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
}

Expand Down Expand Up @@ -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...",
Expand All @@ -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")
Expand Down
Loading