From 8a99bf1670ad4c770b6d6eeb95d8650d38c1324f Mon Sep 17 00:00:00 2001 From: Chris Monardo Date: Thu, 9 Apr 2026 02:57:37 -0400 Subject: [PATCH] Make ghq rm worktree-aware ghq rm previously used os.RemoveAll with no worktree awareness, which caused two problems: removing a linked worktree left a dangling entry in the parent repo's .git/worktrees/, and removing a repo that had linked worktrees orphaned all of them. Now ghq rm detects both scenarios: - If the target is a linked worktree, use git worktree remove to properly unregister it from the parent repo. - If the target repo has linked worktrees, prune each one before removing the main repo. Extract shared worktree helpers into worktree.go so both cmd_rm.go and cmd_migrate.go can reuse them. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd_migrate.go | 121 ---------------- cmd_migrate_test.go | 31 ----- cmd_rm.go | 86 +++++++++++- cmd_rm_test.go | 328 ++++++++++++++++++++++++++------------------ helpers_test.go | 32 +++++ worktree.go | 184 +++++++++++++++++++++++++ 6 files changed, 491 insertions(+), 291 deletions(-) create mode 100644 worktree.go diff --git a/cmd_migrate.go b/cmd_migrate.go index 67682ee..d2e0075 100644 --- a/cmd_migrate.go +++ b/cmd_migrate.go @@ -7,7 +7,6 @@ import ( "os" "os/exec" "path/filepath" - "strings" "syscall" "github.com/otiai10/copy" @@ -160,126 +159,6 @@ func doMigrate(ctx context.Context, cmd *cli.Command) error { return nil } -// isLinkedGitDir checks whether dir has a .git file (not directory) with a -// gitdir: reference. This is the case for both linked worktrees and -// submodules — either way, the directory cannot be migrated independently. -// When true, it returns the resolved gitdir target path. -func isLinkedGitDir(dir string) (bool, string, error) { - dotGit := filepath.Join(dir, ".git") - fi, err := os.Lstat(dotGit) - if err != nil { - if os.IsNotExist(err) { - return false, "", nil - } - return false, "", err - } - - // .git is a directory → regular repo, safe to migrate - if fi.IsDir() { - return false, "", nil - } - - // .git is a file → linked checkout (worktree or submodule) - content, err := os.ReadFile(dotGit) - if err != nil { - return false, "", err - } - - line := strings.TrimSpace(string(content)) - if !strings.HasPrefix(line, "gitdir: ") { - return false, "", nil - } - - gitdir := strings.TrimPrefix(line, "gitdir: ") - - // Resolve relative paths - if !filepath.IsAbs(gitdir) { - gitdir = filepath.Join(dir, gitdir) - } - gitdir = filepath.Clean(gitdir) - - return true, gitdir, nil -} - -// hasLinkedWorktrees reports whether the Git repository at dir has any linked -// worktrees (entries under .git/worktrees/). -// -// Known limitation: bare repos store worktrees in /worktrees/ -// (no .git/ prefix). This check only looks at .git/worktrees/ and would -// miss bare repo worktrees. -func hasLinkedWorktrees(dir string) (bool, error) { - worktreesDir := filepath.Join(dir, ".git", "worktrees") - entries, err := os.ReadDir(worktreesDir) - if err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, err - } - for _, e := range entries { - if e.IsDir() { - return true, nil - } - } - return false, nil -} - -// repairWorktreeBackPointers reads .git/worktrees/*/gitdir in destDir and -// returns the current worktree working-directory paths. For worktrees that -// were inside the old repo directory (oldDir), it rewrites the gitdir file -// to reflect the new location so that a subsequent "git worktree repair" -// can match them. -func repairWorktreeBackPointers(oldDir, destDir string) ([]string, error) { - worktreesDir := filepath.Join(destDir, ".git", "worktrees") - entries, err := os.ReadDir(worktreesDir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - - // Normalize paths to forward slashes for comparison, since Git uses forward slashes - // in gitdir files even on Windows - oldPrefixNorm := filepath.ToSlash(oldDir) + "/" - var paths []string - for _, e := range entries { - if !e.IsDir() { - continue - } - gitdirFile := filepath.Join(worktreesDir, e.Name(), "gitdir") - content, err := os.ReadFile(gitdirFile) - if err != nil { - continue // skip entries without a gitdir file - } - wtPath := strings.TrimSpace(string(content)) - if wtPath == "" { - continue - } - - // Internal worktree: moved along with the repo → fix back-pointer - // Normalize wtPath for comparison since Git writes forward slashes on all platforms - wtPathNorm := filepath.ToSlash(wtPath) - if strings.HasPrefix(wtPathNorm, oldPrefixNorm) { - // Compute new path: take the relative portion and join with destDir - relativePart := wtPathNorm[len(oldPrefixNorm):] - newPath := filepath.ToSlash(filepath.Join(destDir, relativePart)) - if err := os.WriteFile(gitdirFile, []byte(newPath+"\n"), 0644); err != nil { - return nil, fmt.Errorf("failed to rewrite gitdir for worktree %s: %w", e.Name(), err) - } - wtPath = newPath - } - - // The gitdir file stores the path to the worktree's .git file - // (e.g., "/path/to/wt/.git"), but git worktree repair expects - // the worktree working directory (e.g., "/path/to/wt"). - // Since wtPath is normalized to forward slashes, trim "/.git" - wtDir := strings.TrimSuffix(wtPath, "/.git") - paths = append(paths, wtDir) - } - return paths, nil -} - // moveDir attempts to move directory from src to dst, with fallback for cross-device moves func moveDir(src, dst string) error { // Try atomic rename first diff --git a/cmd_migrate_test.go b/cmd_migrate_test.go index 33f9dc6..ff78c22 100644 --- a/cmd_migrate_test.go +++ b/cmd_migrate_test.go @@ -10,37 +10,6 @@ import ( "testing" ) -// initGitRepo creates a git repo at dir with the given remote URL and an -// initial empty commit. It returns dir for convenience. -func initGitRepo(t *testing.T, dir, remoteURL string) string { - t.Helper() - os.MkdirAll(dir, 0755) - - for _, args := range [][]string{ - {"init"}, - {"remote", "add", "origin", remoteURL}, - {"-c", "user.name=test", "-c", "user.email=test@test.com", - "commit", "--allow-empty", "-m", "init"}, - } { - c := exec.Command("git", args...) - c.Dir = dir - if out, err := c.CombinedOutput(); err != nil { - t.Fatalf("git %s: %v\n%s", args[0], err, out) - } - } - return dir -} - -// addWorktree creates a git worktree at wtDir branching from the repo at repoDir. -func addWorktree(t *testing.T, repoDir, wtDir, branch string) { - t.Helper() - c := exec.Command("git", "worktree", "add", "-b", branch, wtDir) - c.Dir = repoDir - if out, err := c.CombinedOutput(); err != nil { - t.Fatalf("git worktree add: %v\n%s", err, out) - } -} - // Test for the migrate command func TestDoMigrate(t *testing.T) { defer func(x string) { _home = x }(_home) diff --git a/cmd_rm.go b/cmd_rm.go index 69c0dfa..5b9d883 100644 --- a/cmd_rm.go +++ b/cmd_rm.go @@ -4,8 +4,11 @@ import ( "context" "fmt" "os" + "os/exec" + "strings" "github.com/urfave/cli/v3" + "github.com/x-motemen/ghq/logger" ) func doRm(ctx context.Context, cmd *cli.Command) error { @@ -39,12 +42,56 @@ func doRm(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("directory %q does not exist", p) } + // Scenario A: Is this path itself a linked worktree? + isWorktree := false + var gitdirTarget string + if linked, target, linkErr := isLinkedGitDir(p); linkErr != nil { + return fmt.Errorf("failed to check worktree status: %w", linkErr) + } else if linked && isWorktreeGitDir(target) { + isWorktree = true + gitdirTarget = target + } + + // Scenario B: Does this repo have linked worktrees? + var worktreePaths []string + if !isWorktree { + if hasWt, wtErr := hasLinkedWorktrees(p); wtErr != nil { + return fmt.Errorf("failed to check for linked worktrees: %w", wtErr) + } else if hasWt { + worktreePaths, err = listLinkedWorktreePaths(p) + if err != nil { + return fmt.Errorf("failed to list linked worktrees: %w", err) + } + } + } + + // Dry-run if dry { - fmt.Fprintf(w, "Would remove %s\n", p) + if isWorktree { + fmt.Fprintf(w, "Would remove worktree %s (linked to %s)\n", p, gitdirTarget) + } else if len(worktreePaths) > 0 { + fmt.Fprintf(w, "Would remove %s and its %d linked worktree(s):\n", p, len(worktreePaths)) + for _, wt := range worktreePaths { + fmt.Fprintf(w, " %s\n", wt) + } + } else { + fmt.Fprintf(w, "Would remove %s\n", p) + } return nil } - ok, err = confirm(fmt.Sprintf("Remove %s?", p)) + // Confirmation + var confirmMsg string + if isWorktree { + confirmMsg = fmt.Sprintf("Remove worktree %s?", p) + } else if len(worktreePaths) > 0 { + confirmMsg = fmt.Sprintf("Remove %s and its %d linked worktree(s)?\n %s", + p, len(worktreePaths), strings.Join(worktreePaths, "\n ")) + } else { + confirmMsg = fmt.Sprintf("Remove %s?", p) + } + + ok, err = confirm(confirmMsg) if err != nil { return err } @@ -52,8 +99,39 @@ func doRm(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("aborted") } - if err := os.RemoveAll(p); err != nil { - return err + // Removal + if isWorktree { + // Use git worktree remove to properly unregister from parent repo. + // Run from inside the worktree so git can read its .git file to + // discover the main repository. + gitCmd := exec.Command("git", "worktree", "remove", "--force", p) + gitCmd.Dir = p + if out, gitErr := gitCmd.CombinedOutput(); gitErr != nil { + logger.Log("warning", fmt.Sprintf("git worktree remove failed: %v\n%s", gitErr, out)) + logger.Log("warning", "falling back to direct removal") + if err := os.RemoveAll(p); err != nil { + return err + } + // Best-effort cleanup of dangling .git/worktrees/ entry + if gitdirTarget != "" { + os.RemoveAll(gitdirTarget) + } + } + } else { + // Prune linked worktrees before removing main repo + for _, wt := range worktreePaths { + if _, statErr := os.Stat(wt); os.IsNotExist(statErr) { + continue // already gone + } + gitCmd := exec.Command("git", "worktree", "remove", "--force", wt) + gitCmd.Dir = p + if out, gitErr := gitCmd.CombinedOutput(); gitErr != nil { + logger.Log("warning", fmt.Sprintf("failed to remove worktree %s: %v\n%s", wt, gitErr, out)) + } + } + if err := os.RemoveAll(p); err != nil { + return err + } } fmt.Fprintf(w, "Removed %s\n", p) diff --git a/cmd_rm_test.go b/cmd_rm_test.go index 74d0b94..2d31105 100644 --- a/cmd_rm_test.go +++ b/cmd_rm_test.go @@ -1,23 +1,23 @@ package main import ( + "context" "os" "os/exec" "path/filepath" - "runtime" + "strings" "sync" "testing" "github.com/x-motemen/ghq/cmdutil" ) -func TestRmCommand(t *testing.T) { +func TestDoRm(t *testing.T) { defer func(orig func(cmd *exec.Cmd) error) { cmdutil.CommandRunner = orig }(cmdutil.CommandRunner) - commandRunner := func(cmd *exec.Cmd) error { - return nil - } + cmdutil.CommandRunner = func(cmd *exec.Cmd) error { return nil } + defer func(orig string) { _home = orig }(_home) _home = "" homeOnce = &sync.Once{} @@ -27,142 +27,200 @@ func TestRmCommand(t *testing.T) { _localRepositoryRoots = nil localRepoOnce = &sync.Once{} - testCases := []struct { - name string - input []string - setup func(t *testing.T) - expectErr bool - cmdRun func(cmd *exec.Cmd) error - skipOnWin bool - }{ - { - name: "simple", - input: []string{"rm", "motemen/ghqq"}, - setup: func(t *testing.T) { - os.MkdirAll(filepath.Join(tmpd, "github.com", "motemen", "ghqq"), 0755) - }, - expectErr: false, - }, - { - name: "empty directory", - input: []string{"rm", "motemen/ghqqq"}, - setup: func(t *testing.T) {}, - expectErr: true, - }, - { - name: "incorrect repository name", - input: []string{"rm", "example.com/goooo/gooo"}, - setup: func(t *testing.T) { - os.MkdirAll(filepath.Join(tmpd, "github.com", "motemen", "ghqq"), 0755) - }, - expectErr: true, - }, - { - name: "permission denied", - input: []string{"rm", "motemen/ghqq"}, - setup: func(t *testing.T) { - f := filepath.Join(tmpd, "github.com", "motemen", "ghqq") - os.MkdirAll(f, 0000) - t.Cleanup(func() { - os.Chmod(f, 0755) - }) - }, - expectErr: true, - }, - } + t.Run("rm_regular_repo", func(t *testing.T) { + repoDir := filepath.Join(tmpd, "github.com", "motemen", "ghqq") + os.MkdirAll(repoDir, 0755) + os.WriteFile(filepath.Join(repoDir, ".git"), []byte(""), 0644) - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - if tc.skipOnWin && runtime.GOOS == "windows" { - t.SkipNow() - } - - if tc.setup != nil { - tc.setup(t) - } - - cmdutil.CommandRunner = commandRunner - if tc.cmdRun != nil { - cmdutil.CommandRunner = tc.cmdRun + out, _, err := captureWithInput([]string{"y"}, func() { + a := newApp() + if e := a.Run(context.Background(), []string{"ghq", "rm", "motemen/ghqq"}); e != nil { + t.Fatal(e) } }) - } -} + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "Removed") { + t.Errorf("expected 'Removed' in output, got: %s", out) + } + if _, err := os.Stat(repoDir); !os.IsNotExist(err) { + t.Error("repo directory should be removed") + } + }) -func TestRmDryRunCommand(t *testing.T) { - defer func(orig func(cmd *exec.Cmd) error) { - cmdutil.CommandRunner = orig - }(cmdutil.CommandRunner) - commandRunner := func(cmd *exec.Cmd) error { - return nil - } - defer func(orig string) { _home = orig }(_home) - _home = "" - homeOnce = &sync.Once{} - tmpd := newTempDir(t) - defer func(orig []string) { _localRepositoryRoots = orig }(_localRepositoryRoots) - setEnv(t, envGhqRoot, tmpd) - _localRepositoryRoots = nil - localRepoOnce = &sync.Once{} + t.Run("rm_nonexistent", func(t *testing.T) { + a := newApp() + e := a.Run(context.Background(), []string{"ghq", "rm", "motemen/doesnotexist"}) + if e == nil { + t.Error("expected error for nonexistent repo") + } + }) - testCases := []struct { - name string - input []string - setup func(t *testing.T) - expectErr bool - cmdRun func(cmd *exec.Cmd) error - skipOnWin bool - }{ - { - name: "simple", - input: []string{"rm", "--dry-run", "motemen/ghqq"}, - setup: func(t *testing.T) { - os.MkdirAll(filepath.Join(tmpd, "github.com", "motemen", "ghqq"), 0755) - }, - expectErr: false, - }, - { - name: "empty directory", - input: []string{"rm", "--dry-run", "motemen/ghqqq"}, - setup: func(t *testing.T) {}, - expectErr: true, - }, - { - name: "incorrect repository name", - input: []string{"rm", "--dry-run", "example.com/goooo/gooo"}, - setup: func(t *testing.T) { - os.MkdirAll(filepath.Join(tmpd, "github.com", "motemen", "ghqq"), 0755) - }, - expectErr: true, - }, - { - name: "permission denied", - input: []string{"rm", "--dry-run", "motemen/ghqq"}, - setup: func(t *testing.T) { - f := filepath.Join(tmpd, "github.com", "motemen", "ghqq") - os.MkdirAll(f, 0000) - t.Cleanup(func() { - os.Chmod(f, 0755) - }) - }, - expectErr: true, - }, - } + t.Run("rm_dryrun", func(t *testing.T) { + repoDir := filepath.Join(tmpd, "github.com", "motemen", "dryrm") + os.MkdirAll(repoDir, 0755) + os.WriteFile(filepath.Join(repoDir, ".git"), []byte(""), 0644) - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - if tc.skipOnWin && runtime.GOOS == "windows" { - t.SkipNow() - } - - if tc.setup != nil { - tc.setup(t) - } - - cmdutil.CommandRunner = commandRunner - if tc.cmdRun != nil { - cmdutil.CommandRunner = tc.cmdRun + out, _, err := capture(func() { + a := newApp() + if e := a.Run(context.Background(), []string{"ghq", "rm", "--dry-run", "motemen/dryrm"}); e != nil { + t.Fatal(e) } }) - } + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "Would remove") { + t.Errorf("expected 'Would remove' in output, got: %s", out) + } + if _, err := os.Stat(repoDir); os.IsNotExist(err) { + t.Error("repo should still exist after dry-run") + } + }) + + t.Run("rm_linked_worktree", func(t *testing.T) { + // Create main repo inside ghq root + mainDir := initGitRepo(t, filepath.Join(tmpd, "github.com", "wt-rm", "main"), + "https://github.com/wt-rm/main.git") + + // Create worktree registered under ghq root so ghq rm can resolve it + wtDir := filepath.Join(tmpd, "github.com", "wt-rm", "wt-linked") + addWorktree(t, mainDir, wtDir, "wt-rm-branch") + + _, _, err := captureWithInput([]string{"y"}, func() { + a := newApp() + if e := a.Run(context.Background(), []string{"ghq", "rm", "wt-rm/wt-linked"}); e != nil { + t.Fatal(e) + } + }) + if err != nil { + t.Fatal(err) + } + + // Worktree directory should be gone + if _, err := os.Stat(wtDir); !os.IsNotExist(err) { + t.Error("worktree directory should be removed") + } + + // Parent repo's .git/worktrees/ should be cleaned up + wtEntry := filepath.Join(mainDir, ".git", "worktrees", "wt-linked") + if _, err := os.Stat(wtEntry); !os.IsNotExist(err) { + t.Error("parent repo's worktree entry should be cleaned up") + } + + // Parent repo should still work + c := exec.Command("git", "status") + c.Dir = mainDir + if out, err := c.CombinedOutput(); err != nil { + t.Errorf("git status in parent repo failed: %v\n%s", err, out) + } + }) + + t.Run("rm_dryrun_worktree", func(t *testing.T) { + mainDir := initGitRepo(t, filepath.Join(tmpd, "github.com", "wt-dry", "main"), + "https://github.com/wt-dry/main.git") + wtDir := filepath.Join(tmpd, "github.com", "wt-dry", "wt-linked") + addWorktree(t, mainDir, wtDir, "wt-dry-branch") + + out, _, err := capture(func() { + a := newApp() + if e := a.Run(context.Background(), []string{"ghq", "rm", "--dry-run", "wt-dry/wt-linked"}); e != nil { + t.Fatal(e) + } + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "Would remove worktree") { + t.Errorf("expected 'Would remove worktree' in output, got: %s", out) + } + if _, err := os.Stat(wtDir); os.IsNotExist(err) { + t.Error("worktree should still exist after dry-run") + } + }) + + t.Run("rm_repo_with_linked_worktrees", func(t *testing.T) { + mainDir := initGitRepo(t, filepath.Join(tmpd, "github.com", "wt-parent", "repo"), + "https://github.com/wt-parent/repo.git") + + // Create two external worktrees + wt1 := filepath.Join(tmpd, "external-wt1") + wt2 := filepath.Join(tmpd, "external-wt2") + addWorktree(t, mainDir, wt1, "branch1") + addWorktree(t, mainDir, wt2, "branch2") + + _, _, err := captureWithInput([]string{"y"}, func() { + a := newApp() + if e := a.Run(context.Background(), []string{"ghq", "rm", "wt-parent/repo"}); e != nil { + t.Fatal(e) + } + }) + if err != nil { + t.Fatal(err) + } + + // Main repo should be gone + if _, err := os.Stat(mainDir); !os.IsNotExist(err) { + t.Error("main repo should be removed") + } + + // Both worktree directories should be gone + if _, err := os.Stat(wt1); !os.IsNotExist(err) { + t.Error("worktree 1 should be removed") + } + if _, err := os.Stat(wt2); !os.IsNotExist(err) { + t.Error("worktree 2 should be removed") + } + }) + + t.Run("rm_dryrun_with_linked_worktrees", func(t *testing.T) { + mainDir := initGitRepo(t, filepath.Join(tmpd, "github.com", "wt-dry2", "repo"), + "https://github.com/wt-dry2/repo.git") + + wt1 := filepath.Join(tmpd, "dry-wt1") + addWorktree(t, mainDir, wt1, "dry-branch1") + + out, _, err := capture(func() { + a := newApp() + if e := a.Run(context.Background(), []string{"ghq", "rm", "--dry-run", "wt-dry2/repo"}); e != nil { + t.Fatal(e) + } + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "linked worktree") { + t.Errorf("expected 'linked worktree' in output, got: %s", out) + } + if _, err := os.Stat(mainDir); os.IsNotExist(err) { + t.Error("repo should still exist after dry-run") + } + }) + + t.Run("rm_repo_with_already_deleted_worktree", func(t *testing.T) { + mainDir := initGitRepo(t, filepath.Join(tmpd, "github.com", "wt-gone", "repo"), + "https://github.com/wt-gone/repo.git") + + wt := filepath.Join(tmpd, "gone-wt") + addWorktree(t, mainDir, wt, "gone-branch") + + // Manually delete the worktree directory (simulating user deleting it) + os.RemoveAll(wt) + + _, _, err := captureWithInput([]string{"y"}, func() { + a := newApp() + if e := a.Run(context.Background(), []string{"ghq", "rm", "wt-gone/repo"}); e != nil { + t.Fatal(e) + } + }) + if err != nil { + t.Fatal(err) + } + + // Main repo should be gone regardless + if _, err := os.Stat(mainDir); !os.IsNotExist(err) { + t.Error("main repo should be removed even with pre-deleted worktree") + } + }) } diff --git a/helpers_test.go b/helpers_test.go index 81dde81..9b64f47 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -5,6 +5,7 @@ import ( "io" "net/url" "os" + "os/exec" "testing" ) @@ -135,3 +136,34 @@ func setEnv(t *testing.T, key, val string) { } }) } + +// initGitRepo creates a git repo at dir with the given remote URL and an +// initial empty commit. It returns dir for convenience. +func initGitRepo(t *testing.T, dir, remoteURL string) string { + t.Helper() + os.MkdirAll(dir, 0755) + + for _, args := range [][]string{ + {"init"}, + {"remote", "add", "origin", remoteURL}, + {"-c", "user.name=test", "-c", "user.email=test@test.com", + "commit", "--allow-empty", "-m", "init"}, + } { + c := exec.Command("git", args...) + c.Dir = dir + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", args[0], err, out) + } + } + return dir +} + +// addWorktree creates a git worktree at wtDir branching from the repo at repoDir. +func addWorktree(t *testing.T, repoDir, wtDir, branch string) { + t.Helper() + c := exec.Command("git", "worktree", "add", "-b", branch, wtDir) + c.Dir = repoDir + if out, err := c.CombinedOutput(); err != nil { + t.Fatalf("git worktree add: %v\n%s", err, out) + } +} diff --git a/worktree.go b/worktree.go new file mode 100644 index 0000000..a0da4ff --- /dev/null +++ b/worktree.go @@ -0,0 +1,184 @@ +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" +) + +// isNotADirectory returns true if err indicates a "not a directory" condition +// (e.g., trying to traverse a path component that is a regular file). +func isNotADirectory(err error) bool { + return errors.Is(err, syscall.ENOTDIR) +} + +// isLinkedGitDir checks whether dir has a .git file (not directory) with a +// gitdir: reference. This is the case for both linked worktrees and +// submodules — either way, the directory cannot be migrated independently. +// When true, it returns the resolved gitdir target path. +func isLinkedGitDir(dir string) (bool, string, error) { + dotGit := filepath.Join(dir, ".git") + fi, err := os.Lstat(dotGit) + if err != nil { + if os.IsNotExist(err) { + return false, "", nil + } + return false, "", err + } + + // .git is a directory → regular repo, safe to migrate + if fi.IsDir() { + return false, "", nil + } + + // .git is a file → linked checkout (worktree or submodule) + content, err := os.ReadFile(dotGit) + if err != nil { + return false, "", err + } + + line := strings.TrimSpace(string(content)) + if !strings.HasPrefix(line, "gitdir: ") { + return false, "", nil + } + + gitdir := strings.TrimPrefix(line, "gitdir: ") + + // Resolve relative paths + if !filepath.IsAbs(gitdir) { + gitdir = filepath.Join(dir, gitdir) + } + gitdir = filepath.Clean(gitdir) + + return true, gitdir, nil +} + +// isWorktreeGitDir returns true if gitdirTarget looks like a worktree entry +// (.git/worktrees/) rather than a submodule (.git/modules/). +func isWorktreeGitDir(gitdirTarget string) bool { + return strings.Contains(filepath.ToSlash(gitdirTarget), ".git/worktrees/") +} + +// hasLinkedWorktrees reports whether the Git repository at dir has any linked +// worktrees (entries under .git/worktrees/). +// +// Known limitation: bare repos store worktrees in /worktrees/ +// (no .git/ prefix). This check only looks at .git/worktrees/ and would +// miss bare repo worktrees. +func hasLinkedWorktrees(dir string) (bool, error) { + worktreesDir := filepath.Join(dir, ".git", "worktrees") + entries, err := os.ReadDir(worktreesDir) + if err != nil { + if os.IsNotExist(err) || isNotADirectory(err) { + return false, nil + } + return false, err + } + for _, e := range entries { + if e.IsDir() { + return true, nil + } + } + return false, nil +} + +// listLinkedWorktreePaths reads .git/worktrees/*/gitdir in dir and returns +// the worktree working-directory paths. +func listLinkedWorktreePaths(dir string) ([]string, error) { + worktreesDir := filepath.Join(dir, ".git", "worktrees") + entries, err := os.ReadDir(worktreesDir) + if err != nil { + if os.IsNotExist(err) || isNotADirectory(err) { + return nil, nil + } + return nil, err + } + + var paths []string + for _, e := range entries { + if !e.IsDir() { + continue + } + gitdirFile := filepath.Join(worktreesDir, e.Name(), "gitdir") + content, err := os.ReadFile(gitdirFile) + if err != nil { + continue + } + wtPath := strings.TrimSpace(string(content)) + if wtPath == "" { + continue + } + + // Resolve to native path + wtPath = filepath.FromSlash(wtPath) + if !filepath.IsAbs(wtPath) { + wtPath = filepath.Join(worktreesDir, e.Name(), wtPath) + } + wtPath = filepath.Clean(wtPath) + + // The gitdir file stores the path to the worktree's .git file + // (e.g., "/path/to/wt/.git"); strip trailing /.git to get working dir. + wtDir := strings.TrimSuffix(filepath.ToSlash(wtPath), "/.git") + paths = append(paths, filepath.FromSlash(wtDir)) + } + return paths, nil +} + +// repairWorktreeBackPointers reads .git/worktrees/*/gitdir in destDir and +// returns the current worktree working-directory paths. For worktrees that +// were inside the old repo directory (oldDir), it rewrites the gitdir file +// to reflect the new location so that a subsequent "git worktree repair" +// can match them. +func repairWorktreeBackPointers(oldDir, destDir string) ([]string, error) { + worktreesDir := filepath.Join(destDir, ".git", "worktrees") + entries, err := os.ReadDir(worktreesDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + // Normalize paths to forward slashes for comparison, since Git uses forward slashes + // in gitdir files even on Windows + oldPrefixNorm := filepath.ToSlash(oldDir) + "/" + var paths []string + for _, e := range entries { + if !e.IsDir() { + continue + } + gitdirFile := filepath.Join(worktreesDir, e.Name(), "gitdir") + content, err := os.ReadFile(gitdirFile) + if err != nil { + continue // skip entries without a gitdir file + } + wtPath := strings.TrimSpace(string(content)) + if wtPath == "" { + continue + } + + // Internal worktree: moved along with the repo → fix back-pointer + // Normalize wtPath for comparison since Git writes forward slashes on all platforms + wtPathNorm := filepath.ToSlash(wtPath) + if strings.HasPrefix(wtPathNorm, oldPrefixNorm) { + // Compute new path: take the relative portion and join with destDir + relativePart := wtPathNorm[len(oldPrefixNorm):] + newPath := filepath.ToSlash(filepath.Join(destDir, relativePart)) + if err := os.WriteFile(gitdirFile, []byte(newPath+"\n"), 0644); err != nil { + return nil, fmt.Errorf("failed to rewrite gitdir for worktree %s: %w", e.Name(), err) + } + wtPath = newPath + } + + // The gitdir file stores the path to the worktree's .git file + // (e.g., "/path/to/wt/.git"), but git worktree repair expects + // the worktree working directory (e.g., "/path/to/wt"). + // Since wtPath is normalized to forward slashes, trim "/.git" + wtDir := strings.TrimSuffix(wtPath, "/.git") + paths = append(paths, wtDir) + } + return paths, nil +}