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) <noreply@anthropic.com>
This commit is contained in:
Chris Monardo 2026-04-09 02:57:37 -04:00
parent c33ed436cf
commit 8a99bf1670
6 changed files with 491 additions and 291 deletions

View file

@ -7,7 +7,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"syscall" "syscall"
"github.com/otiai10/copy" "github.com/otiai10/copy"
@ -160,126 +159,6 @@ func doMigrate(ctx context.Context, cmd *cli.Command) error {
return nil 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 <bare-repo>/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 // moveDir attempts to move directory from src to dst, with fallback for cross-device moves
func moveDir(src, dst string) error { func moveDir(src, dst string) error {
// Try atomic rename first // Try atomic rename first

View file

@ -10,37 +10,6 @@ import (
"testing" "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 // Test for the migrate command
func TestDoMigrate(t *testing.T) { func TestDoMigrate(t *testing.T) {
defer func(x string) { _home = x }(_home) defer func(x string) { _home = x }(_home)

View file

@ -4,8 +4,11 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"os/exec"
"strings"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
"github.com/x-motemen/ghq/logger"
) )
func doRm(ctx context.Context, cmd *cli.Command) error { 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) 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 { 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 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 { if err != nil {
return err return err
} }
@ -52,8 +99,39 @@ func doRm(ctx context.Context, cmd *cli.Command) error {
return fmt.Errorf("aborted") return fmt.Errorf("aborted")
} }
if err := os.RemoveAll(p); err != nil { // Removal
return err 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/<name> 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) fmt.Fprintf(w, "Removed %s\n", p)

View file

@ -1,23 +1,23 @@
package main package main
import ( import (
"context"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "strings"
"sync" "sync"
"testing" "testing"
"github.com/x-motemen/ghq/cmdutil" "github.com/x-motemen/ghq/cmdutil"
) )
func TestRmCommand(t *testing.T) { func TestDoRm(t *testing.T) {
defer func(orig func(cmd *exec.Cmd) error) { defer func(orig func(cmd *exec.Cmd) error) {
cmdutil.CommandRunner = orig cmdutil.CommandRunner = orig
}(cmdutil.CommandRunner) }(cmdutil.CommandRunner)
commandRunner := func(cmd *exec.Cmd) error { cmdutil.CommandRunner = func(cmd *exec.Cmd) error { return nil }
return nil
}
defer func(orig string) { _home = orig }(_home) defer func(orig string) { _home = orig }(_home)
_home = "" _home = ""
homeOnce = &sync.Once{} homeOnce = &sync.Once{}
@ -27,142 +27,200 @@ func TestRmCommand(t *testing.T) {
_localRepositoryRoots = nil _localRepositoryRoots = nil
localRepoOnce = &sync.Once{} localRepoOnce = &sync.Once{}
testCases := []struct { t.Run("rm_regular_repo", func(t *testing.T) {
name string repoDir := filepath.Join(tmpd, "github.com", "motemen", "ghqq")
input []string os.MkdirAll(repoDir, 0755)
setup func(t *testing.T) os.WriteFile(filepath.Join(repoDir, ".git"), []byte(""), 0644)
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,
},
}
for _, tc := range testCases { out, _, err := captureWithInput([]string{"y"}, func() {
t.Run(tc.name, func(t *testing.T) { a := newApp()
if tc.skipOnWin && runtime.GOOS == "windows" { if e := a.Run(context.Background(), []string{"ghq", "rm", "motemen/ghqq"}); e != nil {
t.SkipNow() t.Fatal(e)
}
if tc.setup != nil {
tc.setup(t)
}
cmdutil.CommandRunner = commandRunner
if tc.cmdRun != nil {
cmdutil.CommandRunner = tc.cmdRun
} }
}) })
} 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) { t.Run("rm_nonexistent", func(t *testing.T) {
defer func(orig func(cmd *exec.Cmd) error) { a := newApp()
cmdutil.CommandRunner = orig e := a.Run(context.Background(), []string{"ghq", "rm", "motemen/doesnotexist"})
}(cmdutil.CommandRunner) if e == nil {
commandRunner := func(cmd *exec.Cmd) error { t.Error("expected error for nonexistent repo")
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{}
testCases := []struct { t.Run("rm_dryrun", func(t *testing.T) {
name string repoDir := filepath.Join(tmpd, "github.com", "motemen", "dryrm")
input []string os.MkdirAll(repoDir, 0755)
setup func(t *testing.T) os.WriteFile(filepath.Join(repoDir, ".git"), []byte(""), 0644)
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,
},
}
for _, tc := range testCases { out, _, err := capture(func() {
t.Run(tc.name, func(t *testing.T) { a := newApp()
if tc.skipOnWin && runtime.GOOS == "windows" { if e := a.Run(context.Background(), []string{"ghq", "rm", "--dry-run", "motemen/dryrm"}); e != nil {
t.SkipNow() t.Fatal(e)
}
if tc.setup != nil {
tc.setup(t)
}
cmdutil.CommandRunner = commandRunner
if tc.cmdRun != nil {
cmdutil.CommandRunner = tc.cmdRun
} }
}) })
} 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/<name> 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")
}
})
} }

View file

@ -5,6 +5,7 @@ import (
"io" "io"
"net/url" "net/url"
"os" "os"
"os/exec"
"testing" "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)
}
}

184
worktree.go Normal file
View file

@ -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/<name>) rather than a submodule (.git/modules/<name>).
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 <bare-repo>/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
}