Support local-only git repos without remote origin (bd-biwp)

- Add hasGitRemote() helper to detect if any remote exists
- Gracefully skip git pull/push when no remote configured
- Daemon now works in local-only mode (RPC, auto-flush, JSONL export)
- Add comprehensive test coverage for local-only workflows
- Fixes GH#279: daemon crash on repos without origin remote

Amp-Thread-ID: https://ampcode.com/threads/T-5dad0ca8-ac77-4ae0-8de6-208b23ea47af
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-11-09 16:16:45 -08:00
parent 23fce6ea49
commit 4de9f015d6
4 changed files with 166 additions and 0 deletions

View File

@@ -279,6 +279,7 @@
{"id":"bd-bdhn","content_hash":"ddbc003327e0492285b53fd765e90a816b9cea1e4cf9fc8797e8a465a1e834bd","title":"bd message: Add input validation for --importance flag","description":"The --importance flag accepts any string without validation, leading to confusing server errors.\n\n**Location:** cmd/bd/message.go:256-258\n\n**Fix:**\n- Add flag validation for: low, normal, high, urgent\n- Add shell completion support\n- Validate in runMessageSend before sending\n\n**Impact:** Better UX, prevents confusing errors","status":"closed","priority":1,"issue_type":"bug","created_at":"2025-11-08T12:54:26.43027-08:00","updated_at":"2025-11-08T12:57:59.65367-08:00","closed_at":"2025-11-08T12:57:59.65367-08:00","source_repo":".","dependencies":[{"issue_id":"bd-bdhn","depends_on_id":"bd-6uix","type":"parent-child","created_at":"2025-11-08T12:55:54.910841-08:00","created_by":"daemon"}]}
{"id":"bd-be7a","content_hash":"d9043a7a49f8e42dc88c3c01aaa178c1560b67c1637c3373b39c387272e8b725","title":"Create npm package structure with package.json","description":"Set up initial npm package structure for @beads/bd:\n\n## Files to create\n- npm/package.json - Package metadata, dependencies, scripts\n- npm/bin/bd - CLI wrapper script that invokes native binary\n- npm/.gitignore - Ignore downloaded binaries\n- npm/README.md - Installation and usage instructions\n\n## package.json structure\n- Name: @beads/bd (scoped package)\n- Main: index.js (exports binary path)\n- Bin: bin/bd (CLI entry point)\n- Scripts: postinstall (download binary)\n- Keywords: issue-tracker, cli, beads, bd\n- License: MIT\n\n## Bin wrapper\nSimple Node.js script that:\n- Spawns native binary with child_process.spawn\n- Passes through all arguments and stdio\n- Exits with binary's exit code","status":"closed","priority":1,"issue_type":"task","created_at":"2025-11-02T23:39:47.416779-08:00","updated_at":"2025-11-03T10:31:45.381258-08:00","closed_at":"2025-11-03T10:31:45.381258-08:00","source_repo":".","dependencies":[{"issue_id":"bd-be7a","depends_on_id":"bd-febc","type":"parent-child","created_at":"2025-11-02T23:40:32.923859-08:00","created_by":"daemon"}]}
{"id":"bd-bgca","content_hash":"c617d03baef137f2425cea14eb5346012e556b35e9048f0601fe8d719b5b705f","title":"Latency test manual","description":"","status":"closed","priority":3,"issue_type":"task","created_at":"2025-11-08T00:04:25.028223-08:00","updated_at":"2025-11-08T00:06:46.169654-08:00","closed_at":"2025-11-08T00:06:46.169654-08:00","source_repo":"."}
{"id":"bd-biwp","content_hash":"ece37e742d401489872e2735084fc94510f9308c3acff2659b233ab19440ebb4","title":"Support local-only git repos without remote origin","description":"Daemon crashes when working with local git repos that don't have origin remote configured. Should gracefully degrade to local-only mode: skip git pull/push operations but maintain daemon features (RPC server, auto-flush, JSONL export).","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2025-11-09T16:09:50.677769-08:00","updated_at":"2025-11-09T16:13:21.970934-08:00","external_ref":"gh#279","source_repo":"."}
{"id":"bd-buol","content_hash":"020dc9dbbd7f3e2b40c35f01bf8a65cf32ab419c188081493ea4e541bad1442e","title":"Invert control for compact: provide tools for agent-driven compaction","description":"Currently compact requires Anthropic API key because bd calls the AI directly. This is backwards - we should provide tools (like all other bd commands) that let an AI agent perform the compaction. The agent decides what to keep/merge, not bd. Related to GH #243 complaint about API key requirement.","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-11-07T00:27:28.498069-08:00","updated_at":"2025-11-08T01:49:23.46152-08:00","closed_at":"2025-11-07T23:08:51.67473-08:00","source_repo":"."}
{"id":"bd-by3x","content_hash":"80149be1ddf4ef26d5d56c444895be01ec8b59492c258c2365fa1c2619061bbd","title":"Windows binaries lack SQLite support (GH #253)","description":"Windows users installing via install.ps1 get \"sql: unknown driver sqlite\" error. Root cause: GoReleaser was building with CGO_ENABLED=0, which excludes SQLite driver.\n\nFixed by:\n1. Enabling CGO in .goreleaser.yml\n2. Installing MinGW cross-compiler in release workflow\n3. Splitting builds per platform to set correct CC for Windows\n\nNeeds new release to fix for users.","status":"closed","priority":0,"issue_type":"bug","created_at":"2025-11-07T15:54:13.134815-08:00","updated_at":"2025-11-07T15:55:07.024156-08:00","closed_at":"2025-11-07T15:55:07.024156-08:00","source_repo":"."}
{"id":"bd-bzfy","content_hash":"90bbde4d90d68728a9377d5d966682dc836740f1be43a0cf80d3cc69002a560b","title":"Integrate beads-merge tool by @neongreen","description":"**Context**: @neongreen built a production-ready 3-way merge tool for JSONL files that works with both Git and Jujutsu. This is superior to our planned bd resolve-conflicts because it prevents conflicts proactively instead of resolving them after the fact.\n\n**Tool**: https://github.com/neongreen/mono/tree/main/beads-merge\n\n**What it does**:\n- 3-way merge of JSONL files (base, left, right)\n- Field-level merging (titles, status, priority, etc.)\n- Smart dependency merging (union + dedup)\n- Conflict markers for unresolvable conflicts\n- Exit code 1 for conflicts (standard)\n\n**Integration options**:\n\n1. **Recommend (minimal effort)** - Document in AGENTS.md + TROUBLESHOOTING.md\n2. **Bundle binary** - Include in releases (cross-platform builds)\n3. **Port to Go** - Reimplement in bd codebase\n4. **Auto-install hook** - During bd init, offer to install merge driver\n\n**Recommendation**: Start with option 1 (document), then option 2 (bundle) once proven.\n\n**Related**: bd-5f483051 (bd resolve-conflicts - can close as superseded)","notes":"Created GitHub issue to discuss integration approach with @neongreen: https://github.com/neongreen/mono/issues/240\n\nAwaiting their preference on:\n1. Vendor with attribution (fastest)\n2. Extract as importable module (best long-term)\n3. Keep as separate tool (current state)\n\nNext: Wait for response before proceeding with integration.\n\nUPDATE 2025-11-06: @neongreen gave permission to vendor! Quote: \"I switched from beads to my own thing (tk) so I'm very happy to give beads-merge away — feel free to move it into the beads repo and I will point mono's readme to beads\"\n\nNext: Vendor beads-merge with full attribution","status":"closed","priority":1,"issue_type":"feature","created_at":"2025-11-05T11:31:44.906652-08:00","updated_at":"2025-11-06T18:19:16.233387-08:00","closed_at":"2025-11-06T15:38:37.052274-08:00","source_repo":"."}

View File

@@ -16,6 +16,11 @@ import (
// syncBranchCommitAndPush commits JSONL to the sync branch using a worktree
// Returns true if changes were committed, false if no changes or sync.branch not configured
func syncBranchCommitAndPush(ctx context.Context, store storage.Storage, autoPush bool, log daemonLogger) (bool, error) {
// Check if any remote exists (bd-biwp: support local-only repos)
if !hasGitRemote(ctx) {
return true, nil // Skip sync branch commit/push in local-only mode
}
// Get sync.branch config
syncBranch, err := store.GetConfig(ctx, "sync.branch")
if err != nil {
@@ -169,6 +174,11 @@ func gitPushFromWorktree(ctx context.Context, worktreePath, branch string) error
// syncBranchPull pulls changes from the sync branch into the worktree
// Returns true if pull was performed, false if sync.branch not configured
func syncBranchPull(ctx context.Context, store storage.Storage, log daemonLogger) (bool, error) {
// Check if any remote exists (bd-biwp: support local-only repos)
if !hasGitRemote(ctx) {
return true, nil // Skip sync branch pull in local-only mode
}
// Get sync.branch config
syncBranch, err := store.GetConfig(ctx, "sync.branch")
if err != nil {

View File

@@ -385,8 +385,24 @@ func gitCommit(ctx context.Context, filePath string, message string) error {
return nil
}
// hasGitRemote checks if a git remote exists in the repository
func hasGitRemote(ctx context.Context) bool {
cmd := exec.CommandContext(ctx, "git", "remote")
output, err := cmd.Output()
if err != nil {
return false
}
return len(strings.TrimSpace(string(output))) > 0
}
// gitPull pulls from the current branch's upstream
// Returns nil if no remote configured (local-only mode)
func gitPull(ctx context.Context) error {
// Check if any remote exists (bd-biwp: support local-only repos)
if !hasGitRemote(ctx) {
return nil // Gracefully skip - local-only mode
}
// Get current branch name
branchCmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD")
branchOutput, err := branchCmd.Output()
@@ -414,7 +430,13 @@ func gitPull(ctx context.Context) error {
}
// gitPush pushes to the current branch's upstream
// Returns nil if no remote configured (local-only mode)
func gitPush(ctx context.Context) error {
// Check if any remote exists (bd-biwp: support local-only repos)
if !hasGitRemote(ctx) {
return nil // Gracefully skip - local-only mode
}
cmd := exec.CommandContext(ctx, "git", "push")
output, err := cmd.CombinedOutput()
if err != nil {

View File

@@ -0,0 +1,133 @@
package main
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
)
// TestLocalOnlyMode tests that daemon works with local git repos (no remote)
func TestLocalOnlyMode(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Create temp directory for local-only repo
tempDir := t.TempDir()
// Initialize local git repo without remote
runGitCmd(t, tempDir, "init")
runGitCmd(t, tempDir, "config", "user.email", "test@example.com")
runGitCmd(t, tempDir, "config", "user.name", "Test User")
// Change to temp directory so git commands run in the test repo
oldDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working dir: %v", err)
}
defer os.Chdir(oldDir)
if err := os.Chdir(tempDir); err != nil {
t.Fatalf("Failed to change to temp dir: %v", err)
}
// Verify no remote exists
cmd := exec.Command("git", "remote")
output, err := cmd.Output()
if err != nil {
t.Fatalf("Failed to check git remotes: %v", err)
}
if len(output) > 0 {
t.Fatalf("Expected no remotes, got: %s", output)
}
ctx := context.Background()
// Test hasGitRemote returns false
if hasGitRemote(ctx) {
t.Error("Expected hasGitRemote to return false for local-only repo")
}
// Test gitPull returns nil (no error)
if err := gitPull(ctx); err != nil {
t.Errorf("gitPull should gracefully skip when no remote, got error: %v", err)
}
// Test gitPush returns nil (no error)
if err := gitPush(ctx); err != nil {
t.Errorf("gitPush should gracefully skip when no remote, got error: %v", err)
}
// Create a dummy JSONL file to commit
beadsDir := filepath.Join(tempDir, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("Failed to create .beads dir: %v", err)
}
jsonlPath := filepath.Join(beadsDir, "issues.jsonl")
if err := os.WriteFile(jsonlPath, []byte(`{"id":"test-1","title":"Test"}`+"\n"), 0644); err != nil {
t.Fatalf("Failed to write JSONL: %v", err)
}
// Test gitCommit works (local commits should work fine)
runGitCmd(t, tempDir, "add", ".beads")
if err := gitCommit(ctx, jsonlPath, "Test commit"); err != nil {
t.Errorf("gitCommit should work in local-only mode, got error: %v", err)
}
// Verify commit was created
cmd = exec.Command("git", "log", "--oneline")
output, err = cmd.Output()
if err != nil {
t.Fatalf("Failed to check git log: %v", err)
}
if len(output) == 0 {
t.Error("Expected at least one commit in git log")
}
}
// TestWithRemote verifies hasGitRemote detects remotes correctly
func TestWithRemote(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
// Create temp directories
tempDir := t.TempDir()
remoteDir := filepath.Join(tempDir, "remote")
cloneDir := filepath.Join(tempDir, "clone")
// Create bare remote
if err := os.MkdirAll(remoteDir, 0750); err != nil {
t.Fatalf("Failed to create remote dir: %v", err)
}
runGitCmd(t, remoteDir, "init", "--bare")
// Clone it
runGitCmd(t, tempDir, "clone", remoteDir, cloneDir)
// Change to clone directory
oldDir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working dir: %v", err)
}
defer os.Chdir(oldDir)
if err := os.Chdir(cloneDir); err != nil {
t.Fatalf("Failed to change to clone dir: %v", err)
}
ctx := context.Background()
// Test hasGitRemote returns true
if !hasGitRemote(ctx) {
t.Error("Expected hasGitRemote to return true when origin exists")
}
// Verify git pull doesn't error (even with empty remote)
// Note: pull might fail with "couldn't find remote ref", but that's different
// from the fatal "'origin' does not appear to be a git repository" error
gitPull(ctx) // Just verify it doesn't panic
}