Merge pull request #449 from atusy/feat-git-migrate-be-aware-of-worktree-and-submodule

feat(migrate): make worktree and submodule aware
This commit is contained in:
Masayuki Matsuki 2026-02-17 15:43:11 +09:00 committed by GitHub
commit 8f58aeffe0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 389 additions and 1 deletions

View file

@ -4,11 +4,14 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"github.com/otiai10/copy"
"github.com/urfave/cli/v2"
"github.com/x-motemen/ghq/logger"
)
func doMigrate(c *cli.Context) error {
@ -42,6 +45,17 @@ func doMigrate(c *cli.Context) error {
return fmt.Errorf("failed to detect VCS backend in %q", absDir)
}
// Refuse to migrate a linked Git checkout (worktree or submodule).
// These have a .git file referencing a parent repo; moving them alone
// breaks the link.
if vcsBackend == GitBackend {
if linked, target, err := isLinkedGitDir(absDir); err != nil {
return fmt.Errorf("failed to check .git link status: %w", err)
} else if linked {
return fmt.Errorf("directory %q has a .git file linking to %q; it is a worktree or submodule and cannot be migrated independently", absDir, target)
}
}
// Get remote URL
if vcsBackend.RemoteURL == nil {
return fmt.Errorf("migrate is not supported for this VCS backend")
@ -77,9 +91,21 @@ func doMigrate(c *cli.Context) error {
return fmt.Errorf("failed to check destination directory: %w", err)
}
// Check for linked worktrees before dry-run return so we can report them
var hasWorktrees bool
if vcsBackend == GitBackend {
hasWorktrees, err = hasLinkedWorktrees(absDir)
if err != nil {
return fmt.Errorf("failed to check for linked worktrees: %w", err)
}
}
// Dry-run mode
if dry {
fmt.Fprintf(w, "Would migrate %s to %s\n", absDir, destPath)
if hasWorktrees {
fmt.Fprintf(w, "Would run 'git worktree repair' to update linked worktrees\n")
}
return nil
}
@ -105,10 +131,147 @@ func doMigrate(c *cli.Context) error {
return fmt.Errorf("failed to move repository: %w", err)
}
// Repair linked worktrees so their .git files reference the new location.
//
// For each worktree, two pointers exist:
// back-pointer: .git/worktrees/<name>/gitdir → worktree working dir
// forward ref: <worktree>/.git → main repo's .git/worktrees/<name>
//
// External worktrees (outside the repo) didn't move, so only the forward
// ref is stale. Internal worktrees (inside the repo) moved along with
// the repo, so BOTH pointers are stale. We fix the back-pointers first
// so that "git worktree repair" can match entries to update the forward refs.
if hasWorktrees {
wtPaths, wtErr := repairWorktreeBackPointers(absDir, destPath)
if wtErr != nil {
logger.Log("warning", fmt.Sprintf("failed to discover linked worktree paths: %v", wtErr))
} else if len(wtPaths) > 0 {
args := append([]string{"worktree", "repair"}, wtPaths...)
cmd := exec.Command("git", args...)
cmd.Dir = destPath
if out, err := cmd.CombinedOutput(); err != nil {
logger.Log("warning", fmt.Sprintf("git worktree repair failed: %v\n%s", err, out))
}
}
}
fmt.Fprintln(w, destPath)
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
}
oldPrefix := oldDir + string(filepath.Separator)
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
if strings.HasPrefix(wtPath, oldPrefix) {
newPath := filepath.Join(destDir, wtPath[len(oldDir):])
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").
wtDir := strings.TrimSuffix(wtPath, string(filepath.Separator)+".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

View file

@ -9,6 +9,37 @@ 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)
@ -77,6 +108,107 @@ func TestDoMigrate(t *testing.T) {
t.Error("source should still exist")
}
})
// Test case: migrate repo with linked worktrees repairs forward references
t.Run("migrate_with_linked_worktrees", func(t *testing.T) {
srcdir := initGitRepo(t, filepath.Join(tmpdir, "sources_wt", "main"),
"https://github.com/wt-user/main.git")
wtDir := filepath.Join(tmpdir, "sources_wt", "wt")
addWorktree(t, srcdir, wtDir, "wt-branch")
a := newApp()
e := a.Run([]string{"ghq", "migrate", "-y", srcdir})
if e != nil {
t.Fatal(e)
}
dest := filepath.Join(tmpdir, "github.com", "wt-user", "main")
if _, err := os.Stat(dest); os.IsNotExist(err) {
t.Error("dest not found")
}
// Verify worktree's .git file has exact gitdir: reference to new location
content, err := os.ReadFile(filepath.Join(wtDir, ".git"))
if err != nil {
t.Fatal(err)
}
wantGitdir := "gitdir: " + filepath.Join(dest, ".git", "worktrees", "wt")
if got := strings.TrimSpace(string(content)); got != wantGitdir {
t.Errorf("worktree .git:\n got: %s\n want: %s", got, wantGitdir)
}
// Verify git status works in the worktree after migration
c := exec.Command("git", "status")
c.Dir = wtDir
if out, err := c.CombinedOutput(); err != nil {
t.Errorf("git status in worktree failed after migration: %v\n%s", err, out)
}
})
// Test case: dry run with linked worktrees mentions repair
t.Run("migrate_dryrun_with_worktrees", func(t *testing.T) {
srcdir := initGitRepo(t, filepath.Join(tmpdir, "sources_wt_dry", "main"),
"https://github.com/wt-dry/proj.git")
wtDir := filepath.Join(tmpdir, "sources_wt_dry", "wt")
addWorktree(t, srcdir, wtDir, "wt-dry-branch")
out, _, err := capture(func() {
a := newApp()
a.Run([]string{"ghq", "migrate", "--dry-run", srcdir})
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "Would migrate") {
t.Errorf("expected dry-run migration message, got: %s", out)
}
if !strings.Contains(out, "worktree repair") {
t.Errorf("expected worktree repair mention in dry-run, got: %s", out)
}
if _, err := os.Stat(srcdir); os.IsNotExist(err) {
t.Error("source should still exist in dry-run mode")
}
})
// Test case: worktree inside the repo directory moves along with it
t.Run("migrate_with_internal_worktree", func(t *testing.T) {
srcdir := initGitRepo(t, filepath.Join(tmpdir, "sources_wt_int", "main"),
"https://github.com/wt-int/proj.git")
// Create worktree INSIDE the repo directory
wtDir := filepath.Join(srcdir, ".worktrees", "feat")
addWorktree(t, srcdir, wtDir, "wt-int-branch")
a := newApp()
e := a.Run([]string{"ghq", "migrate", "-y", srcdir})
if e != nil {
t.Fatal(e)
}
dest := filepath.Join(tmpdir, "github.com", "wt-int", "proj")
if _, err := os.Stat(dest); os.IsNotExist(err) {
t.Error("dest not found")
}
// The worktree moved with the repo — verify its .git file
// has exact gitdir: reference to the new main repo location
newWtDir := filepath.Join(dest, ".worktrees", "feat")
content, err := os.ReadFile(filepath.Join(newWtDir, ".git"))
if err != nil {
t.Fatal(err)
}
wantGitdir := "gitdir: " + filepath.Join(dest, ".git", "worktrees", "feat")
if got := strings.TrimSpace(string(content)); got != wantGitdir {
t.Errorf("internal worktree .git:\n got: %s\n want: %s", got, wantGitdir)
}
// Verify git status works in the internal worktree after migration
c := exec.Command("git", "status")
c.Dir = newWtDir
if out, err := c.CombinedOutput(); err != nil {
t.Errorf("git status in internal worktree failed after migration: %v\n%s", err, out)
}
})
}
func TestMigrateEdgeCases(t *testing.T) {
@ -137,6 +269,25 @@ func TestMigrateEdgeCases(t *testing.T) {
}
})
t.Run("migrate_worktree_refused", func(t *testing.T) {
srcdir := initGitRepo(t, filepath.Join(tmpdir, "src_wt_ref", "main"),
"https://github.com/wt-ref/proj.git")
wtDir := filepath.Join(tmpdir, "src_wt_ref", "wt")
addWorktree(t, srcdir, wtDir, "wt-ref-branch")
a := newApp()
e := a.Run([]string{"ghq", "migrate", "-y", wtDir})
if e == nil {
t.Fatal("expected error migrating a worktree")
}
if !strings.Contains(e.Error(), "worktree or submodule") {
t.Errorf("error should mention worktree or submodule, got: %v", e)
}
if !strings.Contains(e.Error(), ".git") {
t.Errorf("error should mention .git link target, got: %v", e)
}
})
t.Run("unsupported_vcs", func(t *testing.T) {
// Create a CVS repository structure to test unsupported VCS
srcdir := filepath.Join(tmpdir, "src6", "cvs-repo")
@ -231,3 +382,77 @@ func TestMoveDir(t *testing.T) {
// higher-level integration tests / environments that provide multiple
// mounts, rather than in this unit test.
}
func TestIsLinkedGitDir(t *testing.T) {
tmpdir := newTempDir(t)
t.Run("regular_repo", func(t *testing.T) {
dir := filepath.Join(tmpdir, "regular")
os.MkdirAll(dir, 0755)
c := exec.Command("git", "init")
c.Dir = dir
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git init: %v\n%s", err, out)
}
linked, _, err := isLinkedGitDir(dir)
if err != nil {
t.Fatal(err)
}
if linked {
t.Error("regular repo should not be detected as linked")
}
})
t.Run("no_git", func(t *testing.T) {
dir := filepath.Join(tmpdir, "nogit")
os.MkdirAll(dir, 0755)
linked, _, err := isLinkedGitDir(dir)
if err != nil {
t.Fatal(err)
}
if linked {
t.Error("directory without .git should not be detected as linked")
}
})
t.Run("submodule_gitfile", func(t *testing.T) {
dir := filepath.Join(tmpdir, "submod")
os.MkdirAll(dir, 0755)
// Simulate a submodule's .git file pointing to .git/modules/
os.WriteFile(filepath.Join(dir, ".git"),
[]byte("gitdir: ../.git/modules/submod\n"), 0644)
linked, target, err := isLinkedGitDir(dir)
if err != nil {
t.Fatal(err)
}
if !linked {
t.Error("submodule should be detected as linked")
}
if !strings.Contains(target, "modules") {
t.Errorf("target should reference modules dir, got: %s", target)
}
})
t.Run("actual_worktree", func(t *testing.T) {
mainDir := initGitRepo(t, filepath.Join(tmpdir, "wt_main"),
"https://github.com/dummy/wt-main.git")
wtDir := filepath.Join(tmpdir, "wt_linked")
addWorktree(t, mainDir, wtDir, "wt-test")
linked, target, err := isLinkedGitDir(wtDir)
if err != nil {
t.Fatal(err)
}
if !linked {
t.Error("worktree should be detected as linked")
}
if !strings.Contains(target, "worktrees") {
t.Errorf("target should reference worktrees dir, got: %s", target)
}
})
}

2
go.mod
View file

@ -6,6 +6,7 @@ require (
github.com/Songmu/gitconfig v0.2.2
github.com/mattn/go-isatty v0.0.20
github.com/motemen/go-colorine v0.0.0-20180816141035-45d19169413a
github.com/otiai10/copy v1.14.1
github.com/saracen/walker v0.1.4
github.com/urfave/cli/v2 v2.27.7
golang.org/x/net v0.50.0
@ -15,7 +16,6 @@ require (
require (
github.com/cli/go-gh/v2 v2.13.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/otiai10/copy v1.14.1 // indirect
github.com/otiai10/mint v1.6.3 // indirect
)