From 7b3a49565d9e2dc7005a28425c29bf700391f4a2 Mon Sep 17 00:00:00 2001 From: Koki Matsumoto Date: Tue, 16 Jun 2026 02:01:36 +0900 Subject: [PATCH 1/4] feat: default to current repository, add --all for all ghq repos Operating on every ghq repository by default is surprising for a git subcommand and makes -d/-D a footgun across unrelated repositories. Default to the repository containing the current directory and gate the previous all-repositories behaviour behind --all. --- internal/wtclean/app.go | 34 ++++++++++++++++++++- internal/wtclean/app_test.go | 59 ++++++++++++++++++++++++++++++++---- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/internal/wtclean/app.go b/internal/wtclean/app.go index da5df1d..6cb329b 100644 --- a/internal/wtclean/app.go +++ b/internal/wtclean/app.go @@ -13,6 +13,7 @@ import ( type Options struct { DeleteMode string Prune bool + All bool Verbose bool Help bool Version bool @@ -77,7 +78,7 @@ func (a *App) Run(ctx context.Context, args []string) int { func (a *App) run(ctx context.Context, opts Options) (Summary, error) { var summary Summary - repos, err := a.listRepositories(ctx) + repos, err := a.repositories(ctx, opts) if err != nil { return summary, err } @@ -92,6 +93,18 @@ func (a *App) run(ctx context.Context, opts Options) (Summary, error) { return summary, nil } +func (a *App) repositories(ctx context.Context, opts Options) ([]string, error) { + if opts.All { + return a.listRepositories(ctx) + } + + repo, err := a.currentRepo(ctx) + if err != nil { + return nil, err + } + return []string{repo}, nil +} + func (a *App) listRepositories(ctx context.Context) ([]string, error) { out, err := a.runner.Output(ctx, "ghq", "list", "-p") if err != nil { @@ -100,6 +113,23 @@ func (a *App) listRepositories(ctx context.Context) ([]string, error) { return splitLines(out), nil } +// currentRepo resolves the repository containing the current directory and +// returns the path of its primary worktree. Linked worktrees share the same +// worktree list, so resolving to the primary keeps the rest of the pipeline +// consistent regardless of which worktree the command was invoked from. +func (a *App) currentRepo(ctx context.Context) (string, error) { + out, err := a.runner.Output(ctx, "git", "worktree", "list", "--porcelain", "-z") + if err != nil { + return "", fmt.Errorf("not inside a git repository (use --all to target all ghq repositories): %w", err) + } + + worktrees := ParseWorktreeList(out) + if len(worktrees) == 0 || worktrees[0].Path == "" { + return "", errors.New("could not determine the current repository (use --all to target all ghq repositories)") + } + return worktrees[0].Path, nil +} + func (a *App) processRepo(ctx context.Context, opts Options, repo string, summary *Summary) { if !a.isGitRepo(ctx, repo) { return @@ -214,6 +244,8 @@ func ParseArgs(args []string) (Options, error) { opts.DeleteMode = arg case "--prune": opts.Prune = true + case "--all": + opts.All = true case "-v", "--verbose": opts.Verbose = true case "--version": diff --git a/internal/wtclean/app_test.go b/internal/wtclean/app_test.go index 0d57fac..b28cc8d 100644 --- a/internal/wtclean/app_test.go +++ b/internal/wtclean/app_test.go @@ -37,7 +37,7 @@ func TestParseWorktreeList(t *testing.T) { } } -func TestRunDryRunListsLinkedWorktrees(t *testing.T) { +func TestRunDryRunDefaultsToCurrentRepository(t *testing.T) { app, runner, stdout, stderr := newTestApp() code := app.Run(context.Background(), nil) @@ -46,6 +46,43 @@ func TestRunDryRunListsLinkedWorktrees(t *testing.T) { t.Fatalf("exit code = %d, stderr = %s", code, stderr.String()) } + want := strings.Join([]string{ + "Would remove: /tmp/repo1/.wt/feature-a", + "Dry run. found=1. Run 'git wtclean -d' to remove, or 'git wtclean -D' to force remove.", + "", + }, "\n") + if stdout.String() != want { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } + + if got := runner.callsContaining("ghq list"); len(got) != 0 { + t.Fatalf("ghq calls = %#v, want none in current-repo mode", got) + } +} + +func TestRunOutsideRepositoryErrors(t *testing.T) { + app, runner, _, stderr := newTestApp() + runner.outsideRepo = true + + code := app.Run(context.Background(), nil) + + if code != 127 { + t.Fatalf("exit code = %d, want 127", code) + } + if !strings.Contains(stderr.String(), "not inside a git repository") { + t.Fatalf("stderr = %q", stderr.String()) + } +} + +func TestRunDryRunAllListsLinkedWorktrees(t *testing.T) { + app, runner, stdout, stderr := newTestApp() + + code := app.Run(context.Background(), []string{"--all"}) + + if code != 0 { + t.Fatalf("exit code = %d, stderr = %s", code, stderr.String()) + } + want := strings.Join([]string{ "Would remove: /tmp/repo1/.wt/feature-a", "Would remove: /tmp/repo2/.wt/feature-b", @@ -64,7 +101,7 @@ func TestRunDryRunListsLinkedWorktrees(t *testing.T) { func TestRunDeleteRemovesWithoutForce(t *testing.T) { app, runner, _, stderr := newTestApp() - code := app.Run(context.Background(), []string{"-d"}) + code := app.Run(context.Background(), []string{"--all", "-d"}) if code != 0 { t.Fatalf("exit code = %d, stderr = %s", code, stderr.String()) @@ -82,7 +119,7 @@ func TestRunDeleteRemovesWithoutForce(t *testing.T) { func TestRunForceDeleteRemovesWithForce(t *testing.T) { app, runner, _, stderr := newTestApp() - code := app.Run(context.Background(), []string{"-D"}) + code := app.Run(context.Background(), []string{"--all", "-D"}) if code != 0 { t.Fatalf("exit code = %d, stderr = %s", code, stderr.String()) @@ -100,7 +137,7 @@ func TestRunForceDeleteRemovesWithForce(t *testing.T) { func TestRunPruneQuietByDefault(t *testing.T) { app, runner, stdout, stderr := newTestApp() - code := app.Run(context.Background(), []string{"--prune"}) + code := app.Run(context.Background(), []string{"--all", "--prune"}) if code != 0 { t.Fatalf("exit code = %d, stderr = %s", code, stderr.String()) @@ -126,7 +163,7 @@ func TestRunPruneQuietByDefault(t *testing.T) { func TestRunPruneVerbosePrintsRepositories(t *testing.T) { app, _, stdout, stderr := newTestApp() - code := app.Run(context.Background(), []string{"--prune", "--verbose"}) + code := app.Run(context.Background(), []string{"--all", "--prune", "--verbose"}) if code != 0 { t.Fatalf("exit code = %d, stderr = %s", code, stderr.String()) @@ -180,7 +217,8 @@ func newTestApp() (*App, *fakeRunner, *bytes.Buffer, *bytes.Buffer) { } type fakeRunner struct { - calls []string + calls []string + outsideRepo bool } func (r *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { @@ -190,6 +228,15 @@ func (r *fakeRunner) Output(_ context.Context, name string, args ...string) ([]b switch call { case "ghq list -p": return []byte("/tmp/repo1\n/tmp/repo2\n/tmp/not-git\n"), nil + case "git worktree list --porcelain -z": + if r.outsideRepo { + return nil, errors.New("not a git repository") + } + return porcelain( + []string{"worktree /tmp/repo1", "HEAD 1111111111111111111111111111111111111111", "branch refs/heads/main"}, + []string{"worktree /tmp/repo1/.wt/feature-a", "HEAD 2222222222222222222222222222222222222222", "branch refs/heads/feature-a"}, + []string{"worktree /tmp/repo1/.wt/bare-cache", "bare"}, + ), nil case "git -C /tmp/repo1 rev-parse --git-dir", "git -C /tmp/repo2 rev-parse --git-dir": return []byte(".git\n"), nil case "git -C /tmp/not-git rev-parse --git-dir": From 5b820668e53bef6545660b91831e16e688e594ec Mon Sep 17 00:00:00 2001 From: Koki Matsumoto Date: Tue, 16 Jun 2026 02:01:56 +0900 Subject: [PATCH 2/4] docs: describe current-repo default and --all in help text --- internal/wtclean/app.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/wtclean/app.go b/internal/wtclean/app.go index 6cb329b..65a3452 100644 --- a/internal/wtclean/app.go +++ b/internal/wtclean/app.go @@ -262,15 +262,17 @@ func ParseArgs(args []string) (Options, error) { func Usage(w io.Writer) { writeString(w, `Usage: - git wtclean Show linked worktrees under ghq repositories that would be removed + git wtclean Show linked worktrees in the current repository that would be removed git wtclean -d Remove them with `+"`git worktree remove`"+` git wtclean -D Force remove them with `+"`git worktree remove --force`"+` git wtclean --prune Prune stale worktree metadata with `+"`git worktree prune`"+` + git wtclean --all Target every repository listed by `+"`ghq list -p`"+` instead of just the current one Options: -d Remove worktrees with `+"`git worktree remove`"+` -D Force remove worktrees with `+"`git worktree remove --force`"+` - --prune Run `+"`git worktree prune`"+` for each ghq repository + --prune Run `+"`git worktree prune`"+` + --all Target every ghq repository instead of the current one -v, --verbose Print each repository while pruning --version Show version -h Show this help From 2d41bf5f917cf8b9664575e3044aae3cac6a97df Mon Sep 17 00:00:00 2001 From: Koki Matsumoto Date: Tue, 16 Jun 2026 02:02:25 +0900 Subject: [PATCH 3/4] docs: document current-repo default and --all in README --- README.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b7a1c6a..5a0045c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # git-wtclean -`git-wtclean` is a Git subcommand for cleaning linked worktrees under repositories managed by [`ghq`](https://github.com/x-motemen/ghq). +`git-wtclean` is a Git subcommand for cleaning linked worktrees. By default it operates on the current repository; with `--all` it spans every repository managed by [`ghq`](https://github.com/x-motemen/ghq). It uses Git's native worktree commands: @@ -27,6 +27,8 @@ git wtclean ## Usage +By default, `git wtclean` operates on the repository containing the current directory. Run it outside a Git repository and it errors, suggesting `--all`. + Dry-run by default: ```sh @@ -45,12 +47,20 @@ Force remove linked worktrees: git wtclean -D ``` -Prune stale worktree metadata for each ghq repository: +Prune stale worktree metadata: ```sh git wtclean --prune ``` +Target every repository listed by `ghq list -p` instead of just the current one: + +```sh +git wtclean --all +git wtclean --all -d +git wtclean --all --prune +``` + Print each repository while pruning: ```sh @@ -60,14 +70,20 @@ git wtclean --prune -v ## What It Removes -`git wtclean` targets linked worktrees discovered from: +By default, `git wtclean` resolves the current repository and targets its linked worktrees: + +```sh +git worktree list --porcelain -z +``` + +With `--all`, it instead iterates over every repository reported by `ghq`: ```sh ghq list -p git -C worktree list --porcelain -z ``` -The first worktree entry is treated as the primary worktree and is skipped. Bare worktree entries are also skipped. +In either case, the first worktree entry is treated as the primary worktree and is skipped. Bare worktree entries are also skipped. With `-d`, each target is removed with: From 08637d4c100f45ee482e09fb8c14351eeaf60769 Mon Sep 17 00:00:00 2001 From: Koki Matsumoto Date: Tue, 16 Jun 2026 02:14:34 +0900 Subject: [PATCH 4/4] fix: never remove the worktree the command is run from git worktree remove deletes a clean linked worktree even when it is the current checkout, leaving the user inside a deleted directory. Resolve the current worktree via git rev-parse --show-toplevel and exclude it from the removal scan in both default and --all modes. --- internal/wtclean/app.go | 51 +++++++++++++++++++++++++++++++++--- internal/wtclean/app_test.go | 29 ++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/internal/wtclean/app.go b/internal/wtclean/app.go index 65a3452..dc57cf9 100644 --- a/internal/wtclean/app.go +++ b/internal/wtclean/app.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os/exec" + "path/filepath" "strings" ) @@ -83,11 +84,16 @@ func (a *App) run(ctx context.Context, opts Options) (Summary, error) { return summary, err } + // Never offer to remove the worktree the command is being run from: + // `git worktree remove` happily deletes a clean linked worktree, including + // the current checkout, leaving the user inside a deleted directory. + current := a.currentWorktree(ctx) + for _, repo := range repos { if repo == "" { continue } - a.processRepo(ctx, opts, repo, &summary) + a.processRepo(ctx, opts, repo, current, &summary) } return summary, nil @@ -130,7 +136,18 @@ func (a *App) currentRepo(ctx context.Context) (string, error) { return worktrees[0].Path, nil } -func (a *App) processRepo(ctx context.Context, opts Options, repo string, summary *Summary) { +// currentWorktree returns the absolute path of the worktree containing the +// current directory, or "" if it cannot be determined (e.g. when --all is run +// outside any repository). Used to exclude the current checkout from removal. +func (a *App) currentWorktree(ctx context.Context) string { + out, err := a.runner.Output(ctx, "git", "rev-parse", "--show-toplevel") + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func (a *App) processRepo(ctx context.Context, opts Options, repo, current string, summary *Summary) { if !a.isGitRepo(ctx, repo) { return } @@ -138,7 +155,7 @@ func (a *App) processRepo(ctx context.Context, opts Options, repo string, summar // In pure prune mode (--prune without a delete option), only clean stale // worktree metadata and skip listing active worktrees. if opts.DeleteMode != "" || !opts.Prune { - paths, err := a.listLinkedWorktreePaths(ctx, repo) + paths, err := a.listLinkedWorktreePaths(ctx, repo, current) if err != nil { summary.Failed++ writef(a.stderr, "git wtclean: failed to list worktrees: %s: %v\n", repo, err) @@ -165,7 +182,7 @@ func (a *App) isGitRepo(ctx context.Context, repo string) bool { return err == nil } -func (a *App) listLinkedWorktreePaths(ctx context.Context, repo string) ([]string, error) { +func (a *App) listLinkedWorktreePaths(ctx context.Context, repo, current string) ([]string, error) { out, err := a.runner.Output(ctx, "git", "-C", repo, "worktree", "list", "--porcelain", "-z") if err != nil { return nil, err @@ -177,11 +194,37 @@ func (a *App) listLinkedWorktreePaths(ctx context.Context, repo string) ([]strin if i == 0 || wt.Bare || wt.Path == "" { continue } + // Skip the worktree the command is being run from so we never delete + // the user's current checkout. + if samePath(wt.Path, current) { + continue + } paths = append(paths, wt.Path) } return paths, nil } +// samePath reports whether two paths refer to the same location, resolving +// symlinks so a worktree registered under, e.g., /tmp still matches a current +// directory reported as /private/tmp. +func samePath(a, b string) bool { + if a == "" || b == "" { + return false + } + if a == b { + return true + } + ra, err := filepath.EvalSymlinks(a) + if err != nil { + ra = filepath.Clean(a) + } + rb, err := filepath.EvalSymlinks(b) + if err != nil { + rb = filepath.Clean(b) + } + return ra == rb +} + func (a *App) removeWorktree(ctx context.Context, opts Options, repo, path string, summary *Summary) { writef(a.stdout, "Removing (%s): %s\n", opts.DeleteMode, path) diff --git a/internal/wtclean/app_test.go b/internal/wtclean/app_test.go index b28cc8d..c6a8f1d 100644 --- a/internal/wtclean/app_test.go +++ b/internal/wtclean/app_test.go @@ -74,6 +74,23 @@ func TestRunOutsideRepositoryErrors(t *testing.T) { } } +func TestRunExcludesCurrentWorktree(t *testing.T) { + app, runner, stdout, stderr := newTestApp() + runner.currentWorktree = "/tmp/repo1/.wt/feature-a" + + code := app.Run(context.Background(), []string{"-d"}) + + if code != 0 { + t.Fatalf("exit code = %d, stderr = %s", code, stderr.String()) + } + if got := runner.callsContaining("worktree remove"); len(got) != 0 { + t.Fatalf("remove calls = %#v, want none (current worktree must be excluded)", got) + } + if !strings.Contains(stdout.String(), "found=0") { + t.Fatalf("stdout = %q, want found=0", stdout.String()) + } +} + func TestRunDryRunAllListsLinkedWorktrees(t *testing.T) { app, runner, stdout, stderr := newTestApp() @@ -219,6 +236,9 @@ func newTestApp() (*App, *fakeRunner, *bytes.Buffer, *bytes.Buffer) { type fakeRunner struct { calls []string outsideRepo bool + // currentWorktree overrides the path reported by `git rev-parse + // --show-toplevel`. Defaults to the primary worktree of repo1. + currentWorktree string } func (r *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { @@ -237,6 +257,15 @@ func (r *fakeRunner) Output(_ context.Context, name string, args ...string) ([]b []string{"worktree /tmp/repo1/.wt/feature-a", "HEAD 2222222222222222222222222222222222222222", "branch refs/heads/feature-a"}, []string{"worktree /tmp/repo1/.wt/bare-cache", "bare"}, ), nil + case "git rev-parse --show-toplevel": + if r.outsideRepo { + return nil, errors.New("not a git repository") + } + top := r.currentWorktree + if top == "" { + top = "/tmp/repo1" + } + return []byte(top + "\n"), nil case "git -C /tmp/repo1 rev-parse --git-dir", "git -C /tmp/repo2 rev-parse --git-dir": return []byte(".git\n"), nil case "git -C /tmp/not-git rev-parse --git-dir":