mirror of
https://github.com/x-motemen/ghq.git
synced 2026-09-10 07:26:27 -04:00
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:
parent
c33ed436cf
commit
8a99bf1670
121
cmd_migrate.go
121
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 <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
|
||||
func moveDir(src, dst string) error {
|
||||
// Try atomic rename first
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
86
cmd_rm.go
86
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/<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)
|
||||
|
|
|
|||
328
cmd_rm_test.go
328
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/<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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
184
worktree.go
Normal file
184
worktree.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue