fix(sync): use sync-branch worktree for --full --no-pull (#1183)
* fix(sync): use sync-branch worktree for --full --no-pull (#1173) Bug 1: PullFromSyncBranch was copying uncommitted worktree changes to main repo when remoteAhead==0. This corrupted the 3-way merge because local changes appeared as remote changes. Fixed by copying only the committed state from HEAD instead of the working directory. Bug 2: doExportOnlySync was checking main repo for changes via gitHasBeadsChanges, but when sync-branch is configured, changes go to the worktree, not main. Fixed by detecting sync-branch config and using CommitToSyncBranch which operates on the worktree. Fixes #1173 * refactor(sync): consolidate sync-branch detection and commit/push logic Extract repeated patterns into reusable helpers: - SyncBranchContext struct: holds branch name and repo root - getSyncBranchContext(): detects sync-branch config from store - commitAndPushBeads(): handles both sync-branch and regular git workflows This eliminates duplicated sync-branch detection code (was in 3 places) and the duplicated commit/push conditional logic (was in 2 places). Net reduction of ~20 lines while improving maintainability. * fix: remove unused bool return from commitAndPushBeads
This commit is contained in:
173
cmd/bd/sync.go
173
cmd/bd/sync.go
@@ -18,6 +18,81 @@ import (
|
||||
"github.com/steveyegge/beads/internal/syncbranch"
|
||||
)
|
||||
|
||||
// SyncBranchContext holds sync-branch configuration detected from the store.
|
||||
// This consolidates the repeated pattern of checking for sync-branch config.
|
||||
type SyncBranchContext struct {
|
||||
Branch string // Sync branch name, empty if not configured
|
||||
RepoRoot string // Git repository root path
|
||||
}
|
||||
|
||||
// IsConfigured returns true if a sync branch is configured.
|
||||
func (s *SyncBranchContext) IsConfigured() bool {
|
||||
return s.Branch != ""
|
||||
}
|
||||
|
||||
// getSyncBranchContext detects sync-branch configuration from the store.
|
||||
// Returns a context with empty Branch if not configured or on error.
|
||||
func getSyncBranchContext(ctx context.Context) *SyncBranchContext {
|
||||
sbc := &SyncBranchContext{}
|
||||
if err := ensureStoreActive(); err != nil || store == nil {
|
||||
return sbc
|
||||
}
|
||||
if sb, _ := syncbranch.Get(ctx, store); sb != "" {
|
||||
sbc.Branch = sb
|
||||
if rc, err := beads.GetRepoContext(); err == nil {
|
||||
sbc.RepoRoot = rc.RepoRoot
|
||||
}
|
||||
}
|
||||
return sbc
|
||||
}
|
||||
|
||||
// commitAndPushBeads commits and pushes .beads changes using the appropriate method.
|
||||
// When sync-branch is configured, uses worktree-based commit/push.
|
||||
// Otherwise, uses standard git commit/push on the current branch.
|
||||
func commitAndPushBeads(ctx context.Context, sbc *SyncBranchContext, jsonlPath string, noPush bool, message string) error {
|
||||
if sbc.IsConfigured() {
|
||||
fmt.Printf("→ Committing to sync branch '%s'...\n", sbc.Branch)
|
||||
commitResult, err := syncbranch.CommitToSyncBranch(ctx, sbc.RepoRoot, sbc.Branch, jsonlPath, !noPush)
|
||||
if err != nil {
|
||||
return fmt.Errorf("committing to sync branch: %w", err)
|
||||
}
|
||||
if commitResult.Committed {
|
||||
fmt.Printf(" Committed: %s\n", commitResult.Message)
|
||||
if commitResult.Pushed {
|
||||
fmt.Println(" Pushed to remote")
|
||||
}
|
||||
} else {
|
||||
fmt.Println("→ No changes to commit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Standard git workflow
|
||||
hasChanges, err := gitHasBeadsChanges(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking git status: %w", err)
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
fmt.Println("→ Committing changes...")
|
||||
if err := gitCommitBeadsDir(ctx, message); err != nil {
|
||||
return fmt.Errorf("committing: %w", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("→ No changes to commit")
|
||||
}
|
||||
|
||||
// Push to remote
|
||||
if !noPush && hasChanges {
|
||||
fmt.Println("→ Pushing to remote...")
|
||||
if err := gitPush(ctx, ""); err != nil {
|
||||
return fmt.Errorf("pushing: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var syncCmd = &cobra.Command{
|
||||
Use: "sync",
|
||||
GroupID: "sync",
|
||||
@@ -264,16 +339,7 @@ The --full flag provides the legacy full sync behavior for backwards compatibili
|
||||
// GH#638: Check sync.branch BEFORE upstream check
|
||||
// When sync.branch is configured, we should use worktree-based sync even if
|
||||
// the current branch has no upstream (e.g., detached HEAD in jj, git worktrees)
|
||||
var syncBranchName, syncBranchRepoRoot string
|
||||
if err := ensureStoreActive(); err == nil && store != nil {
|
||||
if sb, _ := syncbranch.Get(ctx, store); sb != "" {
|
||||
syncBranchName = sb
|
||||
if rc, err := beads.GetRepoContext(); err == nil {
|
||||
syncBranchRepoRoot = rc.RepoRoot
|
||||
}
|
||||
}
|
||||
}
|
||||
hasSyncBranchConfig := syncBranchName != ""
|
||||
sbc := getSyncBranchContext(ctx)
|
||||
|
||||
// GH#1166: Block sync if currently on the sync branch
|
||||
// This must happen BEFORE worktree operations - after entering a worktree,
|
||||
@@ -294,7 +360,7 @@ The --full flag provides the legacy full sync behavior for backwards compatibili
|
||||
// the beads files are in a different git repo than the current working directory.
|
||||
redirectInfo := beads.GetRedirectInfo()
|
||||
if redirectInfo.IsRedirected {
|
||||
if hasSyncBranchConfig {
|
||||
if sbc.IsConfigured() {
|
||||
fmt.Printf("⚠️ Redirect active (-> %s), skipping sync-branch operations\n", redirectInfo.TargetDir)
|
||||
fmt.Println(" Hint: Redirected clones should not have sync-branch configured")
|
||||
fmt.Println(" The owner of the target .beads directory handles sync-branch")
|
||||
@@ -319,7 +385,7 @@ The --full flag provides the legacy full sync behavior for backwards compatibili
|
||||
// Preflight: check for upstream tracking
|
||||
// If no upstream, automatically switch to --from-main mode (gt-ick9: ephemeral branch support)
|
||||
// GH#638: Skip this fallback if sync.branch is explicitly configured
|
||||
if !noPull && !gitHasUpstream() && !hasSyncBranchConfig {
|
||||
if !noPull && !gitHasUpstream() && !sbc.IsConfigured() {
|
||||
if hasGitRemote(ctx) {
|
||||
// Remote exists but no upstream - use from-main mode
|
||||
fmt.Println("→ No upstream configured, using --from-main mode")
|
||||
@@ -335,7 +401,7 @@ The --full flag provides the legacy full sync behavior for backwards compatibili
|
||||
// Pull-first sync: Pull → Merge → Export → Commit → Push
|
||||
// This eliminates the export-before-pull data loss pattern (#911) by
|
||||
// seeing remote changes before exporting local state.
|
||||
if err := doPullFirstSync(ctx, jsonlPath, renameOnImport, noGitHistory, dryRun, noPush, noPull, message, acceptRebase, syncBranchName, syncBranchRepoRoot); err != nil {
|
||||
if err := doPullFirstSync(ctx, jsonlPath, renameOnImport, noGitHistory, dryRun, noPush, noPull, message, acceptRebase, sbc); err != nil {
|
||||
FatalError("%v", err)
|
||||
}
|
||||
},
|
||||
@@ -354,7 +420,7 @@ The --full flag provides the legacy full sync behavior for backwards compatibili
|
||||
//
|
||||
// When noPull is true, skips the pull/merge steps and just does:
|
||||
// Export → Commit → Push
|
||||
func doPullFirstSync(ctx context.Context, jsonlPath string, renameOnImport, noGitHistory, dryRun, noPush, noPull bool, message string, acceptRebase bool, syncBranch, syncBranchRepoRoot string) error {
|
||||
func doPullFirstSync(ctx context.Context, jsonlPath string, renameOnImport, noGitHistory, dryRun, noPush, noPull bool, message string, acceptRebase bool, sbc *SyncBranchContext) error {
|
||||
beadsDir := filepath.Dir(jsonlPath)
|
||||
_ = acceptRebase // Reserved for future sync branch force-push detection
|
||||
|
||||
@@ -388,9 +454,6 @@ func doPullFirstSync(ctx context.Context, jsonlPath string, renameOnImport, noGi
|
||||
return fmt.Errorf("activating store: %w", err)
|
||||
}
|
||||
|
||||
// Derive sync-branch config from parameters (detected at caller)
|
||||
hasSyncBranchConfig := syncBranch != ""
|
||||
|
||||
localIssues, err := store.SearchIssues(ctx, "", beads.IssueFilter{IncludeTombstones: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading local issues: %w", err)
|
||||
@@ -452,9 +515,9 @@ func doPullFirstSync(ctx context.Context, jsonlPath string, renameOnImport, noGi
|
||||
|
||||
// Git-based pull (for git-portable, belt-and-suspenders, or when Dolt not available)
|
||||
if ShouldExportJSONL(ctx, store) {
|
||||
if hasSyncBranchConfig {
|
||||
fmt.Printf("→ Pulling from sync branch '%s'...\n", syncBranch)
|
||||
pullResult, err := syncbranch.PullFromSyncBranch(ctx, syncBranchRepoRoot, syncBranch, jsonlPath, false)
|
||||
if sbc.IsConfigured() {
|
||||
fmt.Printf("→ Pulling from sync branch '%s'...\n", sbc.Branch)
|
||||
pullResult, err := syncbranch.PullFromSyncBranch(ctx, sbc.RepoRoot, sbc.Branch, jsonlPath, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pulling from sync branch: %w", err)
|
||||
}
|
||||
@@ -527,46 +590,9 @@ func doPullFirstSync(ctx context.Context, jsonlPath string, renameOnImport, noGi
|
||||
return fmt.Errorf("exporting: %w", err)
|
||||
}
|
||||
|
||||
// Step 8: Check for changes and commit
|
||||
// Step 9: Push to remote
|
||||
// When sync.branch is configured, use worktree-based commit/push to sync branch
|
||||
// Otherwise, use normal git commit/push on the current branch
|
||||
if hasSyncBranchConfig {
|
||||
fmt.Printf("→ Committing to sync branch '%s'...\n", syncBranch)
|
||||
commitResult, err := syncbranch.CommitToSyncBranch(ctx, syncBranchRepoRoot, syncBranch, jsonlPath, !noPush)
|
||||
if err != nil {
|
||||
return fmt.Errorf("committing to sync branch: %w", err)
|
||||
}
|
||||
if commitResult.Committed {
|
||||
fmt.Printf(" Committed: %s\n", commitResult.Message)
|
||||
if commitResult.Pushed {
|
||||
fmt.Println(" Pushed to remote")
|
||||
}
|
||||
} else {
|
||||
fmt.Println("→ No changes to commit")
|
||||
}
|
||||
} else {
|
||||
hasChanges, err := gitHasBeadsChanges(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking git status: %w", err)
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
fmt.Println("→ Committing changes...")
|
||||
if err := gitCommitBeadsDir(ctx, message); err != nil {
|
||||
return fmt.Errorf("committing: %w", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("→ No changes to commit")
|
||||
}
|
||||
|
||||
// Push to remote
|
||||
if !noPush && hasChanges {
|
||||
fmt.Println("→ Pushing to remote...")
|
||||
if err := gitPush(ctx, ""); err != nil {
|
||||
return fmt.Errorf("pushing: %w", err)
|
||||
}
|
||||
}
|
||||
// Step 8 & 9: Commit and push changes
|
||||
if err := commitAndPushBeads(ctx, sbc, jsonlPath, noPush, message); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 10: Update base state for next sync (after successful push)
|
||||
@@ -627,32 +653,17 @@ func doExportOnlySync(ctx context.Context, jsonlPath string, noPush bool, messag
|
||||
return err
|
||||
}
|
||||
|
||||
// GH#1173: Detect sync-branch configuration and use appropriate commit method
|
||||
sbc := getSyncBranchContext(ctx)
|
||||
|
||||
fmt.Println("→ Exporting pending changes to JSONL...")
|
||||
if err := exportToJSONL(ctx, jsonlPath); err != nil {
|
||||
return fmt.Errorf("exporting: %w", err)
|
||||
}
|
||||
|
||||
// Check for changes and commit
|
||||
hasChanges, err := gitHasBeadsChanges(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking git status: %w", err)
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
fmt.Println("→ Committing changes...")
|
||||
if err := gitCommitBeadsDir(ctx, message); err != nil {
|
||||
return fmt.Errorf("committing: %w", err)
|
||||
}
|
||||
} else {
|
||||
fmt.Println("→ No changes to commit")
|
||||
}
|
||||
|
||||
// Push to remote
|
||||
if !noPush && hasChanges {
|
||||
fmt.Println("→ Pushing to remote...")
|
||||
if err := gitPush(ctx, ""); err != nil {
|
||||
return fmt.Errorf("pushing: %w", err)
|
||||
}
|
||||
// Commit and push using the appropriate method (sync-branch worktree or regular git)
|
||||
if err := commitAndPushBeads(ctx, sbc, jsonlPath, noPush, message); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear sync state on successful sync
|
||||
|
||||
@@ -272,8 +272,12 @@ func PullFromSyncBranch(ctx context.Context, repoRoot, syncBranch, jsonlPath str
|
||||
// Case 1: Already up to date (remote has nothing new)
|
||||
if remoteAhead == 0 {
|
||||
result.Pulled = true
|
||||
// Still copy JSONL in case worktree has uncommitted changes
|
||||
if err := copyJSONLToMainRepo(worktreePath, jsonlRelPath, jsonlPath); err != nil {
|
||||
// GH#1173: Do NOT copy uncommitted worktree changes to main repo.
|
||||
// The worktree may have uncommitted changes from previous exports that
|
||||
// haven't been committed yet. Copying those to main would make local
|
||||
// data appear as "remote" data, corrupting the 3-way merge.
|
||||
// Instead, copy only the COMMITTED state from the worktree.
|
||||
if err := copyCommittedJSONLToMainRepo(ctx, worktreePath, jsonlRelPath, jsonlPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
@@ -655,6 +659,35 @@ func extractJSONLFromCommit(ctx context.Context, worktreePath, commit, filePath
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// copyCommittedJSONLToMainRepo copies the COMMITTED JSONL from worktree to main repo.
|
||||
// GH#1173: This extracts the file from HEAD rather than the working directory,
|
||||
// ensuring uncommitted local changes don't corrupt the 3-way merge.
|
||||
func copyCommittedJSONLToMainRepo(ctx context.Context, worktreePath, jsonlRelPath, jsonlPath string) error {
|
||||
// GH#785: Handle bare repo worktrees
|
||||
normalizedRelPath := normalizeBeadsRelPath(jsonlRelPath)
|
||||
|
||||
// Extract the committed JSONL from HEAD
|
||||
data, err := extractJSONLFromCommit(ctx, worktreePath, "HEAD", normalizedRelPath)
|
||||
if err != nil {
|
||||
// File might not exist in HEAD yet (first sync), nothing to copy
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.WriteFile(jsonlPath, data, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write main JSONL: %w", err)
|
||||
}
|
||||
|
||||
// Also copy committed metadata.json if it exists
|
||||
beadsDir := filepath.Dir(jsonlPath)
|
||||
metadataRelPath := filepath.Join(filepath.Dir(normalizedRelPath), "metadata.json")
|
||||
if metaData, err := extractJSONLFromCommit(ctx, worktreePath, "HEAD", metadataRelPath); err == nil {
|
||||
dstPath := filepath.Join(beadsDir, "metadata.json")
|
||||
_ = os.WriteFile(dstPath, metaData, 0600) // Best effort
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyJSONLToMainRepo copies JSONL and related files from worktree to main repo.
|
||||
func copyJSONLToMainRepo(worktreePath, jsonlRelPath, jsonlPath string) error {
|
||||
// GH#785: Handle bare repo worktrees where jsonlRelPath might include the
|
||||
|
||||
Reference in New Issue
Block a user