Skip to content
Merged
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
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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:

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 <repo> 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:

Expand Down
91 changes: 84 additions & 7 deletions internal/wtclean/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import (
"fmt"
"io"
"os/exec"
"path/filepath"
"strings"
)

type Options struct {
DeleteMode string
Prune bool
All bool
Verbose bool
Help bool
Version bool
Expand Down Expand Up @@ -77,21 +79,38 @@ 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
}

// 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
}

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 {
Expand All @@ -100,15 +119,43 @@ func (a *App) listRepositories(ctx context.Context) ([]string, error) {
return splitLines(out), nil
}

func (a *App) processRepo(ctx context.Context, opts Options, repo string, summary *Summary) {
// 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
Comment thread
rokuosan marked this conversation as resolved.
}

// 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
}

// 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)
Expand All @@ -135,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
Expand All @@ -147,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)

Expand Down Expand Up @@ -214,6 +287,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":
Expand All @@ -230,15 +305,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
Expand Down
88 changes: 82 additions & 6 deletions internal/wtclean/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -46,6 +46,60 @@ 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 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()

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",
Expand All @@ -64,7 +118,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())
Expand All @@ -82,7 +136,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())
Expand All @@ -100,7 +154,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())
Expand All @@ -126,7 +180,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())
Expand Down Expand Up @@ -180,7 +234,11 @@ func newTestApp() (*App, *fakeRunner, *bytes.Buffer, *bytes.Buffer) {
}

type fakeRunner struct {
calls []string
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) {
Expand All @@ -190,6 +248,24 @@ 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 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":
Expand Down