From f0c0eaeefd4e03a195f2ffe32c9a473830c40521 Mon Sep 17 00:00:00 2001 From: Raphael Gruber Date: Fri, 10 Apr 2026 08:52:09 +0200 Subject: [PATCH] perf: use lightweight worktree loader for cd interactive mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wt cd -i` was calling LoadWorktreesForRepos which runs 4 git commands per repo (worktree list, branch config, origin URL, commit metadata). The CD wizard only needs path, branch, and repo name — all from a single `git worktree list` call. The other 3 commands were wasted. Add ListWorktreesForRepos — a lightweight parallel loader that only calls `git worktree list` per repo. Switch cd interactive mode to use it, eliminating 3N unnecessary git subprocess spawns for N repos. Co-Authored-By: Claude Opus 4.6 --- cmd/wt/cd_cmd.go | 2 +- internal/git/doc.go | 3 +- internal/git/load.go | 51 +++++++++++++++++++++ internal/git/load_test.go | 95 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 2 deletions(-) diff --git a/cmd/wt/cd_cmd.go b/cmd/wt/cd_cmd.go index f56f33a..ebfa3d6 100644 --- a/cmd/wt/cd_cmd.go +++ b/cmd/wt/cd_cmd.go @@ -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) } diff --git a/internal/git/doc.go b/internal/git/doc.go index 0d3cac6..6b86a49 100644 --- a/internal/git/doc.go +++ b/internal/git/doc.go @@ -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 // diff --git a/internal/git/load.go b/internal/git/load.go index 38e8f2f..46e90f8 100644 --- a/internal/git/load.go +++ b/internal/git/load.go @@ -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) diff --git a/internal/git/load_test.go b/internal/git/load_test.go index 2c5e04b..ce94b75 100644 --- a/internal/git/load_test.go +++ b/internal/git/load_test.go @@ -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()