From 1a5d19cfdf3726d7a38b1fed98453892f41688a3 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sat, 2 May 2026 13:18:22 -0700 Subject: [PATCH 1/4] fix(git): detach dirty worktrees before reuse --- cmd/e2e_test.go | 114 +++++++++++++++++++++++++++++++++++++++++++- cmd/get.go | 4 ++ cmd/return_cmd.go | 4 ++ internal/git/git.go | 10 +++- 4 files changed, 129 insertions(+), 3 deletions(-) diff --git a/cmd/e2e_test.go b/cmd/e2e_test.go index 03cf9fb..6c8d04b 100644 --- a/cmd/e2e_test.go +++ b/cmd/e2e_test.go @@ -11,8 +11,9 @@ import ( ) var ( - treehouseBin string - exitShellBin string + treehouseBin string + exitShellBin string + dirtyMainShellBin string ) func TestMain(m *testing.M) { @@ -60,6 +61,45 @@ func TestMain(m *testing.M) { panic("failed to build exit-shell: " + err.Error()) } + dirtyMainShellBin = filepath.Join(buildDir, "dirty-main-shell") + if runtime.GOOS == "windows" { + dirtyMainShellBin += ".exe" + } + dirtyMainSrcDir := filepath.Join(buildDir, "dirty-main-shell-src") + if err := os.MkdirAll(dirtyMainSrcDir, 0o755); err != nil { + panic(err) + } + if err := os.WriteFile(filepath.Join(dirtyMainSrcDir, "go.mod"), []byte("module dirty-main-shell\n\ngo 1.21\n"), 0o644); err != nil { + panic(err) + } + if err := os.WriteFile(filepath.Join(dirtyMainSrcDir, "main.go"), []byte(`package main + +import ( + "os" + "os/exec" +) + +func main() { + cmd := exec.Command("git", "checkout", "main") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + os.Exit(1) + } + if err := os.WriteFile("README.md", []byte("dirty\n"), 0o644); err != nil { + os.Exit(1) + } +} +`), 0o644); err != nil { + panic(err) + } + buildDirtyMainShell := exec.Command("go", "build", "-o", dirtyMainShellBin, ".") + buildDirtyMainShell.Dir = dirtyMainSrcDir + buildDirtyMainShell.Stderr = os.Stderr + if err := buildDirtyMainShell.Run(); err != nil { + panic("failed to build dirty-main-shell: " + err.Error()) + } + code := m.Run() os.RemoveAll(buildDir) os.Exit(code) @@ -181,6 +221,16 @@ func gitCmd(t *testing.T, dir string, args ...string) string { return strings.TrimSpace(string(out)) } +func gitCmdResult(t *testing.T, dir string, args ...string) (string, error) { + t.Helper() + cmd := exec.Command("git", args...) + if dir != "" { + cmd.Dir = dir + } + out, err := cmd.CombinedOutput() + return strings.TrimSpace(string(out)), err +} + // extractWorktreePath parses the worktree path from "treehouse get" stderr. // The output line looks like: // @@ -342,6 +392,66 @@ func TestReturnFromInsideWorktreeDoesNotTerminateCaller(t *testing.T) { } } +func TestGetDetachesWorktreeWhenLeavingDirty(t *testing.T) { + repoDir, homeDir := setupTestRepo(t) + gitCmd(t, repoDir, "checkout", "-b", "feature") + + env := []string{"SHELL=" + dirtyMainShellBin} + _, getErr, code := runTreehouse(t, repoDir, homeDir, env, "get") + if code != 0 { + t.Fatalf("get failed (code %d): %s", code, getErr) + } + wtPath := extractWorktreePath(getErr, homeDir) + if wtPath == "" { + t.Fatal("could not extract worktree path") + } + if !strings.Contains(getErr, "Worktree left dirty") { + t.Fatalf("expected get to leave dirty worktree for this regression, got: %s", getErr) + } + + if branch, err := gitCmdResult(t, wtPath, "symbolic-ref", "--short", "-q", "HEAD"); err == nil { + t.Fatalf("expected worktree HEAD to be detached, got branch %q", branch) + } + if out, err := gitCmdResult(t, repoDir, "checkout", "main"); err != nil { + t.Fatalf("expected main repo to checkout main after dirty worktree exit, got: %v\n%s", err, out) + } +} + +func TestReturnForceCleansAndDetachesCheckedOutBranch(t *testing.T) { + repoDir, homeDir := setupTestRepo(t) + gitCmd(t, repoDir, "checkout", "-b", "feature") + + env := []string{"SHELL=" + exitShellBin} + _, getErr, code := runTreehouse(t, repoDir, homeDir, env, "get") + if code != 0 { + t.Fatalf("get failed (code %d): %s", code, getErr) + } + wtPath := extractWorktreePath(getErr, homeDir) + if wtPath == "" { + t.Fatal("could not extract worktree path") + } + + gitCmd(t, wtPath, "checkout", "main") + if err := os.WriteFile(filepath.Join(wtPath, "README.md"), []byte("dirty\n"), 0o644); err != nil { + t.Fatal(err) + } + + _, returnErr, code := runTreehouse(t, repoDir, homeDir, nil, "return", "--force", wtPath) + if code != 0 { + t.Fatalf("return --force failed (code %d): %s", code, returnErr) + } + + if branch, err := gitCmdResult(t, wtPath, "symbolic-ref", "--short", "-q", "HEAD"); err == nil { + t.Fatalf("expected returned worktree HEAD to be detached, got branch %q", branch) + } + if status := gitCmd(t, wtPath, "status", "--porcelain"); status != "" { + t.Fatalf("expected return --force to clean tracked changes, got status:\n%s", status) + } + if out, err := gitCmdResult(t, repoDir, "checkout", "main"); err != nil { + t.Fatalf("expected main repo to checkout main after return --force, got: %v\n%s", err, out) + } +} + func TestDestroySpecific(t *testing.T) { repoDir, homeDir := setupTestRepo(t) env := []string{"SHELL=" + exitShellBin} diff --git a/cmd/get.go b/cmd/get.go index b4bfab1..02adad8 100644 --- a/cmd/get.go +++ b/cmd/get.go @@ -60,6 +60,10 @@ func getRunE(cmd *cobra.Command, args []string) error { _, err = shell.Spawn(wtPath, env) // Subshell exited — handle return + if err := git.DetachWorktree(wtPath); err != nil { + fmt.Fprintf(os.Stderr, "🌳 Warning: failed to detach worktree HEAD: %v\n", err) + } + dirty, _ := git.IsDirty(wtPath) if dirty { fmt.Fprintf(os.Stderr, "🌳 Worktree has uncommitted changes.\n") diff --git a/cmd/return_cmd.go b/cmd/return_cmd.go index 910bef7..cba972a 100644 --- a/cmd/return_cmd.go +++ b/cmd/return_cmd.go @@ -44,6 +44,10 @@ var returnCmd = &cobra.Command{ return fmt.Errorf("worktree %s is not managed by treehouse", wtPath) } + if err := git.DetachWorktree(wtPath); err != nil { + return fmt.Errorf("failed to detach worktree HEAD: %w", err) + } + if !returnForce { dirty, _ := git.IsDirty(wtPath) if dirty { diff --git a/internal/git/git.go b/internal/git/git.go index 455c0ae..2cd3e99 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -136,13 +136,21 @@ func ResetWorktree(worktreePath, branch string) error { repoRoot = worktreePath } ref := branchRef(repoRoot, branch) - if _, err := runGit(worktreePath, "checkout", "--detach", ref); err != nil { + if _, err := runGit(worktreePath, "checkout", "--detach", "--force", ref); err != nil { + return err + } + if _, err := runGit(worktreePath, "reset", "--hard", ref); err != nil { return err } _, err = runGit(worktreePath, "clean", "-fd") return err } +func DetachWorktree(worktreePath string) error { + _, err := runGit(worktreePath, "checkout", "--detach") + return err +} + func IsDirty(worktreePath string) (bool, error) { out, err := runGit(worktreePath, "status", "--porcelain") if err != nil { From d6a728a600d2a92bf5b44fcb2fc8cf98355bab38 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sat, 2 May 2026 13:24:42 -0700 Subject: [PATCH 2/4] no-mistakes(review): Fix force return conflict cleanup --- cmd/e2e_test.go | 46 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/return_cmd.go | 8 ++++---- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/cmd/e2e_test.go b/cmd/e2e_test.go index 6c8d04b..8e35afa 100644 --- a/cmd/e2e_test.go +++ b/cmd/e2e_test.go @@ -452,6 +452,52 @@ func TestReturnForceCleansAndDetachesCheckedOutBranch(t *testing.T) { } } +func TestReturnForceCleansConflictedWorktree(t *testing.T) { + repoDir, homeDir := setupTestRepo(t) + gitCmd(t, repoDir, "checkout", "-b", "feature") + + env := []string{"SHELL=" + exitShellBin} + _, getErr, code := runTreehouse(t, repoDir, homeDir, env, "get") + if code != 0 { + t.Fatalf("get failed (code %d): %s", code, getErr) + } + wtPath := extractWorktreePath(getErr, homeDir) + if wtPath == "" { + t.Fatal("could not extract worktree path") + } + + gitCmd(t, repoDir, "checkout", "main") + if err := os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("main change\n"), 0o644); err != nil { + t.Fatal(err) + } + gitCmd(t, repoDir, "commit", "-am", "change main") + gitCmd(t, repoDir, "push", "origin", "main") + + gitCmd(t, wtPath, "checkout", "-b", "conflict") + if err := os.WriteFile(filepath.Join(wtPath, "README.md"), []byte("worktree change\n"), 0o644); err != nil { + t.Fatal(err) + } + gitCmd(t, wtPath, "commit", "-am", "change worktree") + if out, err := gitCmdResult(t, wtPath, "merge", "origin/main"); err == nil { + t.Fatalf("expected merge conflict, got success:\n%s", out) + } + + _, returnErr, code := runTreehouse(t, repoDir, homeDir, nil, "return", "--force", wtPath) + if code != 0 { + t.Fatalf("return --force failed (code %d): %s", code, returnErr) + } + + if branch, err := gitCmdResult(t, wtPath, "symbolic-ref", "--short", "-q", "HEAD"); err == nil { + t.Fatalf("expected returned worktree HEAD to be detached, got branch %q", branch) + } + if status := gitCmd(t, wtPath, "status", "--porcelain"); status != "" { + t.Fatalf("expected return --force to clean conflicted worktree, got status:\n%s", status) + } + if out, err := gitCmdResult(t, repoDir, "checkout", "main"); err != nil { + t.Fatalf("expected main repo to checkout main after return --force, got: %v\n%s", err, out) + } +} + func TestDestroySpecific(t *testing.T) { repoDir, homeDir := setupTestRepo(t) env := []string{"SHELL=" + exitShellBin} diff --git a/cmd/return_cmd.go b/cmd/return_cmd.go index cba972a..607ccd0 100644 --- a/cmd/return_cmd.go +++ b/cmd/return_cmd.go @@ -44,10 +44,6 @@ var returnCmd = &cobra.Command{ return fmt.Errorf("worktree %s is not managed by treehouse", wtPath) } - if err := git.DetachWorktree(wtPath); err != nil { - return fmt.Errorf("failed to detach worktree HEAD: %w", err) - } - if !returnForce { dirty, _ := git.IsDirty(wtPath) if dirty { @@ -57,6 +53,10 @@ var returnCmd = &cobra.Command{ return nil } } + + if err := git.DetachWorktree(wtPath); err != nil { + return fmt.Errorf("failed to detach worktree HEAD: %w", err) + } } killLingeringProcesses(wtPath) From 7a16545271b93e0b217721452f0102bb10b73c08 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sat, 2 May 2026 13:27:10 -0700 Subject: [PATCH 3/4] no-mistakes(document): Document return force cleanup behavior --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4ccdfc8..85f7dea 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ Treehouse manages a **pool of git worktrees** per repository, stored under `~/.t | Command | Flag | Description | | --------- | --------- | --------------------------------- | -| `return` | `--force` | Skip dirty-check prompt | +| `return` | `--force` | Clean, reset, and return without prompting | | `destroy` | `--force` | Force destroy even if in-use | | `destroy` | `--all` | Destroy all worktrees in the pool | From d2ed5e554f3aad8b466baab1f34ff4f0f289aea3 Mon Sep 17 00:00:00 2001 From: kunchenguid Date: Sat, 2 May 2026 13:28:10 -0700 Subject: [PATCH 4/4] no-mistakes(document): Update return force help text --- cmd/return_cmd.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/return_cmd.go b/cmd/return_cmd.go index 607ccd0..4b087e2 100644 --- a/cmd/return_cmd.go +++ b/cmd/return_cmd.go @@ -71,7 +71,7 @@ var returnCmd = &cobra.Command{ } func init() { - returnCmd.Flags().BoolVar(&returnForce, "force", false, "Skip dirty check prompt") + returnCmd.Flags().BoolVar(&returnForce, "force", false, "Clean, reset, and return without prompting") rootCmd.AddCommand(returnCmd) }