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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
160 changes: 158 additions & 2 deletions cmd/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import (
)

var (
treehouseBin string
exitShellBin string
treehouseBin string
exitShellBin string
dirtyMainShellBin string
)

func TestMain(m *testing.M) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
//
Expand Down Expand Up @@ -342,6 +392,112 @@ 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 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}
Expand Down
4 changes: 4 additions & 0 deletions cmd/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
6 changes: 5 additions & 1 deletion cmd/return_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,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)
Expand All @@ -67,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)
}

Expand Down
10 changes: 9 additions & 1 deletion internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading