fix(prime): use flush-only workflow when no git remote configured (#940)

Use flush-only workflow when no git remote is configured

Detects local-only repos (no git remote) and provides appropriate instructions:
- bd sync --flush-only instead of full git workflow
- Clear note about local-only storage
- Prevents confusing git errors for non-git users
This commit is contained in:
kustrun
2026-01-08 05:45:40 +01:00
committed by GitHub
parent bec44d85a1
commit 7aa3e79649
2 changed files with 100 additions and 6 deletions

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -13,6 +14,7 @@ import (
"github.com/steveyegge/beads" "github.com/steveyegge/beads"
"github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/syncbranch"
) )
// isDaemonAutoSyncing checks if daemon is running with auto-commit and auto-push enabled. // isDaemonAutoSyncing checks if daemon is running with auto-commit and auto-push enabled.
@@ -179,6 +181,11 @@ var isEphemeralBranch = func() bool {
return err != nil return err != nil
} }
// primeHasGitRemote detects if any git remote is configured (stubbable for tests)
var primeHasGitRemote = func() bool {
return syncbranch.HasGitRemote(context.Background())
}
// getRedirectNotice returns a notice string if beads is redirected // getRedirectNotice returns a notice string if beads is redirected
func getRedirectNotice(verbose bool) string { func getRedirectNotice(verbose bool) string {
redirectInfo := beads.GetRedirectInfo() redirectInfo := beads.GetRedirectInfo()
@@ -208,10 +215,11 @@ func outputMCPContext(w io.Writer, stealthMode bool) error {
ephemeral := isEphemeralBranch() ephemeral := isEphemeralBranch()
noPush := config.GetBool("no-push") noPush := config.GetBool("no-push")
autoSync := isDaemonAutoSyncing() autoSync := isDaemonAutoSyncing()
localOnly := !primeHasGitRemote()
var closeProtocol string var closeProtocol string
if stealthMode { if stealthMode || localOnly {
// Stealth mode: only flush to JSONL as there's nothing to commit. // Stealth mode or local-only: only flush to JSONL, no git operations
closeProtocol = "Before saying \"done\": bd sync --flush-only" closeProtocol = "Before saying \"done\": bd sync --flush-only"
} else if autoSync && !ephemeral && !noPush { } else if autoSync && !ephemeral && !noPush {
// Daemon is auto-syncing - no bd sync needed // Daemon is auto-syncing - no bd sync needed
@@ -248,6 +256,7 @@ func outputCLIContext(w io.Writer, stealthMode bool) error {
ephemeral := isEphemeralBranch() ephemeral := isEphemeralBranch()
noPush := config.GetBool("no-push") noPush := config.GetBool("no-push")
autoSync := isDaemonAutoSyncing() autoSync := isDaemonAutoSyncing()
localOnly := !primeHasGitRemote()
var closeProtocol string var closeProtocol string
var closeNote string var closeNote string
@@ -255,8 +264,8 @@ func outputCLIContext(w io.Writer, stealthMode bool) error {
var completingWorkflow string var completingWorkflow string
var gitWorkflowRule string var gitWorkflowRule string
if stealthMode { if stealthMode || localOnly {
// Stealth mode: only flush to JSONL, no git operations // Stealth mode or local-only: only flush to JSONL, no git operations
closeProtocol = `[ ] bd sync --flush-only (export beads to JSONL only)` closeProtocol = `[ ] bd sync --flush-only (export beads to JSONL only)`
syncSection = `### Sync & Collaboration syncSection = `### Sync & Collaboration
- ` + "`bd sync --flush-only`" + ` - Export to JSONL` - ` + "`bd sync --flush-only`" + ` - Export to JSONL`
@@ -265,7 +274,13 @@ func outputCLIContext(w io.Writer, stealthMode bool) error {
bd close <id1> <id2> ... # Close all completed issues at once bd close <id1> <id2> ... # Close all completed issues at once
bd sync --flush-only # Export to JSONL bd sync --flush-only # Export to JSONL
` + "```" ` + "```"
gitWorkflowRule = "Git workflow: stealth mode (no git ops)" // Only show local-only note if not in stealth mode (stealth is explicit user choice)
if localOnly && !stealthMode {
closeNote = "**Note:** No git remote configured. Issues are saved locally only."
gitWorkflowRule = "Git workflow: local-only (no git remote)"
} else {
gitWorkflowRule = "Git workflow: stealth mode (no git ops)"
}
} else if autoSync && !ephemeral && !noPush { } else if autoSync && !ephemeral && !noPush {
// Daemon is auto-syncing - simplified protocol (no bd sync needed) // Daemon is auto-syncing - simplified protocol (no bd sync needed)
closeProtocol = `[ ] 1. git status (check what changed) closeProtocol = `[ ] 1. git status (check what changed)

View File

@@ -12,6 +12,7 @@ func TestOutputContextFunction(t *testing.T) {
mcpMode bool mcpMode bool
stealthMode bool stealthMode bool
ephemeralMode bool ephemeralMode bool
localOnlyMode bool
expectText []string expectText []string
rejectText []string rejectText []string
}{ }{
@@ -20,6 +21,7 @@ func TestOutputContextFunction(t *testing.T) {
mcpMode: false, mcpMode: false,
stealthMode: false, stealthMode: false,
ephemeralMode: false, ephemeralMode: false,
localOnlyMode: false,
expectText: []string{"Beads Workflow Context", "bd sync", "git push"}, expectText: []string{"Beads Workflow Context", "bd sync", "git push"},
rejectText: []string{"bd sync --flush-only", "--from-main"}, rejectText: []string{"bd sync --flush-only", "--from-main"},
}, },
@@ -28,6 +30,7 @@ func TestOutputContextFunction(t *testing.T) {
mcpMode: false, mcpMode: false,
stealthMode: false, stealthMode: false,
ephemeralMode: true, ephemeralMode: true,
localOnlyMode: false,
expectText: []string{"Beads Workflow Context", "bd sync --from-main", "ephemeral branch"}, expectText: []string{"Beads Workflow Context", "bd sync --from-main", "ephemeral branch"},
rejectText: []string{"bd sync --flush-only", "git push"}, rejectText: []string{"bd sync --flush-only", "git push"},
}, },
@@ -36,14 +39,43 @@ func TestOutputContextFunction(t *testing.T) {
mcpMode: false, mcpMode: false,
stealthMode: true, stealthMode: true,
ephemeralMode: false, // stealth mode overrides ephemeral detection ephemeralMode: false, // stealth mode overrides ephemeral detection
localOnlyMode: false,
expectText: []string{"Beads Workflow Context", "bd sync --flush-only"}, expectText: []string{"Beads Workflow Context", "bd sync --flush-only"},
rejectText: []string{"git push", "git pull", "git commit", "git status", "git add"}, rejectText: []string{"git push", "git pull", "git commit", "git status", "git add"},
}, },
{
name: "CLI Local-only (no git remote)",
mcpMode: false,
stealthMode: false,
ephemeralMode: false,
localOnlyMode: true,
expectText: []string{"Beads Workflow Context", "bd sync --flush-only", "No git remote configured"},
rejectText: []string{"git push", "git pull", "--from-main"},
},
{
name: "CLI Local-only overrides ephemeral",
mcpMode: false,
stealthMode: false,
ephemeralMode: true, // ephemeral is true but local-only takes precedence
localOnlyMode: true,
expectText: []string{"Beads Workflow Context", "bd sync --flush-only", "No git remote configured"},
rejectText: []string{"git push", "--from-main", "ephemeral branch"},
},
{
name: "CLI Stealth overrides local-only",
mcpMode: false,
stealthMode: true,
ephemeralMode: false,
localOnlyMode: true, // local-only is true but stealth takes precedence
expectText: []string{"Beads Workflow Context", "bd sync --flush-only"},
rejectText: []string{"git push", "git pull", "git commit", "git status", "git add", "No git remote configured"},
},
{ {
name: "MCP Normal (non-ephemeral)", name: "MCP Normal (non-ephemeral)",
mcpMode: true, mcpMode: true,
stealthMode: false, stealthMode: false,
ephemeralMode: false, ephemeralMode: false,
localOnlyMode: false,
expectText: []string{"Beads Issue Tracker Active", "bd sync", "git push"}, expectText: []string{"Beads Issue Tracker Active", "bd sync", "git push"},
rejectText: []string{"bd sync --flush-only", "--from-main"}, rejectText: []string{"bd sync --flush-only", "--from-main"},
}, },
@@ -52,6 +84,7 @@ func TestOutputContextFunction(t *testing.T) {
mcpMode: true, mcpMode: true,
stealthMode: false, stealthMode: false,
ephemeralMode: true, ephemeralMode: true,
localOnlyMode: false,
expectText: []string{"Beads Issue Tracker Active", "bd sync --from-main", "ephemeral branch"}, expectText: []string{"Beads Issue Tracker Active", "bd sync --from-main", "ephemeral branch"},
rejectText: []string{"bd sync --flush-only", "git push"}, rejectText: []string{"bd sync --flush-only", "git push"},
}, },
@@ -60,6 +93,34 @@ func TestOutputContextFunction(t *testing.T) {
mcpMode: true, mcpMode: true,
stealthMode: true, stealthMode: true,
ephemeralMode: false, // stealth mode overrides ephemeral detection ephemeralMode: false, // stealth mode overrides ephemeral detection
localOnlyMode: false,
expectText: []string{"Beads Issue Tracker Active", "bd sync --flush-only"},
rejectText: []string{"git push", "git pull", "git commit", "git status", "git add"},
},
{
name: "MCP Local-only (no git remote)",
mcpMode: true,
stealthMode: false,
ephemeralMode: false,
localOnlyMode: true,
expectText: []string{"Beads Issue Tracker Active", "bd sync --flush-only"},
rejectText: []string{"git push", "git pull", "--from-main"},
},
{
name: "MCP Local-only overrides ephemeral",
mcpMode: true,
stealthMode: false,
ephemeralMode: true, // ephemeral is true but local-only takes precedence
localOnlyMode: true,
expectText: []string{"Beads Issue Tracker Active", "bd sync --flush-only"},
rejectText: []string{"git push", "--from-main", "ephemeral branch"},
},
{
name: "MCP Stealth overrides local-only",
mcpMode: true,
stealthMode: true,
ephemeralMode: false,
localOnlyMode: true, // local-only is true but stealth takes precedence
expectText: []string{"Beads Issue Tracker Active", "bd sync --flush-only"}, expectText: []string{"Beads Issue Tracker Active", "bd sync --flush-only"},
rejectText: []string{"git push", "git pull", "git commit", "git status", "git add"}, rejectText: []string{"git push", "git pull", "git commit", "git status", "git add"},
}, },
@@ -68,7 +129,8 @@ func TestOutputContextFunction(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
defer stubIsEphemeralBranch(tt.ephemeralMode)() defer stubIsEphemeralBranch(tt.ephemeralMode)()
defer stubIsDaemonAutoSyncing(false)() // Default: no auto-sync in tests defer stubIsDaemonAutoSyncing(false)() // Default: no auto-sync in tests
defer stubPrimeHasGitRemote(!tt.localOnlyMode)() // localOnly = !primeHasGitRemote
var buf bytes.Buffer var buf bytes.Buffer
err := outputPrimeContext(&buf, tt.mcpMode, tt.stealthMode) err := outputPrimeContext(&buf, tt.mcpMode, tt.stealthMode)
@@ -121,3 +183,20 @@ func stubIsDaemonAutoSyncing(isAutoSync bool) func() {
isDaemonAutoSyncing = original isDaemonAutoSyncing = original
} }
} }
// stubPrimeHasGitRemote temporarily replaces primeHasGitRemote
// with a stub returning returnValue.
//
// Returns a function to restore the original primeHasGitRemote.
// Usage:
//
// defer stubPrimeHasGitRemote(true)()
func stubPrimeHasGitRemote(hasRemote bool) func() {
original := primeHasGitRemote
primeHasGitRemote = func() bool {
return hasRemote
}
return func() {
primeHasGitRemote = original
}
}