Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
38 changes: 16 additions & 22 deletions cmd/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand All @@ -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, "")

Expand Down
8 changes: 1 addition & 7 deletions cmd/worktree_share.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 1 addition & 6 deletions cmd/worktree_switch.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package cmd

import (
"errors"
"fmt"
"path/filepath"

Expand All @@ -27,18 +26,14 @@ 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
}

filtered := filterBareWorktrees(worktrees)
if len(filtered) == 0 {
return errors.New("no worktrees found")
return fmt.Errorf("no worktrees found")
}

root, _ := wtClient.GetWorktreeRoot()
Expand Down
75 changes: 75 additions & 0 deletions cmd/worktree_test.go
Original file line number Diff line number Diff line change
@@ -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", "[email protected]")
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...)
}
Original file line number Diff line number Diff line change
@@ -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: `<git-common-dir>/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 <path>` 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 `<root>/<name>`.
- `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.
Loading
Loading