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:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
133
cmd/bd/sync_local_only_test.go
Normal file
133
cmd/bd/sync_local_only_test.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user