diff --git a/README.md b/README.md index 658f6c4..c4ed1ef 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,8 @@ gmc tag --yes ### 🔥 Worktree management (`gmc wt`) +`gmc wt` can discover the current repository's worktree family from any related repository/worktree directory. gmc still prefers the `.bare` + worktree layout when creating new setups (`gmc wt clone`, `gmc wt init`), but listing and shared-resource management are no longer limited to `.bare` repositories. + ```bash gmc wt gmc wt list @@ -159,6 +161,14 @@ gmc wt promote .dup-1 feature/best-solution # Rename temp branch to permanent This is designed for the `.bare` + worktree pattern. See `docs/auto-bare-worktree.md`. +### Shared resources across worktrees + +`gmc wt share` stores its config in the repository's shared git directory: +- normal repositories: `.git/gmc-share.yml` +- bare worktree setups: `.bare/gmc-share.yml` + +Shared paths are stored relative to the worktree root, and sync targets are resolved from Git's worktree metadata, so existing non-bare worktree repositories are supported too. + #### Open source (fork + upstream) workflow Clone your fork and register the upstream remote in one command: diff --git a/cmd/worktree.go b/cmd/worktree.go index 0931080..87af8f7 100644 --- a/cmd/worktree.go +++ b/cmd/worktree.go @@ -222,16 +222,6 @@ func init() { } func runWorktreeDefault(wtClient *worktree.Client, cmd *cobra.Command) error { - // Auto-detect if we're in bare worktree mode - isBareWorktree := wtClient.IsBareWorktree() - - // If not using bare worktree pattern, show status + help - if !isBareWorktree { - fmt.Fprintln(outWriter(), "Current repository is not using the bare worktree pattern.") - return nil - } - - // In bare worktree mode - show full worktree info worktrees, err := wtClient.List() if err != nil { return err @@ -355,6 +345,20 @@ func runWorktreeClone(wtClient *worktree.Client, url string) error { return err } +func displayWorktreeName(root string, wtPath string) string { + if root == "" { + return filepath.Base(wtPath) + } + rel, err := filepath.Rel(root, wtPath) + if err != nil || rel == "." || rel == "" { + return filepath.Base(wtPath) + } + if strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { + return filepath.Base(wtPath) + } + return rel +} + func printWorktreeTable(wtClient *worktree.Client, worktrees []worktree.Info) { if len(worktrees) == 0 { return @@ -371,12 +375,7 @@ func printWorktreeTable(wtClient *worktree.Client, worktrees []worktree.Info) { maxName := len("Name") maxBranch := len("Branch") for _, wt := range worktrees { - var name string - if root != "" { - name = strings.TrimPrefix(wt.Path, root+string(filepath.Separator)) - } else { - name = filepath.Base(wt.Path) - } + name := displayWorktreeName(root, wt.Path) if len(name) > maxName { maxName = len(name) } @@ -394,12 +393,7 @@ func printWorktreeTable(wtClient *worktree.Client, worktrees []worktree.Info) { // Print rows for _, wt := range worktrees { - var name string - if root != "" { - name = strings.TrimPrefix(wt.Path, root+string(filepath.Separator)) - } else { - name = filepath.Base(wt.Path) - } + name := displayWorktreeName(root, wt.Path) shortCommit := stringsutil.ShortHash(wt.Commit, 7, "") diff --git a/cmd/worktree_share.go b/cmd/worktree_share.go index 6e20654..7be810c 100644 --- a/cmd/worktree_share.go +++ b/cmd/worktree_share.go @@ -22,7 +22,7 @@ var wtShareCmd = &cobra.Command{ If run without arguments, it opens an interactive mode to manage resources. -Config file is stored at .gmc-shared.yml in the worktree root.`, +Config file is stored at the repository's shared git common dir (for example .git/gmc-share.yml or .bare/gmc-share.yml).`, RunE: func(_ *cobra.Command, _ []string) error { wtClient := newWorktreeClient() return runWorktreeShareInteractive(wtClient) @@ -197,12 +197,6 @@ func promptAddResource(c *worktree.Client, reader *bufio.Reader) { return } - // If user entered a relative path and we're in a worktree, prepend worktree name - if currentWorktree != "" && !strings.Contains(path, "/") { - path = filepath.Join(currentWorktree, path) - fmt.Printf("Using full path: %s\n", path) - } - strategy := promptStrategy(reader) report, err := c.AddSharedResource(path, strategy) diff --git a/cmd/worktree_switch.go b/cmd/worktree_switch.go index 899f765..067fdbd 100644 --- a/cmd/worktree_switch.go +++ b/cmd/worktree_switch.go @@ -1,7 +1,6 @@ package cmd import ( - "errors" "fmt" "path/filepath" @@ -27,10 +26,6 @@ Without shell integration, this command will only print the path.`, } func runWorktreeSwitch(wtClient *worktree.Client) error { - if !wtClient.IsBareWorktree() { - return errors.New("not in a bare worktree setup") - } - worktrees, err := wtClient.List() if err != nil { return err @@ -38,7 +33,7 @@ func runWorktreeSwitch(wtClient *worktree.Client) error { filtered := filterBareWorktrees(worktrees) if len(filtered) == 0 { - return errors.New("no worktrees found") + return fmt.Errorf("no worktrees found") } root, _ := wtClient.GetWorktreeRoot() diff --git a/cmd/worktree_test.go b/cmd/worktree_test.go new file mode 100644 index 0000000..c203921 --- /dev/null +++ b/cmd/worktree_test.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "bytes" + "io" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/samzong/gmc/internal/worktree" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunWorktreeDefault_ShowsWorktreesInNonBareRepo(t *testing.T) { + repoDir := initCmdTestRepo(t) + linkedWt := filepath.Join(t.TempDir(), "feature-wt") + runGitCmd(t, repoDir, "worktree", "add", "-b", "feature/demo", linkedWt, "main") + + oldCwd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(oldCwd) }() + require.NoError(t, os.Chdir(linkedWt)) + + var out bytes.Buffer + oldOut := outWriterFunc + oldErr := errWriterFunc + outWriterFunc = func() io.Writer { return &out } + errWriterFunc = func() io.Writer { return &out } + defer func() { + outWriterFunc = oldOut + errWriterFunc = oldErr + }() + + client := worktree.NewClient(worktree.Options{}) + cmd := &cobra.Command{Use: "wt"} + cmd.AddCommand(&cobra.Command{Use: "list", Short: "List all worktrees"}) + + err = runWorktreeDefault(client, cmd) + require.NoError(t, err) + + output := out.String() + assert.Contains(t, output, "Current Worktrees:") + assert.Contains(t, output, "feature/demo") + assert.NotContains(t, output, "not using the bare worktree pattern") +} + +func initCmdTestRepo(t *testing.T) string { + t.Helper() + repoDir := t.TempDir() + runGitCmd(t, repoDir, "init", "-b", "main") + runGitCmd(t, repoDir, "config", "user.name", "Test User") + runGitCmd(t, repoDir, "config", "user.email", "test@example.com") + require.NoError(t, os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("init"), 0o644)) + runGitCmd(t, repoDir, "add", ".") + runGitCmd(t, repoDir, "commit", "-m", "init") + return repoDir +} + +func runGitCmd(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := execCommand("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, string(output)) + } + return string(output) +} + +var execCommand = func(name string, args ...string) *exec.Cmd { + return exec.Command(name, args...) +} diff --git a/docs/plans/2026-03-14-worktree-discovery-share-common-dir-design.md b/docs/plans/2026-03-14-worktree-discovery-share-common-dir-design.md new file mode 100644 index 0000000..4e1dff2 --- /dev/null +++ b/docs/plans/2026-03-14-worktree-discovery-share-common-dir-design.md @@ -0,0 +1,37 @@ +# Worktree Discovery + Shared Config Design + +## Goal +Make `gmc wt` discover and display all related worktrees from any repository/worktree directory while keeping `.bare` as the preferred initialization layout for `gmc wt clone` / `gmc wt init`. Also make `gmc wt share` usable in existing non-bare worktree repositories. + +## Decisions + +### 1. Discovery is Git-first +- Worktree discovery uses Git's shared metadata (`git worktree list --porcelain`, `git rev-parse --git-common-dir`). +- `.bare` remains the default creation/layout mode for gmc-managed repositories, but discovery no longer depends on `.bare` being present. +- `gmc wt` default output shows worktrees whenever the current directory belongs to a repo/worktree family. + +### 2. Shared config lives in git common dir +- Canonical path: `/gmc-share.yml` +- Backward-compatible reads: legacy `.gmc-shared.yml` / `.gmc-shared.yaml` +- In `.bare` mode this naturally resolves under `.bare/`. +- In normal repositories this resolves under `.git/`. + +### 3. Shared resource paths are normalized to worktree-relative paths +- `gmc wt share add ` stores a cleaned relative path. +- Paths escaping the worktree (`..`) are rejected. +- Existing legacy configs that implicitly used a source-worktree prefix continue to be tolerated during sync. + +### 4. Sync targets use actual worktree paths +- Sync logic no longer assumes all worktrees live under `/`. +- `SyncAllSharedResources` iterates real worktree paths from Git metadata. +- This enables support for linked worktrees located anywhere on disk. + +## Tradeoffs +- Canonical source resolution still prefers the main repo root, then falls back to the current worktree if needed. This keeps the change small and practical, but does not yet implement a more advanced dedicated shared-resource store. +- Display names for worktrees prefer repo-relative paths when reasonable, otherwise fall back to basename for externally located worktrees. + +## Verification Plan +- Unit/integration test: `gmc wt` default output works from a non-bare linked worktree. +- Unit/integration test: shared config path resolves to git common dir in a non-bare linked worktree. +- Unit/integration test: sync copies shared resources into linked worktrees in a non-bare repo family. +- Regression test: legacy `.bare`-style shared-resource sync test remains green. diff --git a/internal/worktree/resource.go b/internal/worktree/resource.go index a7ef1d1..c794821 100644 --- a/internal/worktree/resource.go +++ b/internal/worktree/resource.go @@ -23,6 +23,12 @@ type SharedResource struct { Strategy ResourceStrategy `yaml:"strategy"` } +const ( + sharedConfigName = "gmc-share.yml" + legacySharedConfigYML = ".gmc-shared.yml" + legacySharedConfigYAML = ".gmc-shared.yaml" +) + type SharedConfig struct { Resources []SharedResource `yaml:"shared"` } @@ -30,6 +36,17 @@ type SharedConfig struct { func (c *Client) SyncSharedResources(worktreeName string) (Report, error) { var report Report + targetRoot, err := c.resolveWorktreePath(worktreeName) + if err != nil { + return report, err + } + + return c.syncSharedResourcesToPath(targetRoot) +} + +func (c *Client) syncSharedResourcesToPath(targetRoot string) (Report, error) { + var report Report + cfg, _, err := c.LoadSharedConfig() if err != nil { return report, err @@ -39,15 +56,13 @@ func (c *Client) SyncSharedResources(worktreeName string) (Report, error) { return report, nil } - root, err := c.GetWorktreeRoot() + repoRoot, err := c.GetRepoRoot() if err != nil { return report, err } - targetRoot := filepath.Join(root, worktreeName) - for _, res := range cfg.Resources { - resourceReport, err := c.syncOneResource(root, targetRoot, res) + resourceReport, err := c.syncOneResource(repoRoot, targetRoot, res) report.Merge(resourceReport) if err != nil { return report, err @@ -56,7 +71,7 @@ func (c *Client) SyncSharedResources(worktreeName string) (Report, error) { return report, nil } -func (c *Client) syncOneResource(root, targetRoot string, res SharedResource) (Report, error) { +func (c *Client) syncOneResource(repoRoot, targetRoot string, res SharedResource) (Report, error) { var report Report if res.Path == "" { @@ -66,23 +81,12 @@ func (c *Client) syncOneResource(root, targetRoot string, res SharedResource) (R return report, fmt.Errorf("shared resource '%s' missing 'strategy' field", res.Path) } - srcPath := filepath.Join(root, res.Path) - targetPath := res.Path - parts := strings.SplitN(res.Path, string(filepath.Separator), 2) - if len(parts) == 2 { - potentialWorktree := filepath.Join(root, parts[0]) - if info, err := os.Stat(potentialWorktree); err == nil && info.IsDir() { - if parts[0] != ".bare" { - targetPath = parts[1] - targetWorktreeName := filepath.Base(targetRoot) - if targetWorktreeName == parts[0] { - if c.verbose { - report.Warn(fmt.Sprintf("Skipping %s: source worktree is target", res.Path)) - } - return report, nil - } - } - } + srcPath, targetPath, skip, err := c.resolveSharedPaths(repoRoot, targetRoot, res) + if err != nil { + return report, err + } + if skip { + return report, nil } dstPath := filepath.Join(targetRoot, targetPath) @@ -131,21 +135,38 @@ func (c *Client) syncOneResource(root, targetRoot string, res SharedResource) (R } func (c *Client) LoadSharedConfig() (*SharedConfig, string, error) { - root, err := c.GetWorktreeRoot() + commonDir, err := c.GetGitCommonDir() if err != nil { - return nil, "", err + if root, bareErr := FindBareRoot(""); bareErr == nil { + commonDir = filepath.Join(root, ".bare") + } else { + return nil, "", err + } + } + + configPath := filepath.Join(commonDir, sharedConfigName) + legacyCandidates := []string{ + filepath.Join(commonDir, legacySharedConfigYML), + filepath.Join(commonDir, legacySharedConfigYAML), + } + if repoRoot, rootErr := c.GetRepoRoot(); rootErr == nil && repoRoot != "" { + legacyCandidates = append(legacyCandidates, + filepath.Join(repoRoot, legacySharedConfigYML), + filepath.Join(repoRoot, legacySharedConfigYAML), + ) } - configPath := filepath.Join(root, ".gmc-shared.yml") if _, err := os.Stat(configPath); os.IsNotExist(err) { - yamlPath := filepath.Join(root, ".gmc-shared.yaml") - if _, err := os.Stat(yamlPath); err == nil { - configPath = yamlPath + for _, candidate := range legacyCandidates { + if _, statErr := os.Stat(candidate); statErr == nil { + configPath = candidate + break + } } } if _, err := os.Stat(configPath); os.IsNotExist(err) { - return &SharedConfig{Resources: []SharedResource{}}, configPath, nil + return &SharedConfig{Resources: []SharedResource{}}, filepath.Join(commonDir, sharedConfigName), nil } data, err := os.ReadFile(configPath) @@ -167,7 +188,11 @@ func (c *Client) SaveSharedConfig(cfg *SharedConfig, path string) error { return fmt.Errorf("failed to marshal shared config: %w", err) } - if err := os.WriteFile(path, data, 0644); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("failed to create shared config directory: %w", err) + } + + if err := os.WriteFile(path, data, 0o644); err != nil { return fmt.Errorf("failed to write shared config: %w", err) } return nil @@ -176,6 +201,11 @@ func (c *Client) SaveSharedConfig(cfg *SharedConfig, path string) error { func (c *Client) AddSharedResource(path string, strategy ResourceStrategy) (Report, error) { var report Report + normalizedPath, err := c.NormalizeSharedResourcePath(path) + if err != nil { + return report, err + } + cfg, configPath, err := c.LoadSharedConfig() if err != nil { return report, err @@ -183,7 +213,7 @@ func (c *Client) AddSharedResource(path string, strategy ResourceStrategy) (Repo found := false for i, res := range cfg.Resources { - if res.Path == path { + if res.Path == normalizedPath { cfg.Resources[i].Strategy = strategy found = true break @@ -192,7 +222,7 @@ func (c *Client) AddSharedResource(path string, strategy ResourceStrategy) (Repo if !found { cfg.Resources = append(cfg.Resources, SharedResource{ - Path: path, + Path: normalizedPath, Strategy: strategy, }) } @@ -201,13 +231,18 @@ func (c *Client) AddSharedResource(path string, strategy ResourceStrategy) (Repo return report, err } - report.Info(fmt.Sprintf("Updated shared resource: %s (%s)", path, strategy)) + report.Info(fmt.Sprintf("Updated shared resource: %s (%s)", normalizedPath, strategy)) return report, nil } func (c *Client) RemoveSharedResource(path string) (Report, error) { var report Report + normalizedPath, err := c.NormalizeSharedResourcePath(path) + if err != nil { + return report, err + } + cfg, configPath, err := c.LoadSharedConfig() if err != nil { return report, err @@ -215,13 +250,13 @@ func (c *Client) RemoveSharedResource(path string) (Report, error) { var newResources []SharedResource for _, res := range cfg.Resources { - if res.Path != path { + if res.Path != normalizedPath { newResources = append(newResources, res) } } if len(newResources) == len(cfg.Resources) { - return report, fmt.Errorf("resource not found in config: %s", path) + return report, fmt.Errorf("resource not found in config: %s", normalizedPath) } cfg.Resources = newResources @@ -229,7 +264,7 @@ func (c *Client) RemoveSharedResource(path string) (Report, error) { return report, err } - report.Info("Removed shared resource: " + path) + report.Info("Removed shared resource: " + normalizedPath) return report, nil } @@ -255,17 +290,181 @@ func (c *Client) SyncAllSharedResources() (Report, error) { report.Info(fmt.Sprintf("Syncing resources to %d worktrees...", len(targets))) for _, wt := range targets { - wtName := filepath.Base(wt.Path) - resourceReport, err := c.SyncSharedResources(wtName) + resourceReport, err := c.syncSharedResourcesToPath(wt.Path) report.Merge(resourceReport) if err != nil { - report.Warn(fmt.Sprintf("Warning: failed to sync %s: %v", wtName, err)) + report.Warn(fmt.Sprintf("Warning: failed to sync %s: %v", filepath.Base(wt.Path), err)) } } return report, nil } +func (c *Client) resolveWorktreePath(worktreeName string) (string, error) { + if worktreeName == "" { + return "", errors.New("worktree name cannot be empty") + } + + repoRoot, _ := c.GetRepoRoot() + worktrees, err := c.List() + if err != nil { + if repoRoot != "" { + candidate := filepath.Join(repoRoot, worktreeName) + if info, statErr := os.Stat(candidate); statErr == nil && info.IsDir() { + return candidate, nil + } + } + return "", err + } + + var exactMatches []string + var relMatches []string + var baseMatches []string + for _, wt := range worktrees { + if wt.Path == worktreeName { + exactMatches = append(exactMatches, wt.Path) + continue + } + if repoRoot != "" { + if rel, relErr := filepath.Rel(repoRoot, wt.Path); relErr == nil && rel == worktreeName { + relMatches = append(relMatches, wt.Path) + continue + } + } + if filepath.Base(wt.Path) == worktreeName { + baseMatches = append(baseMatches, wt.Path) + } + } + + if match, err := uniqueWorktreeMatch(worktreeName, exactMatches, "exact path"); match != "" || err != nil { + return match, err + } + if match, err := uniqueWorktreeMatch(worktreeName, relMatches, "repo-relative path"); match != "" || err != nil { + return match, err + } + if match, err := uniqueWorktreeMatch(worktreeName, baseMatches, "basename"); match != "" || err != nil { + return match, err + } + + return "", fmt.Errorf("worktree not found: %s", worktreeName) +} + +func (c *Client) currentTopLevel() string { + result, err := c.runner.Run("rev-parse", "--show-toplevel") + if err != nil { + return "" + } + root := result.StdoutString(true) + if root == "" { + return "" + } + if filepath.IsAbs(root) { + return filepath.Clean(root) + } + absRoot, absErr := filepath.Abs(root) + if absErr != nil { + return "" + } + return absRoot +} + +func (c *Client) NormalizeSharedResourcePath(path string) (string, error) { + trimmed := strings.TrimSpace(path) + if trimmed == "" { + return "", errors.New("shared resource path cannot be empty") + } + + if filepath.IsAbs(trimmed) { + currentRoot := c.currentTopLevel() + if currentRoot == "" { + return "", fmt.Errorf("absolute shared resource path must be inside the current worktree: %s", path) + } + rel, err := filepath.Rel(currentRoot, trimmed) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("absolute shared resource path must stay within the current worktree: %s", path) + } + trimmed = rel + } + + trimmed = filepath.Clean(trimmed) + if trimmed == "." { + return "", errors.New("shared resource path cannot be '.'") + } + if filepath.IsAbs(trimmed) || strings.HasPrefix(trimmed, ".."+string(filepath.Separator)) || trimmed == ".." { + return "", fmt.Errorf("shared resource path must stay within the worktree: %s", path) + } + return trimmed, nil +} + +func (c *Client) resolveSharedPaths(repoRoot, targetRoot string, res SharedResource) (srcPath string, targetPath string, skip bool, err error) { + targetPath, err = sanitizeTargetRelativePath(res.Path) + if err != nil { + return "", "", false, err + } + + parts := strings.SplitN(res.Path, string(filepath.Separator), 2) + if len(parts) == 2 { + worktrees, listErr := c.List() + if listErr == nil { + var baseMatches []string + for _, wt := range worktrees { + if filepath.Base(wt.Path) == parts[0] { + baseMatches = append(baseMatches, wt.Path) + } + } + if match, matchErr := uniqueWorktreeMatch(parts[0], baseMatches, "legacy basename"); matchErr != nil { + return "", "", false, matchErr + } else if match != "" { + if match == targetRoot { + return "", "", true, nil + } + srcPath = filepath.Join(match, parts[1]) + targetPath, err = sanitizeTargetRelativePath(parts[1]) + if err != nil { + return "", "", false, err + } + return srcPath, targetPath, false, nil + } + } + } + + srcPath = filepath.Join(repoRoot, targetPath) + if _, statErr := os.Stat(srcPath); statErr == nil { + return srcPath, targetPath, false, nil + } + + currentRoot := c.currentTopLevel() + if currentRoot != "" { + candidate := filepath.Join(currentRoot, targetPath) + if _, statErr := os.Stat(candidate); statErr == nil { + return candidate, targetPath, false, nil + } + } + + return srcPath, targetPath, false, nil +} + +func uniqueWorktreeMatch(input string, matches []string, matchType string) (string, error) { + if len(matches) == 0 { + return "", nil + } + if len(matches) == 1 { + return matches[0], nil + } + return "", fmt.Errorf("ambiguous worktree %q by %s: %s", input, matchType, strings.Join(matches, ", ")) +} + +func sanitizeTargetRelativePath(path string) (string, error) { + cleaned := filepath.Clean(strings.TrimSpace(path)) + if cleaned == "" || cleaned == "." { + return "", errors.New("shared resource path cannot be empty") + } + if filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("shared resource path must stay within the worktree: %s", path) + } + return cleaned, nil +} + func copyFile(src, dst string) error { sourceFile, err := os.Open(src) if err != nil { diff --git a/internal/worktree/resource_nonbare_test.go b/internal/worktree/resource_nonbare_test.go new file mode 100644 index 0000000..ee515de --- /dev/null +++ b/internal/worktree/resource_nonbare_test.go @@ -0,0 +1,124 @@ +package worktree + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadSharedConfig_UsesGitCommonDirInNonBareWorktree(t *testing.T) { + repoDir := initTestRepo(t) + linkedWt := filepath.Join(t.TempDir(), "feature-wt") + runGit(t, repoDir, "worktree", "add", "-b", "feature/test-share-config", linkedWt, "main") + + client := NewClient(Options{}) + oldCwd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(oldCwd) }() + require.NoError(t, os.Chdir(linkedWt)) + + cfg, configPath, err := client.LoadSharedConfig() + require.NoError(t, err) + assert.Empty(t, cfg.Resources) + expectedCommonDir := strings.TrimSpace(runGit(t, linkedWt, "rev-parse", "--git-common-dir")) + if !filepath.IsAbs(expectedCommonDir) { + expectedCommonDir = filepath.Join(linkedWt, expectedCommonDir) + } + assert.Equal(t, filepath.Join(expectedCommonDir, "gmc-share.yml"), configPath) +} + +func TestSyncAllSharedResources_WorksFromNonBareWorktreeRepo(t *testing.T) { + repoDir := initTestRepo(t) + linkedWt := filepath.Join(t.TempDir(), "feature-wt") + runGit(t, repoDir, "worktree", "add", "-b", "feature/test-sync-share", linkedWt, "main") + + require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".env"), []byte("SECRET=123"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".git", "gmc-share.yml"), []byte("shared:\n - path: .env\n strategy: copy\n"), 0o644)) + + client := NewClient(Options{}) + oldCwd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(oldCwd) }() + require.NoError(t, os.Chdir(linkedWt)) + + _, err = client.SyncAllSharedResources() + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(linkedWt, ".env")) + require.NoError(t, err) + assert.Equal(t, "SECRET=123", string(data)) +} + +func TestLoadSharedConfig_FallsBackToLegacyRepoRootConfig(t *testing.T) { + repoDir := initTestRepo(t) + require.NoError(t, os.WriteFile(filepath.Join(repoDir, legacySharedConfigYML), []byte("shared:\n - path: .env\n strategy: copy\n"), 0o644)) + + client := NewClient(Options{}) + oldCwd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(oldCwd) }() + require.NoError(t, os.Chdir(repoDir)) + + cfg, configPath, err := client.LoadSharedConfig() + require.NoError(t, err) + require.Len(t, cfg.Resources, 1) + expectedPath, pathErr := filepath.EvalSymlinks(filepath.Join(repoDir, legacySharedConfigYML)) + if pathErr != nil { + expectedPath = filepath.Join(repoDir, legacySharedConfigYML) + } + assert.Equal(t, expectedPath, configPath) +} + +func TestNormalizeSharedResourcePath_RejectsAbsolutePathOutsideWorktree(t *testing.T) { + repoDir := initTestRepo(t) + client := NewClient(Options{}) + oldCwd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(oldCwd) }() + require.NoError(t, os.Chdir(repoDir)) + + _, err = client.NormalizeSharedResourcePath(filepath.Join(t.TempDir(), "outside.env")) + require.Error(t, err) +} + +func TestRemoveSharedResource_NormalizesPath(t *testing.T) { + repoDir := initTestRepo(t) + client := NewClient(Options{}) + oldCwd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(oldCwd) }() + require.NoError(t, os.Chdir(repoDir)) + + require.NoError(t, os.WriteFile(filepath.Join(repoDir, ".git", "gmc-share.yml"), []byte("shared:\n - path: config/.env\n strategy: copy\n"), 0o644)) + + _, err = client.RemoveSharedResource("config/../config/.env") + require.NoError(t, err) + + cfg, _, err := client.LoadSharedConfig() + require.NoError(t, err) + assert.Empty(t, cfg.Resources) +} + +func TestResolveWorktreePath_ErrorsOnAmbiguousBasename(t *testing.T) { + repoDir := initTestRepo(t) + wt1 := filepath.Join(t.TempDir(), "dup") + wt2Parent := filepath.Join(t.TempDir(), "nested") + require.NoError(t, os.MkdirAll(wt2Parent, 0o755)) + wt2 := filepath.Join(wt2Parent, "dup") + runGit(t, repoDir, "worktree", "add", "-b", "feature/dup-1", wt1, "main") + runGit(t, repoDir, "worktree", "add", "-b", "feature/dup-2", wt2, "main") + + client := NewClient(Options{}) + oldCwd, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(oldCwd) }() + require.NoError(t, os.Chdir(repoDir)) + + _, err = client.resolveWorktreePath("dup") + require.Error(t, err) + assert.Contains(t, err.Error(), "ambiguous worktree") +} diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 931dc9b..1403879 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -173,15 +173,8 @@ func FindBareRoot(startDir string) (string, error) { return "", errors.New("no .bare directory found in parent directories") } -// GetWorktreeRoot returns the root directory for worktrees (parent of .bare) -func (c *Client) GetWorktreeRoot() (string, error) { - // First try to find .bare directory - root, err := FindBareRoot("") - if err == nil { - return root, nil - } - - // Fall back to git-common-dir +// GetGitCommonDir returns the absolute shared git directory for the current repository/worktree. +func (c *Client) GetGitCommonDir() (string, error) { result, err := c.runner.Run("rev-parse", "--git-common-dir") if err != nil { return "", fmt.Errorf("not in a git repository: %w", err) @@ -192,19 +185,39 @@ func (c *Client) GetWorktreeRoot() (string, error) { return "", errors.New("failed to determine git common directory") } - // Get the absolute path + if filepath.IsAbs(commonDir) { + return filepath.Clean(commonDir), nil + } + absCommonDir, err := filepath.Abs(commonDir) if err != nil { return "", fmt.Errorf("failed to get absolute path: %w", err) } + return absCommonDir, nil +} - // If it ends with .bare, return parent - if filepath.Base(absCommonDir) == ".bare" { - return filepath.Dir(absCommonDir), nil +// GetRepoRoot returns the main worktree/repository root for the current repository family. +func (c *Client) GetRepoRoot() (string, error) { + // First try bare-root discovery for gmc's preferred layout. + root, err := FindBareRoot("") + if err == nil { + return root, nil + } + + commonDir, err := c.GetGitCommonDir() + if err != nil { + return "", err } - // Otherwise return parent of .git - return filepath.Dir(absCommonDir), nil + if filepath.Base(commonDir) == ".bare" { + return filepath.Dir(commonDir), nil + } + return filepath.Dir(commonDir), nil +} + +// GetWorktreeRoot returns the root directory for worktrees (parent of .bare or main repo root). +func (c *Client) GetWorktreeRoot() (string, error) { + return c.GetRepoRoot() } // IsBareWorktree checks if the current repository uses the .bare worktree pattern