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
2 changes: 1 addition & 1 deletion cmd/wt/cd_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ func runCdInteractive(ctx context.Context, reg *registry.Registry, histPath stri
}
repos = filterOrphanedRepos(l, repos)

loaded, warnings := git.LoadWorktreesForRepos(ctx, reposToRefs(repos))
loaded, warnings := git.ListWorktreesForRepos(ctx, reposToRefs(repos))
for _, w := range warnings {
l.Debug("skipping repo", "repo", w.RepoName, "error", w.Err)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/git/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
// - [CreateWorktree], [CreateWorktreeNewBranch]: Create worktrees for existing or new branches
// - [RemoveWorktree]: Remove worktrees with optional force flag
// - [PruneWorktrees]: Clean up stale worktree references
// - [LoadWorktreesForRepos]: Load worktrees from multiple repos in parallel
// - [LoadWorktreesForRepos]: Load worktrees from multiple repos in parallel (full metadata)
// - [ListWorktreesForRepos]: List worktrees from multiple repos in parallel (lightweight)
//
// # Repository Operations
//
Expand Down
51 changes: 51 additions & 0 deletions internal/git/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,57 @@ func LoadWorktreesForRepos(ctx context.Context, repos []RepoRef) ([]Worktree, []
return all, warnings
}

// ListWorktreesForRepos lists worktrees from all repos in parallel (lightweight).
// Only calls `git worktree list` per repo — no branch config, origin URL, or commit metadata.
// Use [LoadWorktreesForRepos] when full metadata is needed (list, prune commands).
// Results maintain stable ordering (by repo index, then worktree order within repo).
func ListWorktreesForRepos(ctx context.Context, repos []RepoRef) ([]Worktree, []LoadWarning) {
type repoResult struct {
worktrees []Worktree
warning *LoadWarning
}

results := make([]repoResult, len(repos))

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)

for i, repo := range repos {
g.Go(func() error {
wtInfos, err := ListWorktreesFromRepo(ctx, repo.Path)
if err != nil {
results[i] = repoResult{warning: &LoadWarning{RepoName: repo.Name, Err: err}}
return nil
}
worktrees := make([]Worktree, len(wtInfos))
for j, wti := range wtInfos {
worktrees[j] = Worktree{
Path: wti.Path,
Branch: wti.Branch,
CommitHash: wti.CommitHash,
RepoName: repo.Name,
RepoPath: repo.Path,
}
}
results[i] = repoResult{worktrees: worktrees}
return nil
})
}

_ = g.Wait()

var all []Worktree
var warnings []LoadWarning
for _, r := range results {
all = append(all, r.worktrees...)
if r.warning != nil {
warnings = append(warnings, *r.warning)
}
}

return all, warnings
}

// loadWorktreesForRepo fetches worktrees for a single repo.
func loadWorktreesForRepo(ctx context.Context, repo RepoRef) ([]Worktree, *LoadWarning) {
wtInfos, err := ListWorktreesFromRepo(ctx, repo.Path)
Expand Down
95 changes: 95 additions & 0 deletions internal/git/load_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,101 @@ func TestLoadWorktreesForRepos(t *testing.T) {
}
}

func TestListWorktreesForRepos(t *testing.T) {
t.Parallel()

repo1 := setupTestRepo(t)
repo2 := setupTestRepo(t)

ctx := context.Background()

wt1 := filepath.Join(filepath.Dir(repo1), "wt-list-1")
if err := runGit(ctx, repo1, "worktree", "add", "-b", "list-branch-1", wt1); err != nil {
t.Fatalf("failed to create worktree: %v", err)
}

wt2 := filepath.Join(filepath.Dir(repo2), "wt-list-2")
if err := runGit(ctx, repo2, "worktree", "add", "-b", "list-branch-2", wt2); err != nil {
t.Fatalf("failed to create worktree: %v", err)
}

repos := []RepoRef{
{Name: "repo1", Path: repo1},
{Name: "repo2", Path: repo2},
}

worktrees, warnings := ListWorktreesForRepos(ctx, repos)

if len(warnings) != 0 {
t.Errorf("expected no warnings, got %v", warnings)
}

if len(worktrees) != 4 {
t.Errorf("got %d worktrees, want 4", len(worktrees))
}

for _, wt := range worktrees {
if wt.RepoName == "" {
t.Error("RepoName should be set")
}
if wt.RepoPath == "" {
t.Error("RepoPath should be set")
}
if wt.Path == "" {
t.Error("Path should be set")
}
if wt.Branch == "" {
t.Error("Branch should be set")
}
// Lightweight loader does not populate metadata fields
if wt.CommitAge != "" {
t.Errorf("CommitAge should be empty, got %q", wt.CommitAge)
}
if wt.Note != "" {
t.Errorf("Note should be empty, got %q", wt.Note)
}
if wt.OriginURL != "" {
t.Errorf("OriginURL should be empty, got %q", wt.OriginURL)
}
}
}

func TestListWorktreesForRepos_BadRepo(t *testing.T) {
t.Parallel()

goodRepo := setupTestRepo(t)
ctx := context.Background()

repos := []RepoRef{
{Name: "bad-repo", Path: "/nonexistent/path"},
{Name: "good-repo", Path: goodRepo},
}

worktrees, warnings := ListWorktreesForRepos(ctx, repos)

if len(warnings) != 1 {
t.Errorf("expected 1 warning, got %d", len(warnings))
}
if len(warnings) > 0 && warnings[0].RepoName != "bad-repo" {
t.Errorf("warning repo = %q, want bad-repo", warnings[0].RepoName)
}

if len(worktrees) < 1 {
t.Error("good repo worktrees should still load")
}

hasGoodRepo := false
for _, wt := range worktrees {
if wt.RepoName == "good-repo" {
hasGoodRepo = true
break
}
}
if !hasGoodRepo {
t.Error("should have worktrees from good-repo")
}
}

func TestLoadWorktreesForRepos_BadRepo(t *testing.T) {
t.Parallel()

Expand Down
Loading