Merge branch 'fix/multi-repo-routing-pr811-v2'
This commit is contained in:
5
internal/.events.jsonl
Normal file
5
internal/.events.jsonl
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{"ts":"2026-01-18T09:52:29Z","source":"gt","type":"session_death","actor":"gt-gastown-witness","payload":{"agent":"unknown","caller":"gt doctor","reason":"zombie cleanup","session":"gt-gastown-witness"},"visibility":"feed"}
|
||||||
|
{"ts":"2026-01-18T10:00:24Z","source":"gt","type":"session_death","actor":"gt-gastown-witness","payload":{"agent":"unknown","caller":"gt doctor","reason":"zombie cleanup","session":"gt-gastown-witness"},"visibility":"feed"}
|
||||||
|
{"ts":"2026-01-18T10:06:48Z","source":"gt","type":"session_death","actor":"gt-gastown-witness","payload":{"agent":"unknown","caller":"gt doctor","reason":"zombie cleanup","session":"gt-gastown-witness"},"visibility":"feed"}
|
||||||
|
{"ts":"2026-01-18T14:26:38Z","source":"gt","type":"session_death","actor":"gt-gastown-witness","payload":{"agent":"unknown","caller":"gt doctor","reason":"zombie cleanup","session":"gt-gastown-witness"},"visibility":"feed"}
|
||||||
|
{"ts":"2026-01-18T14:30:02Z","source":"gt","type":"session_death","actor":"gt-gastown-witness","payload":{"agent":"unknown","caller":"gt doctor","reason":"zombie cleanup","session":"gt-gastown-witness"},"visibility":"feed"}
|
||||||
@@ -114,6 +114,11 @@ type Beads struct {
|
|||||||
workDir string
|
workDir string
|
||||||
beadsDir string // Optional BEADS_DIR override for cross-database access
|
beadsDir string // Optional BEADS_DIR override for cross-database access
|
||||||
isolated bool // If true, suppress inherited beads env vars (for test isolation)
|
isolated bool // If true, suppress inherited beads env vars (for test isolation)
|
||||||
|
|
||||||
|
// Lazy-cached town root for routing resolution.
|
||||||
|
// Populated on first call to getTownRoot() to avoid filesystem walk on every operation.
|
||||||
|
townRoot string
|
||||||
|
searchedRoot bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Beads wrapper for the given directory.
|
// New creates a new Beads wrapper for the given directory.
|
||||||
@@ -144,6 +149,26 @@ func (b *Beads) getActor() string {
|
|||||||
return os.Getenv("BD_ACTOR")
|
return os.Getenv("BD_ACTOR")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getTownRoot returns the Gas Town root directory, using lazy caching.
|
||||||
|
// The town root is found by walking up from workDir looking for mayor/town.json.
|
||||||
|
// Returns empty string if not in a Gas Town project.
|
||||||
|
func (b *Beads) getTownRoot() string {
|
||||||
|
if !b.searchedRoot {
|
||||||
|
b.townRoot = FindTownRoot(b.workDir)
|
||||||
|
b.searchedRoot = true
|
||||||
|
}
|
||||||
|
return b.townRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
// getResolvedBeadsDir returns the beads directory this wrapper is operating on.
|
||||||
|
// This follows any redirects and returns the actual beads directory path.
|
||||||
|
func (b *Beads) getResolvedBeadsDir() string {
|
||||||
|
if b.beadsDir != "" {
|
||||||
|
return b.beadsDir
|
||||||
|
}
|
||||||
|
return ResolveBeadsDir(b.workDir)
|
||||||
|
}
|
||||||
|
|
||||||
// Init initializes a new beads database in the working directory.
|
// Init initializes a new beads database in the working directory.
|
||||||
// This uses the same environment isolation as other commands.
|
// This uses the same environment isolation as other commands.
|
||||||
func (b *Beads) Init(prefix string) error {
|
func (b *Beads) Init(prefix string) error {
|
||||||
|
|||||||
@@ -5,9 +5,32 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// runSlotSet runs `bd slot set` from a specific directory.
|
||||||
|
// This is needed when the agent bead was created via routing to a different
|
||||||
|
// database than the Beads wrapper's default directory.
|
||||||
|
func runSlotSet(workDir, beadID, slotName, slotValue string) error {
|
||||||
|
cmd := exec.Command("bd", "slot", "set", beadID, slotName, slotValue)
|
||||||
|
cmd.Dir = workDir
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSlotClear runs `bd slot clear` from a specific directory.
|
||||||
|
func runSlotClear(workDir, beadID, slotName string) error {
|
||||||
|
cmd := exec.Command("bd", "slot", "clear", beadID, slotName)
|
||||||
|
cmd.Dir = workDir
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// AgentFields holds structured fields for agent beads.
|
// AgentFields holds structured fields for agent beads.
|
||||||
// These are stored as "key: value" lines in the description.
|
// These are stored as "key: value" lines in the description.
|
||||||
type AgentFields struct {
|
type AgentFields struct {
|
||||||
@@ -125,7 +148,21 @@ func ParseAgentFields(description string) *AgentFields {
|
|||||||
// The ID format is: <prefix>-<rig>-<role>-<name> (e.g., gt-gastown-polecat-Toast)
|
// The ID format is: <prefix>-<rig>-<role>-<name> (e.g., gt-gastown-polecat-Toast)
|
||||||
// Use AgentBeadID() helper to generate correct IDs.
|
// Use AgentBeadID() helper to generate correct IDs.
|
||||||
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
// The created_by field is populated from BD_ACTOR env var for provenance tracking.
|
||||||
|
//
|
||||||
|
// This function automatically ensures custom types are configured in the target
|
||||||
|
// database before creating the bead. This handles multi-repo routing scenarios
|
||||||
|
// where the bead may be routed to a different database than the one this wrapper
|
||||||
|
// is connected to.
|
||||||
func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, error) {
|
func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue, error) {
|
||||||
|
// Resolve where this bead will actually be written (handles multi-repo routing)
|
||||||
|
targetDir := ResolveRoutingTarget(b.getTownRoot(), id, b.getResolvedBeadsDir())
|
||||||
|
|
||||||
|
// Ensure target database has custom types configured
|
||||||
|
// This is cached (sentinel file + in-memory) so repeated calls are fast
|
||||||
|
if err := EnsureCustomTypes(targetDir); err != nil {
|
||||||
|
return nil, fmt.Errorf("prepare target for agent bead %s: %w", id, err)
|
||||||
|
}
|
||||||
|
|
||||||
description := FormatAgentDescription(title, fields)
|
description := FormatAgentDescription(title, fields)
|
||||||
|
|
||||||
args := []string{"create", "--json",
|
args := []string{"create", "--json",
|
||||||
@@ -160,8 +197,9 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue,
|
|||||||
// Set the hook slot if specified (this is the authoritative storage)
|
// Set the hook slot if specified (this is the authoritative storage)
|
||||||
// This fixes the slot inconsistency bug where bead status is 'hooked' but
|
// This fixes the slot inconsistency bug where bead status is 'hooked' but
|
||||||
// agent's hook slot is empty. See mi-619.
|
// agent's hook slot is empty. See mi-619.
|
||||||
|
// Must run from targetDir since that's where the agent bead was created
|
||||||
if fields != nil && fields.HookBead != "" {
|
if fields != nil && fields.HookBead != "" {
|
||||||
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
|
if err := runSlotSet(targetDir, id, "hook", fields.HookBead); err != nil {
|
||||||
// Non-fatal: warn but continue - description text has the backup
|
// Non-fatal: warn but continue - description text has the backup
|
||||||
fmt.Printf("Warning: could not set hook slot: %v\n", err)
|
fmt.Printf("Warning: could not set hook slot: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -195,6 +233,9 @@ func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) (
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve where this bead lives (for slot operations)
|
||||||
|
targetDir := ResolveRoutingTarget(b.getTownRoot(), id, b.getResolvedBeadsDir())
|
||||||
|
|
||||||
// The bead already exists (should be closed from previous polecat lifecycle)
|
// The bead already exists (should be closed from previous polecat lifecycle)
|
||||||
// Reopen it and update its fields
|
// Reopen it and update its fields
|
||||||
if _, reopenErr := b.run("reopen", id, "--reason=re-spawning agent"); reopenErr != nil {
|
if _, reopenErr := b.run("reopen", id, "--reason=re-spawning agent"); reopenErr != nil {
|
||||||
@@ -217,12 +258,14 @@ func (b *Beads) CreateOrReopenAgentBead(id, title string, fields *AgentFields) (
|
|||||||
// Note: role slot no longer set - role definitions are config-based
|
// Note: role slot no longer set - role definitions are config-based
|
||||||
|
|
||||||
// Clear any existing hook slot (handles stale state from previous lifecycle)
|
// Clear any existing hook slot (handles stale state from previous lifecycle)
|
||||||
_, _ = b.run("slot", "clear", id, "hook")
|
// Must run from targetDir since that's where the agent bead lives
|
||||||
|
_ = runSlotClear(targetDir, id, "hook")
|
||||||
|
|
||||||
// Set the hook slot if specified
|
// Set the hook slot if specified
|
||||||
|
// Must run from targetDir since that's where the agent bead lives
|
||||||
if fields != nil && fields.HookBead != "" {
|
if fields != nil && fields.HookBead != "" {
|
||||||
if _, err := b.run("slot", "set", id, "hook", fields.HookBead); err != nil {
|
if err := runSlotSet(targetDir, id, "hook", fields.HookBead); err != nil {
|
||||||
// Non-fatal: warn but continue
|
// Non-fatal: warn but continue - description text has the backup
|
||||||
fmt.Printf("Warning: could not set hook slot: %v\n", err)
|
fmt.Printf("Warning: could not set hook slot: %v\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
129
internal/beads/beads_types.go
Normal file
129
internal/beads/beads_types.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// Package beads provides custom type management for agent beads.
|
||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/steveyegge/gastown/internal/constants"
|
||||||
|
)
|
||||||
|
|
||||||
|
// typesSentinel is a marker file indicating custom types have been configured.
|
||||||
|
// This persists across CLI invocations to avoid redundant bd config calls.
|
||||||
|
const typesSentinel = ".gt-types-configured"
|
||||||
|
|
||||||
|
// ensuredDirs tracks which beads directories have been ensured this session.
|
||||||
|
// This provides fast in-memory caching for multiple creates in the same CLI run.
|
||||||
|
var (
|
||||||
|
ensuredDirs = make(map[string]bool)
|
||||||
|
ensuredMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
|
// FindTownRoot walks up from startDir to find the Gas Town root directory.
|
||||||
|
// The town root is identified by the presence of mayor/town.json.
|
||||||
|
// Returns empty string if not found (reached filesystem root).
|
||||||
|
func FindTownRoot(startDir string) string {
|
||||||
|
dir := startDir
|
||||||
|
for {
|
||||||
|
townFile := filepath.Join(dir, "mayor", "town.json")
|
||||||
|
if _, err := os.Stat(townFile); err == nil {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(dir)
|
||||||
|
if parent == dir {
|
||||||
|
return "" // Reached filesystem root
|
||||||
|
}
|
||||||
|
dir = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveRoutingTarget determines which beads directory a bead ID will route to.
|
||||||
|
// It extracts the prefix from the bead ID and looks up the corresponding route.
|
||||||
|
// Returns the resolved beads directory path, following any redirects.
|
||||||
|
//
|
||||||
|
// If townRoot is empty or prefix is not found, falls back to the provided fallbackDir.
|
||||||
|
func ResolveRoutingTarget(townRoot, beadID, fallbackDir string) string {
|
||||||
|
if townRoot == "" {
|
||||||
|
return fallbackDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract prefix from bead ID (e.g., "gt-gastown-polecat-Toast" -> "gt-")
|
||||||
|
prefix := ExtractPrefix(beadID)
|
||||||
|
if prefix == "" {
|
||||||
|
return fallbackDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up rig path for this prefix
|
||||||
|
rigPath := GetRigPathForPrefix(townRoot, prefix)
|
||||||
|
if rigPath == "" {
|
||||||
|
return fallbackDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve redirects and get final beads directory
|
||||||
|
beadsDir := ResolveBeadsDir(rigPath)
|
||||||
|
if beadsDir == "" {
|
||||||
|
return fallbackDir
|
||||||
|
}
|
||||||
|
|
||||||
|
return beadsDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureCustomTypes ensures the target beads directory has custom types configured.
|
||||||
|
// Uses a two-level caching strategy:
|
||||||
|
// - In-memory cache for multiple creates in the same CLI invocation
|
||||||
|
// - Sentinel file on disk for persistence across CLI invocations
|
||||||
|
//
|
||||||
|
// This function is thread-safe and idempotent.
|
||||||
|
func EnsureCustomTypes(beadsDir string) error {
|
||||||
|
if beadsDir == "" {
|
||||||
|
return fmt.Errorf("empty beads directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
ensuredMu.Lock()
|
||||||
|
defer ensuredMu.Unlock()
|
||||||
|
|
||||||
|
// Fast path: in-memory cache (same CLI invocation)
|
||||||
|
if ensuredDirs[beadsDir] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path: sentinel file exists (previous CLI invocation)
|
||||||
|
sentinelPath := filepath.Join(beadsDir, typesSentinel)
|
||||||
|
if _, err := os.Stat(sentinelPath); err == nil {
|
||||||
|
ensuredDirs[beadsDir] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify beads directory exists
|
||||||
|
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("beads directory does not exist: %s", beadsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure custom types via bd CLI
|
||||||
|
typesList := strings.Join(constants.BeadsCustomTypesList(), ",")
|
||||||
|
cmd := exec.Command("bd", "config", "set", "types.custom", typesList)
|
||||||
|
cmd.Dir = beadsDir
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("configure custom types in %s: %s: %w",
|
||||||
|
beadsDir, strings.TrimSpace(string(output)), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write sentinel file (best effort - don't fail if this fails)
|
||||||
|
// The sentinel contains a version marker for future compatibility
|
||||||
|
_ = os.WriteFile(sentinelPath, []byte("v1\n"), 0644)
|
||||||
|
|
||||||
|
ensuredDirs[beadsDir] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetEnsuredDirs clears the in-memory cache of ensured directories.
|
||||||
|
// This is primarily useful for testing.
|
||||||
|
func ResetEnsuredDirs() {
|
||||||
|
ensuredMu.Lock()
|
||||||
|
defer ensuredMu.Unlock()
|
||||||
|
ensuredDirs = make(map[string]bool)
|
||||||
|
}
|
||||||
234
internal/beads/beads_types_test.go
Normal file
234
internal/beads/beads_types_test.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
package beads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFindTownRoot(t *testing.T) {
|
||||||
|
// Create a temporary town structure
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
mayorDir := filepath.Join(tmpDir, "mayor")
|
||||||
|
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(mayorDir, "town.json"), []byte("{}"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create nested directories
|
||||||
|
deepDir := filepath.Join(tmpDir, "rig1", "crew", "worker1")
|
||||||
|
if err := os.MkdirAll(deepDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
startDir string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"from town root", tmpDir, tmpDir},
|
||||||
|
{"from mayor dir", mayorDir, tmpDir},
|
||||||
|
{"from deep nested dir", deepDir, tmpDir},
|
||||||
|
{"from non-town dir", t.TempDir(), ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
result := FindTownRoot(tc.startDir)
|
||||||
|
if result != tc.expected {
|
||||||
|
t.Errorf("FindTownRoot(%q) = %q, want %q", tc.startDir, result, tc.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRoutingTarget(t *testing.T) {
|
||||||
|
// Create a temporary town with routes
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create mayor/town.json for FindTownRoot
|
||||||
|
mayorDir := filepath.Join(tmpDir, "mayor")
|
||||||
|
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(mayorDir, "town.json"), []byte("{}"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create routes.jsonl
|
||||||
|
routesContent := `{"prefix": "gt-", "path": "gastown/mayor/rig"}
|
||||||
|
{"prefix": "hq-", "path": "."}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(beadsDir, "routes.jsonl"), []byte(routesContent), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the rig beads directory
|
||||||
|
rigBeadsDir := filepath.Join(tmpDir, "gastown", "mayor", "rig", ".beads")
|
||||||
|
if err := os.MkdirAll(rigBeadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fallback := "/fallback/.beads"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
townRoot string
|
||||||
|
beadID string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "rig-level bead routes to rig",
|
||||||
|
townRoot: tmpDir,
|
||||||
|
beadID: "gt-gastown-polecat-Toast",
|
||||||
|
expected: rigBeadsDir,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "town-level bead routes to town",
|
||||||
|
townRoot: tmpDir,
|
||||||
|
beadID: "hq-mayor",
|
||||||
|
expected: beadsDir,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown prefix falls back",
|
||||||
|
townRoot: tmpDir,
|
||||||
|
beadID: "xx-unknown",
|
||||||
|
expected: fallback,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty townRoot falls back",
|
||||||
|
townRoot: "",
|
||||||
|
beadID: "gt-gastown-polecat-Toast",
|
||||||
|
expected: fallback,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no prefix falls back",
|
||||||
|
townRoot: tmpDir,
|
||||||
|
beadID: "noprefixid",
|
||||||
|
expected: fallback,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
result := ResolveRoutingTarget(tc.townRoot, tc.beadID, fallback)
|
||||||
|
if result != tc.expected {
|
||||||
|
t.Errorf("ResolveRoutingTarget(%q, %q, %q) = %q, want %q",
|
||||||
|
tc.townRoot, tc.beadID, fallback, result, tc.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureCustomTypes(t *testing.T) {
|
||||||
|
// Reset the in-memory cache before testing
|
||||||
|
ResetEnsuredDirs()
|
||||||
|
|
||||||
|
t.Run("empty beads dir returns error", func(t *testing.T) {
|
||||||
|
err := EnsureCustomTypes("")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for empty beads dir")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-existent beads dir returns error", func(t *testing.T) {
|
||||||
|
err := EnsureCustomTypes("/nonexistent/path/.beads")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for non-existent beads dir")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sentinel file triggers cache hit", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sentinel file
|
||||||
|
sentinelPath := filepath.Join(beadsDir, typesSentinel)
|
||||||
|
if err := os.WriteFile(sentinelPath, []byte("v1\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset cache to ensure we're testing sentinel detection
|
||||||
|
ResetEnsuredDirs()
|
||||||
|
|
||||||
|
// This should succeed without running bd (sentinel exists)
|
||||||
|
err := EnsureCustomTypes(beadsDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("expected success with sentinel file, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("in-memory cache prevents repeated calls", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||||
|
if err := os.MkdirAll(beadsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sentinel to avoid bd call
|
||||||
|
sentinelPath := filepath.Join(beadsDir, typesSentinel)
|
||||||
|
if err := os.WriteFile(sentinelPath, []byte("v1\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ResetEnsuredDirs()
|
||||||
|
|
||||||
|
// First call
|
||||||
|
if err := EnsureCustomTypes(beadsDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove sentinel - second call should still succeed due to in-memory cache
|
||||||
|
os.Remove(sentinelPath)
|
||||||
|
|
||||||
|
if err := EnsureCustomTypes(beadsDir); err != nil {
|
||||||
|
t.Errorf("expected cache hit, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBeads_getTownRoot(t *testing.T) {
|
||||||
|
// Create a temporary town
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
mayorDir := filepath.Join(tmpDir, "mayor")
|
||||||
|
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(mayorDir, "town.json"), []byte("{}"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create nested directory
|
||||||
|
rigDir := filepath.Join(tmpDir, "myrig", "mayor", "rig")
|
||||||
|
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b := New(rigDir)
|
||||||
|
|
||||||
|
// First call should find town root
|
||||||
|
root1 := b.getTownRoot()
|
||||||
|
if root1 != tmpDir {
|
||||||
|
t.Errorf("first getTownRoot() = %q, want %q", root1, tmpDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second call should return cached value
|
||||||
|
root2 := b.getTownRoot()
|
||||||
|
if root2 != root1 {
|
||||||
|
t.Errorf("second getTownRoot() = %q, want cached %q", root2, root1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify searchedRoot flag is set
|
||||||
|
if !b.searchedRoot {
|
||||||
|
t.Error("expected searchedRoot to be true after getTownRoot()")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime && gt mail check --inject && gt nudge deacon session-started"
|
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime --hook && gt mail check --inject && gt nudge deacon session-started"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime"
|
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime --hook"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime && gt nudge deacon session-started"
|
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime --hook && gt nudge deacon session-started"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime"
|
"command": "export PATH=\"$HOME/go/bin:$HOME/bin:$PATH\" && gt prime --hook"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,11 @@ func runGitInit(cmd *cobra.Command, args []string) error {
|
|||||||
fmt.Printf(" ✓ Git repository already exists\n")
|
fmt.Printf(" ✓ Git repository already exists\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Install pre-checkout hook to prevent accidental branch switches
|
||||||
|
if err := InstallPreCheckoutHook(hqRoot); err != nil {
|
||||||
|
fmt.Printf(" %s Could not install pre-checkout hook: %v\n", style.Dim.Render("⚠"), err)
|
||||||
|
}
|
||||||
|
|
||||||
// Create GitHub repo if requested
|
// Create GitHub repo if requested
|
||||||
if gitInitGitHub != "" {
|
if gitInitGitHub != "" {
|
||||||
if err := createGitHubRepo(hqRoot, gitInitGitHub, !gitInitPublic); err != nil {
|
if err := createGitHubRepo(hqRoot, gitInitGitHub, !gitInitPublic); err != nil {
|
||||||
|
|||||||
@@ -221,6 +221,30 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
|||||||
fmt.Printf(" ✓ Created deacon/.claude/settings.json\n")
|
fmt.Printf(" ✓ Created deacon/.claude/settings.json\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create boot directory (deacon/dogs/boot/) for Boot watchdog.
|
||||||
|
// This avoids gt doctor warning on fresh install.
|
||||||
|
bootDir := filepath.Join(deaconDir, "dogs", "boot")
|
||||||
|
if err := os.MkdirAll(bootDir, 0755); err != nil {
|
||||||
|
fmt.Printf(" %s Could not create boot directory: %v\n", style.Dim.Render("⚠"), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create plugins directory for town-level patrol plugins.
|
||||||
|
// This avoids gt doctor warning on fresh install.
|
||||||
|
pluginsDir := filepath.Join(absPath, "plugins")
|
||||||
|
if err := os.MkdirAll(pluginsDir, 0755); err != nil {
|
||||||
|
fmt.Printf(" %s Could not create plugins directory: %v\n", style.Dim.Render("⚠"), err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ✓ Created plugins/\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create daemon.json patrol config.
|
||||||
|
// This avoids gt doctor warning on fresh install.
|
||||||
|
if err := config.EnsureDaemonPatrolConfig(absPath); err != nil {
|
||||||
|
fmt.Printf(" %s Could not create daemon.json: %v\n", style.Dim.Render("⚠"), err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" ✓ Created mayor/daemon.json\n")
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize git BEFORE beads so that bd can compute repository fingerprint.
|
// Initialize git BEFORE beads so that bd can compute repository fingerprint.
|
||||||
// The fingerprint is required for the daemon to start properly.
|
// The fingerprint is required for the daemon to start properly.
|
||||||
if installGit || installGitHub != "" {
|
if installGit || installGitHub != "" {
|
||||||
@@ -234,6 +258,12 @@ func runInstall(cmd *cobra.Command, args []string) error {
|
|||||||
// Town beads (hq- prefix) stores mayor mail, cross-rig coordination, and handoffs.
|
// Town beads (hq- prefix) stores mayor mail, cross-rig coordination, and handoffs.
|
||||||
// Rig beads are separate and have their own prefixes.
|
// Rig beads are separate and have their own prefixes.
|
||||||
if !installNoBeads {
|
if !installNoBeads {
|
||||||
|
// Kill any orphaned bd daemons before initializing beads.
|
||||||
|
// Stale daemons can interfere with fresh database creation.
|
||||||
|
if killed, _, _ := beads.StopAllBdProcesses(false, true); killed > 0 {
|
||||||
|
fmt.Printf(" ✓ Stopped %d orphaned bd daemon(s)\n", killed)
|
||||||
|
}
|
||||||
|
|
||||||
if err := initTownBeads(absPath); err != nil {
|
if err := initTownBeads(absPath); err != nil {
|
||||||
fmt.Printf(" %s Could not initialize town beads: %v\n", style.Dim.Render("⚠"), err)
|
fmt.Printf(" %s Could not initialize town beads: %v\n", style.Dim.Render("⚠"), err)
|
||||||
} else {
|
} else {
|
||||||
@@ -369,6 +399,19 @@ func initTownBeads(townPath string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify .beads directory was actually created (bd init can exit 0 without creating it)
|
||||||
|
beadsDir := filepath.Join(townPath, ".beads")
|
||||||
|
if _, statErr := os.Stat(beadsDir); os.IsNotExist(statErr) {
|
||||||
|
return fmt.Errorf("bd init succeeded but .beads directory not created (check bd daemon interference)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicitly set issue_prefix config (bd init --prefix may not persist it in newer versions).
|
||||||
|
prefixSetCmd := exec.Command("bd", "config", "set", "issue_prefix", "hq")
|
||||||
|
prefixSetCmd.Dir = townPath
|
||||||
|
if prefixOutput, prefixErr := prefixSetCmd.CombinedOutput(); prefixErr != nil {
|
||||||
|
return fmt.Errorf("bd config set issue_prefix failed: %s", strings.TrimSpace(string(prefixOutput)))
|
||||||
|
}
|
||||||
|
|
||||||
// Configure custom types for Gas Town (agent, role, rig, convoy, slot).
|
// Configure custom types for Gas Town (agent, role, rig, convoy, slot).
|
||||||
// These were extracted from beads core in v0.46.0 and now require explicit config.
|
// These were extracted from beads core in v0.46.0 and now require explicit config.
|
||||||
configCmd := exec.Command("bd", "config", "set", "types.custom", constants.BeadsCustomTypes)
|
configCmd := exec.Command("bd", "config", "set", "types.custom", constants.BeadsCustomTypes)
|
||||||
@@ -468,7 +511,7 @@ func initTownAgentBeads(townPath string) error {
|
|||||||
// bd init doesn't enable "custom" issue types by default, but Gas Town uses
|
// bd init doesn't enable "custom" issue types by default, but Gas Town uses
|
||||||
// agent beads during install and runtime. Ensure these types are enabled
|
// agent beads during install and runtime. Ensure these types are enabled
|
||||||
// before attempting to create any town-level system beads.
|
// before attempting to create any town-level system beads.
|
||||||
if err := ensureBeadsCustomTypes(townPath, []string{"agent", "rig", "convoy", "slot"}); err != nil {
|
if err := ensureBeadsCustomTypes(townPath, constants.BeadsCustomTypesList()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,20 +11,37 @@ import (
|
|||||||
// BootHealthCheck verifies Boot watchdog health.
|
// BootHealthCheck verifies Boot watchdog health.
|
||||||
// "The vet checks on the dog."
|
// "The vet checks on the dog."
|
||||||
type BootHealthCheck struct {
|
type BootHealthCheck struct {
|
||||||
BaseCheck
|
FixableCheck
|
||||||
|
missingDir bool // track if directory is missing for Fix()
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBootHealthCheck creates a new Boot health check.
|
// NewBootHealthCheck creates a new Boot health check.
|
||||||
func NewBootHealthCheck() *BootHealthCheck {
|
func NewBootHealthCheck() *BootHealthCheck {
|
||||||
return &BootHealthCheck{
|
return &BootHealthCheck{
|
||||||
BaseCheck: BaseCheck{
|
FixableCheck: FixableCheck{
|
||||||
CheckName: "boot-health",
|
BaseCheck: BaseCheck{
|
||||||
CheckDescription: "Check Boot watchdog health (the vet checks on the dog)",
|
CheckName: "boot-health",
|
||||||
CheckCategory: CategoryInfrastructure,
|
CheckDescription: "Check Boot watchdog health (the vet checks on the dog)",
|
||||||
|
CheckCategory: CategoryInfrastructure,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanFix returns true only if the directory is missing (we can create it).
|
||||||
|
func (c *BootHealthCheck) CanFix() bool {
|
||||||
|
return c.missingDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix creates the missing boot directory.
|
||||||
|
func (c *BootHealthCheck) Fix(ctx *CheckContext) error {
|
||||||
|
if !c.missingDir {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b := boot.New(ctx.TownRoot)
|
||||||
|
return b.EnsureDir()
|
||||||
|
}
|
||||||
|
|
||||||
// Run checks Boot health: directory, session, status, and marker freshness.
|
// Run checks Boot health: directory, session, status, and marker freshness.
|
||||||
func (c *BootHealthCheck) Run(ctx *CheckContext) *CheckResult {
|
func (c *BootHealthCheck) Run(ctx *CheckContext) *CheckResult {
|
||||||
b := boot.New(ctx.TownRoot)
|
b := boot.New(ctx.TownRoot)
|
||||||
@@ -33,12 +50,13 @@ func (c *BootHealthCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
// Check 1: Boot directory exists
|
// Check 1: Boot directory exists
|
||||||
bootDir := b.Dir()
|
bootDir := b.Dir()
|
||||||
if _, err := os.Stat(bootDir); os.IsNotExist(err) {
|
if _, err := os.Stat(bootDir); os.IsNotExist(err) {
|
||||||
|
c.missingDir = true
|
||||||
return &CheckResult{
|
return &CheckResult{
|
||||||
Name: c.Name(),
|
Name: c.Name(),
|
||||||
Status: StatusWarning,
|
Status: StatusWarning,
|
||||||
Message: "Boot directory not present",
|
Message: "Boot directory not present",
|
||||||
Details: []string{fmt.Sprintf("Expected: %s", bootDir)},
|
Details: []string{fmt.Sprintf("Expected: %s", bootDir)},
|
||||||
FixHint: "Boot directory is created on first daemon run",
|
FixHint: "Run 'gt doctor --fix' to create it",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -458,11 +458,20 @@ func (c *PatrolRolesHavePromptsCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var missingPrompts []string
|
var missingPrompts []string
|
||||||
|
rigsChecked := 0
|
||||||
for _, rigName := range rigs {
|
for _, rigName := range rigs {
|
||||||
// Check in mayor's clone (canonical for the rig)
|
// Check in mayor's clone (canonical for the rig)
|
||||||
mayorRig := filepath.Join(ctx.TownRoot, rigName, "mayor", "rig")
|
mayorRig := filepath.Join(ctx.TownRoot, rigName, "mayor", "rig")
|
||||||
templatesDir := filepath.Join(mayorRig, "internal", "templates", "roles")
|
templatesDir := filepath.Join(mayorRig, "internal", "templates", "roles")
|
||||||
|
|
||||||
|
// Skip rigs that don't have internal/templates structure.
|
||||||
|
// Most repos won't have this - templates are embedded in gastown binary.
|
||||||
|
// Only check rigs that explicitly have their own template overrides.
|
||||||
|
if _, err := os.Stat(filepath.Join(mayorRig, "internal", "templates")); os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rigsChecked++
|
||||||
|
|
||||||
var rigMissing []string
|
var rigMissing []string
|
||||||
for _, roleFile := range requiredRolePrompts {
|
for _, roleFile := range requiredRolePrompts {
|
||||||
promptPath := filepath.Join(templatesDir, roleFile)
|
promptPath := filepath.Join(templatesDir, roleFile)
|
||||||
@@ -476,13 +485,21 @@ func (c *PatrolRolesHavePromptsCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Templates are embedded in gastown binary - missing files in rig repos is normal.
|
||||||
|
// Only report as informational, not a warning.
|
||||||
|
if rigsChecked == 0 {
|
||||||
|
return &CheckResult{
|
||||||
|
Name: c.Name(),
|
||||||
|
Status: StatusOK,
|
||||||
|
Message: "Using embedded role templates (no custom overrides)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(missingPrompts) > 0 {
|
if len(missingPrompts) > 0 {
|
||||||
return &CheckResult{
|
return &CheckResult{
|
||||||
Name: c.Name(),
|
Name: c.Name(),
|
||||||
Status: StatusWarning,
|
Status: StatusOK,
|
||||||
Message: fmt.Sprintf("%d role prompt template(s) missing", len(missingPrompts)),
|
Message: fmt.Sprintf("%d rig(s) using embedded templates for some roles", len(c.missingByRig)),
|
||||||
Details: missingPrompts,
|
|
||||||
FixHint: "Run 'gt doctor --fix' to copy embedded templates to rig repos",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,13 +116,35 @@ func (g *Git) wrapError(err error, stdout, stderr string, args []string) error {
|
|||||||
|
|
||||||
// Clone clones a repository to the destination.
|
// Clone clones a repository to the destination.
|
||||||
func (g *Git) Clone(url, dest string) error {
|
func (g *Git) Clone(url, dest string) error {
|
||||||
cmd := exec.Command("git", "clone", url, dest)
|
// Ensure destination directory's parent exists
|
||||||
|
destParent := filepath.Dir(dest)
|
||||||
|
if err := os.MkdirAll(destParent, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating destination parent: %w", err)
|
||||||
|
}
|
||||||
|
// Run clone from a temporary directory to completely isolate from any
|
||||||
|
// git repo at the process cwd. Then move the result to the destination.
|
||||||
|
tmpDir, err := os.MkdirTemp("", "gt-clone-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating temp dir: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||||
|
|
||||||
|
tmpDest := filepath.Join(tmpDir, filepath.Base(dest))
|
||||||
|
cmd := exec.Command("git", "clone", url, tmpDest)
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
cmd.Env = append(os.Environ(), "GIT_CEILING_DIRECTORIES="+tmpDir)
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return g.wrapError(err, stdout.String(), stderr.String(), []string{"clone", url})
|
return g.wrapError(err, stdout.String(), stderr.String(), []string{"clone", url})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move to final destination
|
||||||
|
if err := os.Rename(tmpDest, dest); err != nil {
|
||||||
|
return fmt.Errorf("moving clone to destination: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Configure hooks path for Gas Town clones
|
// Configure hooks path for Gas Town clones
|
||||||
if err := configureHooksPath(dest); err != nil {
|
if err := configureHooksPath(dest); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -134,13 +156,35 @@ func (g *Git) Clone(url, dest string) error {
|
|||||||
// CloneWithReference clones a repository using a local repo as an object reference.
|
// CloneWithReference clones a repository using a local repo as an object reference.
|
||||||
// This saves disk by sharing objects without changing remotes.
|
// This saves disk by sharing objects without changing remotes.
|
||||||
func (g *Git) CloneWithReference(url, dest, reference string) error {
|
func (g *Git) CloneWithReference(url, dest, reference string) error {
|
||||||
cmd := exec.Command("git", "clone", "--reference-if-able", reference, url, dest)
|
// Ensure destination directory's parent exists
|
||||||
|
destParent := filepath.Dir(dest)
|
||||||
|
if err := os.MkdirAll(destParent, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating destination parent: %w", err)
|
||||||
|
}
|
||||||
|
// Run clone from a temporary directory to completely isolate from any
|
||||||
|
// git repo at the process cwd. Then move the result to the destination.
|
||||||
|
tmpDir, err := os.MkdirTemp("", "gt-clone-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating temp dir: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||||
|
|
||||||
|
tmpDest := filepath.Join(tmpDir, filepath.Base(dest))
|
||||||
|
cmd := exec.Command("git", "clone", "--reference-if-able", reference, url, tmpDest)
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
cmd.Env = append(os.Environ(), "GIT_CEILING_DIRECTORIES="+tmpDir)
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return g.wrapError(err, stdout.String(), stderr.String(), []string{"clone", "--reference-if-able", url})
|
return g.wrapError(err, stdout.String(), stderr.String(), []string{"clone", "--reference-if-able", url})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move to final destination
|
||||||
|
if err := os.Rename(tmpDest, dest); err != nil {
|
||||||
|
return fmt.Errorf("moving clone to destination: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Configure hooks path for Gas Town clones
|
// Configure hooks path for Gas Town clones
|
||||||
if err := configureHooksPath(dest); err != nil {
|
if err := configureHooksPath(dest); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -152,13 +196,35 @@ func (g *Git) CloneWithReference(url, dest, reference string) error {
|
|||||||
// CloneBare clones a repository as a bare repo (no working directory).
|
// 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.
|
// This is used for the shared repo architecture where all worktrees share a single git database.
|
||||||
func (g *Git) CloneBare(url, dest string) error {
|
func (g *Git) CloneBare(url, dest string) error {
|
||||||
cmd := exec.Command("git", "clone", "--bare", url, dest)
|
// Ensure destination directory's parent exists
|
||||||
|
destParent := filepath.Dir(dest)
|
||||||
|
if err := os.MkdirAll(destParent, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating destination parent: %w", err)
|
||||||
|
}
|
||||||
|
// Run clone from a temporary directory to completely isolate from any
|
||||||
|
// git repo at the process cwd. Then move the result to the destination.
|
||||||
|
tmpDir, err := os.MkdirTemp("", "gt-clone-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating temp dir: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||||
|
|
||||||
|
tmpDest := filepath.Join(tmpDir, filepath.Base(dest))
|
||||||
|
cmd := exec.Command("git", "clone", "--bare", url, tmpDest)
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
cmd.Env = append(os.Environ(), "GIT_CEILING_DIRECTORIES="+tmpDir)
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return g.wrapError(err, stdout.String(), stderr.String(), []string{"clone", "--bare", url})
|
return g.wrapError(err, stdout.String(), stderr.String(), []string{"clone", "--bare", url})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move to final destination
|
||||||
|
if err := os.Rename(tmpDest, dest); err != nil {
|
||||||
|
return fmt.Errorf("moving clone to destination: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Configure refspec so worktrees can fetch and see origin/* refs
|
// Configure refspec so worktrees can fetch and see origin/* refs
|
||||||
return configureRefspec(dest)
|
return configureRefspec(dest)
|
||||||
}
|
}
|
||||||
@@ -212,13 +278,35 @@ func configureRefspec(repoPath string) error {
|
|||||||
|
|
||||||
// CloneBareWithReference clones a bare repository using a local repo as an object reference.
|
// CloneBareWithReference clones a bare repository using a local repo as an object reference.
|
||||||
func (g *Git) CloneBareWithReference(url, dest, reference string) error {
|
func (g *Git) CloneBareWithReference(url, dest, reference string) error {
|
||||||
cmd := exec.Command("git", "clone", "--bare", "--reference-if-able", reference, url, dest)
|
// Ensure destination directory's parent exists
|
||||||
|
destParent := filepath.Dir(dest)
|
||||||
|
if err := os.MkdirAll(destParent, 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating destination parent: %w", err)
|
||||||
|
}
|
||||||
|
// Run clone from a temporary directory to completely isolate from any
|
||||||
|
// git repo at the process cwd. Then move the result to the destination.
|
||||||
|
tmpDir, err := os.MkdirTemp("", "gt-clone-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating temp dir: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||||
|
|
||||||
|
tmpDest := filepath.Join(tmpDir, filepath.Base(dest))
|
||||||
|
cmd := exec.Command("git", "clone", "--bare", "--reference-if-able", reference, url, tmpDest)
|
||||||
|
cmd.Dir = tmpDir
|
||||||
|
cmd.Env = append(os.Environ(), "GIT_CEILING_DIRECTORIES="+tmpDir)
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return g.wrapError(err, stdout.String(), stderr.String(), []string{"clone", "--bare", "--reference-if-able", url})
|
return g.wrapError(err, stdout.String(), stderr.String(), []string{"clone", "--bare", "--reference-if-able", url})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move to final destination
|
||||||
|
if err := os.Rename(tmpDest, dest); err != nil {
|
||||||
|
return fmt.Errorf("moving clone to destination: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Configure refspec so worktrees can fetch and see origin/* refs
|
// Configure refspec so worktrees can fetch and see origin/* refs
|
||||||
return configureRefspec(dest)
|
return configureRefspec(dest)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user