From 4727f5079ffd8c3333f65288300877038978f076 Mon Sep 17 00:00:00 2001 From: Dan Shapiro Date: Fri, 2 Jan 2026 22:30:39 -0800 Subject: [PATCH] feat: allow local repo reference clones to save disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use git --reference-if-able when a local repo is provided so rigs and crew share objects without changing remotes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/rig.go | 6 +++ internal/config/loader_test.go | 12 ++++- internal/config/types.go | 2 + internal/crew/manager.go | 13 +++++- internal/crew/manager_test.go | 61 +++++++++++++++++++++++++ internal/git/git.go | 29 ++++++++++++ internal/git/git_test.go | 47 ++++++++++++++++++++ internal/rig/manager.go | 81 +++++++++++++++++++++++++++++----- internal/rig/types.go | 3 ++ 9 files changed, 240 insertions(+), 14 deletions(-) diff --git a/internal/cmd/rig.go b/internal/cmd/rig.go index 614b4ef6..a61b970c 100644 --- a/internal/cmd/rig.go +++ b/internal/cmd/rig.go @@ -251,6 +251,7 @@ Examples: // Flags var ( rigAddPrefix string + rigAddLocalRepo string rigResetHandoff bool rigResetMail bool rigResetStale bool @@ -279,6 +280,7 @@ func init() { rigCmd.AddCommand(rigStopCmd) rigAddCmd.Flags().StringVar(&rigAddPrefix, "prefix", "", "Beads issue prefix (default: derived from name)") + rigAddCmd.Flags().StringVar(&rigAddLocalRepo, "local-repo", "", "Local repo path to share git objects (optional)") rigResetCmd.Flags().BoolVar(&rigResetHandoff, "handoff", false, "Clear handoff content") rigResetCmd.Flags().BoolVar(&rigResetMail, "mail", false, "Clear stale mail messages") @@ -330,6 +332,9 @@ func runRigAdd(cmd *cobra.Command, args []string) error { fmt.Printf("Creating rig %s...\n", style.Bold.Render(name)) fmt.Printf(" Repository: %s\n", gitURL) + if rigAddLocalRepo != "" { + fmt.Printf(" Local repo: %s\n", rigAddLocalRepo) + } startTime := time.Now() @@ -338,6 +343,7 @@ func runRigAdd(cmd *cobra.Command, args []string) error { Name: name, GitURL: gitURL, BeadsPrefix: rigAddPrefix, + LocalRepo: rigAddLocalRepo, }) if err != nil { return fmt.Errorf("adding rig: %w", err) diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index db559ccc..6e632fd5 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -44,8 +44,9 @@ func TestRigsConfigRoundTrip(t *testing.T) { Version: 1, Rigs: map[string]RigEntry{ "gastown": { - GitURL: "git@github.com:steveyegge/gastown.git", - AddedAt: time.Now().Truncate(time.Second), + GitURL: "git@github.com:steveyegge/gastown.git", + LocalRepo: "/tmp/local-repo", + AddedAt: time.Now().Truncate(time.Second), BeadsConfig: &BeadsConfig{ Repo: "local", Prefix: "gt-", @@ -74,6 +75,9 @@ func TestRigsConfigRoundTrip(t *testing.T) { if rig.BeadsConfig == nil || rig.BeadsConfig.Prefix != "gt-" { t.Errorf("BeadsConfig.Prefix = %v, want 'gt-'", rig.BeadsConfig) } + if rig.LocalRepo != "/tmp/local-repo" { + t.Errorf("LocalRepo = %q, want %q", rig.LocalRepo, "/tmp/local-repo") + } } func TestAgentStateRoundTrip(t *testing.T) { @@ -140,6 +144,7 @@ func TestRigConfigRoundTrip(t *testing.T) { original := NewRigConfig("gastown", "git@github.com:test/gastown.git") original.CreatedAt = time.Now().Truncate(time.Second) original.Beads = &BeadsConfig{Prefix: "gt-"} + original.LocalRepo = "/tmp/local-repo" if err := SaveRigConfig(path, original); err != nil { t.Fatalf("SaveRigConfig: %v", err) @@ -162,6 +167,9 @@ func TestRigConfigRoundTrip(t *testing.T) { if loaded.GitURL != "git@github.com:test/gastown.git" { t.Errorf("GitURL = %q, want expected URL", loaded.GitURL) } + if loaded.LocalRepo != "/tmp/local-repo" { + t.Errorf("LocalRepo = %q, want %q", loaded.LocalRepo, "/tmp/local-repo") + } if loaded.Beads == nil || loaded.Beads.Prefix != "gt-" { t.Error("Beads.Prefix not preserved") } diff --git a/internal/config/types.go b/internal/config/types.go index f27de87d..ff1e5e37 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -54,6 +54,7 @@ type RigsConfig struct { // RigEntry represents a single rig in the registry. type RigEntry struct { GitURL string `json:"git_url"` + LocalRepo string `json:"local_repo,omitempty"` AddedAt time.Time `json:"added_at"` BeadsConfig *BeadsConfig `json:"beads,omitempty"` } @@ -92,6 +93,7 @@ type RigConfig struct { Version int `json:"version"` // schema version Name string `json:"name"` // rig name GitURL string `json:"git_url"` // git repository URL + LocalRepo string `json:"local_repo,omitempty"` CreatedAt time.Time `json:"created_at"` // when the rig was created Beads *BeadsConfig `json:"beads,omitempty"` } diff --git a/internal/crew/manager.go b/internal/crew/manager.go index 6f8f68bd..ceab5c0b 100644 --- a/internal/crew/manager.go +++ b/internal/crew/manager.go @@ -72,8 +72,17 @@ func (m *Manager) Add(name string, createBranch bool) (*CrewWorker, error) { } // Clone the rig repo - if err := m.git.Clone(m.rig.GitURL, crewPath); err != nil { - return nil, fmt.Errorf("cloning rig: %w", err) + if m.rig.LocalRepo != "" { + if err := m.git.CloneWithReference(m.rig.GitURL, crewPath, m.rig.LocalRepo); err != nil { + fmt.Printf("Warning: could not clone with local repo reference: %v\n", err) + if err := m.git.Clone(m.rig.GitURL, crewPath); err != nil { + return nil, fmt.Errorf("cloning rig: %w", err) + } + } + } else { + if err := m.git.Clone(m.rig.GitURL, crewPath); err != nil { + return nil, fmt.Errorf("cloning rig: %w", err) + } } crewGit := git.NewGit(crewPath) diff --git a/internal/crew/manager_test.go b/internal/crew/manager_test.go index c5fd50e2..d1996945 100644 --- a/internal/crew/manager_test.go +++ b/internal/crew/manager_test.go @@ -99,6 +99,67 @@ func TestManagerAddAndGet(t *testing.T) { } } +func TestManagerAddUsesLocalRepoReference(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "crew-test-local-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + rigPath := filepath.Join(tmpDir, "test-rig") + if err := os.MkdirAll(rigPath, 0755); err != nil { + t.Fatalf("failed to create rig dir: %v", err) + } + + remoteRepoPath := filepath.Join(tmpDir, "remote.git") + if err := runCmd("git", "init", "--bare", remoteRepoPath); err != nil { + t.Fatalf("failed to create bare repo: %v", err) + } + + localRepoPath := filepath.Join(tmpDir, "local-repo") + if err := runCmd("git", "init", localRepoPath); err != nil { + t.Fatalf("failed to init local repo: %v", err) + } + if err := runCmd("git", "-C", localRepoPath, "config", "user.email", "test@test.com"); err != nil { + t.Fatalf("failed to configure email: %v", err) + } + if err := runCmd("git", "-C", localRepoPath, "config", "user.name", "Test"); err != nil { + t.Fatalf("failed to configure name: %v", err) + } + if err := runCmd("git", "-C", localRepoPath, "remote", "add", "origin", remoteRepoPath); err != nil { + t.Fatalf("failed to add origin: %v", err) + } + + if err := os.WriteFile(filepath.Join(localRepoPath, "README.md"), []byte("# Test\n"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + if err := runCmd("git", "-C", localRepoPath, "add", "."); err != nil { + t.Fatalf("failed to add file: %v", err) + } + if err := runCmd("git", "-C", localRepoPath, "commit", "-m", "initial"); err != nil { + t.Fatalf("failed to commit: %v", err) + } + + r := &rig.Rig{ + Name: "test-rig", + Path: rigPath, + GitURL: remoteRepoPath, + LocalRepo: localRepoPath, + } + + mgr := NewManager(r, git.NewGit(rigPath)) + + worker, err := mgr.Add("dave", false) + if err != nil { + t.Fatalf("Add failed: %v", err) + } + + alternates := filepath.Join(worker.ClonePath, ".git", "objects", "info", "alternates") + if _, err := os.Stat(alternates); err != nil { + t.Fatalf("expected alternates file: %v", err) + } +} + func TestManagerAddWithBranch(t *testing.T) { // Create temp directory for test tmpDir, err := os.MkdirTemp("", "crew-test-branch-*") diff --git a/internal/git/git.go b/internal/git/git.go index 6b38ddc7..e04d9998 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -40,6 +40,12 @@ func (g *Git) WorkDir() string { return g.workDir } +// IsRepo returns true if the workDir is a git repository. +func (g *Git) IsRepo() bool { + _, err := g.run("rev-parse", "--git-dir") + return err == nil +} + // run executes a git command and returns stdout. func (g *Git) run(args ...string) (string, error) { // If gitDir is set (bare repo), prepend --git-dir flag @@ -99,6 +105,18 @@ func (g *Git) Clone(url, dest string) error { return nil } +// CloneWithReference clones a repository using a local repo as an object reference. +// This saves disk by sharing objects without changing remotes. +func (g *Git) CloneWithReference(url, dest, reference string) error { + cmd := exec.Command("git", "clone", "--reference-if-able", reference, url, dest) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return g.wrapError(err, stderr.String(), []string{"clone", "--reference-if-able", url}) + } + return nil +} + // CloneBare clones a repository as a bare repo (no working directory). // This is used for the shared repo architecture where all worktrees share a single git database. func (g *Git) CloneBare(url, dest string) error { @@ -111,6 +129,17 @@ func (g *Git) CloneBare(url, dest string) error { return nil } +// CloneBareWithReference clones a bare repository using a local repo as an object reference. +func (g *Git) CloneBareWithReference(url, dest, reference string) error { + cmd := exec.Command("git", "clone", "--bare", "--reference-if-able", reference, url, dest) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return g.wrapError(err, stderr.String(), []string{"clone", "--bare", "--reference-if-able", url}) + } + return nil +} + // Checkout checks out the given ref. func (g *Git) Checkout(ref string) error { _, err := g.run("checkout", ref) diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 1224bb63..57d805bd 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -41,6 +41,53 @@ func initTestRepo(t *testing.T) string { return dir } +func TestIsRepo(t *testing.T) { + dir := t.TempDir() + g := NewGit(dir) + + if g.IsRepo() { + t.Fatal("expected IsRepo to be false for empty dir") + } + + cmd := exec.Command("git", "init") + cmd.Dir = dir + if err := cmd.Run(); err != nil { + t.Fatalf("git init: %v", err) + } + + if !g.IsRepo() { + t.Fatal("expected IsRepo to be true after git init") + } +} + +func TestCloneWithReferenceCreatesAlternates(t *testing.T) { + tmp := t.TempDir() + src := filepath.Join(tmp, "src") + dst := filepath.Join(tmp, "dst") + + if err := exec.Command("git", "init", src).Run(); err != nil { + t.Fatalf("init src: %v", err) + } + _ = exec.Command("git", "-C", src, "config", "user.email", "test@test.com").Run() + _ = exec.Command("git", "-C", src, "config", "user.name", "Test User").Run() + + if err := os.WriteFile(filepath.Join(src, "README.md"), []byte("# Test\n"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + _ = exec.Command("git", "-C", src, "add", ".").Run() + _ = exec.Command("git", "-C", src, "commit", "-m", "initial").Run() + + g := NewGit(tmp) + if err := g.CloneWithReference(src, dst, src); err != nil { + t.Fatalf("CloneWithReference: %v", err) + } + + alternates := filepath.Join(dst, ".git", "objects", "info", "alternates") + if _, err := os.Stat(alternates); err != nil { + t.Fatalf("expected alternates file: %v", err) + } +} + func TestCurrentBranch(t *testing.T) { dir := initTestRepo(t) g := NewGit(dir) diff --git a/internal/rig/manager.go b/internal/rig/manager.go index 478921a0..62dc887d 100644 --- a/internal/rig/manager.go +++ b/internal/rig/manager.go @@ -28,6 +28,7 @@ type RigConfig struct { Version int `json:"version"` // schema version Name string `json:"name"` // rig name GitURL string `json:"git_url"` // repository URL + LocalRepo string `json:"local_repo,omitempty"` // optional local reference repo DefaultBranch string `json:"default_branch,omitempty"` // main, master, etc. CreatedAt time.Time `json:"created_at"` // when rig was created Beads *BeadsConfig `json:"beads,omitempty"` @@ -104,10 +105,11 @@ func (m *Manager) loadRig(name string, entry config.RigEntry) (*Rig, error) { } rig := &Rig{ - Name: name, - Path: rigPath, - GitURL: entry.GitURL, - Config: entry.BeadsConfig, + Name: name, + Path: rigPath, + GitURL: entry.GitURL, + LocalRepo: entry.LocalRepo, + Config: entry.BeadsConfig, } // Scan for polecats @@ -156,6 +158,38 @@ type AddRigOptions struct { Name string // Rig name (directory name) GitURL string // Repository URL BeadsPrefix string // Beads issue prefix (defaults to derived from name) + LocalRepo string // Optional local repo for reference clones +} + +func resolveLocalRepo(path, gitURL string) (string, string) { + if path == "" { + return "", "" + } + + absPath, err := filepath.Abs(path) + if err != nil { + return "", fmt.Sprintf("local repo path invalid: %v", err) + } + + absPath, err = filepath.EvalSymlinks(absPath) + if err != nil { + return "", fmt.Sprintf("local repo path invalid: %v", err) + } + + repoGit := git.NewGit(absPath) + if !repoGit.IsRepo() { + return "", fmt.Sprintf("local repo is not a git repository: %s", absPath) + } + + origin, err := repoGit.RemoteURL("origin") + if err != nil { + return absPath, "local repo has no origin; using it anyway" + } + if origin != gitURL { + return "", fmt.Sprintf("local repo origin %q does not match %q", origin, gitURL) + } + + return absPath, "" } // AddRig creates a new rig as a container with clones for each agent. @@ -193,6 +227,11 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) { opts.BeadsPrefix = deriveBeadsPrefix(opts.Name) } + localRepo, warn := resolveLocalRepo(opts.LocalRepo, opts.GitURL) + if warn != "" { + fmt.Printf(" Warning: %s\n", warn) + } + // Create container directory if err := os.MkdirAll(rigPath, 0755); err != nil { return nil, fmt.Errorf("creating rig directory: %w", err) @@ -213,6 +252,7 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) { Version: CurrentRigConfigVersion, Name: opts.Name, GitURL: opts.GitURL, + LocalRepo: localRepo, CreatedAt: time.Now(), Beads: &BeadsConfig{ Prefix: opts.BeadsPrefix, @@ -226,8 +266,18 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) { // This allows refinery to see polecat branches without pushing to remote. // Mayor remains a separate clone (doesn't need branch visibility). bareRepoPath := filepath.Join(rigPath, ".repo.git") - if err := m.git.CloneBare(opts.GitURL, bareRepoPath); err != nil { - return nil, fmt.Errorf("creating bare repo: %w", err) + if localRepo != "" { + if err := m.git.CloneBareWithReference(opts.GitURL, bareRepoPath, localRepo); err != nil { + fmt.Printf(" Warning: could not use local repo reference: %v\n", err) + _ = os.RemoveAll(bareRepoPath) + if err := m.git.CloneBare(opts.GitURL, bareRepoPath); err != nil { + return nil, fmt.Errorf("creating bare repo: %w", err) + } + } + } else { + if err := m.git.CloneBare(opts.GitURL, bareRepoPath); err != nil { + return nil, fmt.Errorf("creating bare repo: %w", err) + } } bareGit := git.NewGitWithDir(bareRepoPath, "") @@ -246,8 +296,18 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) { if err := os.MkdirAll(filepath.Dir(mayorRigPath), 0755); err != nil { return nil, fmt.Errorf("creating mayor dir: %w", err) } - if err := m.git.Clone(opts.GitURL, mayorRigPath); err != nil { - return nil, fmt.Errorf("cloning for mayor: %w", err) + if localRepo != "" { + if err := m.git.CloneWithReference(opts.GitURL, mayorRigPath, localRepo); err != nil { + fmt.Printf(" Warning: could not use local repo reference: %v\n", err) + _ = os.RemoveAll(mayorRigPath) + if err := m.git.Clone(opts.GitURL, mayorRigPath); err != nil { + return nil, fmt.Errorf("cloning for mayor: %w", err) + } + } + } else { + if err := m.git.Clone(opts.GitURL, mayorRigPath); err != nil { + return nil, fmt.Errorf("cloning for mayor: %w", err) + } } // Check if source repo has .beads/ with its own prefix - if so, use that prefix. @@ -379,8 +439,9 @@ Use crew for your own workspace. Polecats are for batch work dispatch. // Register in town config m.config.Rigs[opts.Name] = config.RigEntry{ - GitURL: opts.GitURL, - AddedAt: time.Now(), + GitURL: opts.GitURL, + LocalRepo: localRepo, + AddedAt: time.Now(), BeadsConfig: &config.BeadsConfig{ Prefix: opts.BeadsPrefix, }, diff --git a/internal/rig/types.go b/internal/rig/types.go index 2d1a4e74..2c580a3b 100644 --- a/internal/rig/types.go +++ b/internal/rig/types.go @@ -16,6 +16,9 @@ type Rig struct { // GitURL is the remote repository URL. GitURL string `json:"git_url"` + // LocalRepo is an optional local repository used for reference clones. + LocalRepo string `json:"local_repo,omitempty"` + // Config is the rig-level configuration. Config *config.BeadsConfig `json:"config,omitempty"`