mirror of
https://github.com/x-motemen/ghq.git
synced 2026-09-10 07:26:27 -04:00
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:
commit
8f58aeffe0
163
cmd_migrate.go
163
cmd_migrate.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
2
go.mod
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue