feat: allow local repo reference clones to save disk
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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-*")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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"`
|
||||
|
||||
|
||||
Reference in New Issue
Block a user