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"
|
"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{
|
var syncCmd = &cobra.Command{
|
||||||
Use: "sync",
|
Use: "sync",
|
||||||
GroupID: "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
|
// GH#638: Check sync.branch BEFORE upstream check
|
||||||
// When sync.branch is configured, we should use worktree-based sync even if
|
// 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)
|
// the current branch has no upstream (e.g., detached HEAD in jj, git worktrees)
|
||||||
var syncBranchName, syncBranchRepoRoot string
|
sbc := getSyncBranchContext(ctx)
|
||||||
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 != ""
|
|
||||||
|
|
||||||
// GH#1166: Block sync if currently on the sync branch
|
// GH#1166: Block sync if currently on the sync branch
|
||||||
// This must happen BEFORE worktree operations - after entering a worktree,
|
// 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.
|
// the beads files are in a different git repo than the current working directory.
|
||||||
redirectInfo := beads.GetRedirectInfo()
|
redirectInfo := beads.GetRedirectInfo()
|
||||||
if redirectInfo.IsRedirected {
|
if redirectInfo.IsRedirected {
|
||||||
if hasSyncBranchConfig {
|
if sbc.IsConfigured() {
|
||||||
fmt.Printf("⚠️ Redirect active (-> %s), skipping sync-branch operations\n", redirectInfo.TargetDir)
|
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(" Hint: Redirected clones should not have sync-branch configured")
|
||||||
fmt.Println(" The owner of the target .beads directory handles sync-branch")
|
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
|
// Preflight: check for upstream tracking
|
||||||
// If no upstream, automatically switch to --from-main mode (gt-ick9: ephemeral branch support)
|
// 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
|
// GH#638: Skip this fallback if sync.branch is explicitly configured
|
||||||
if !noPull && !gitHasUpstream() && !hasSyncBranchConfig {
|
if !noPull && !gitHasUpstream() && !sbc.IsConfigured() {
|
||||||
if hasGitRemote(ctx) {
|
if hasGitRemote(ctx) {
|
||||||
// Remote exists but no upstream - use from-main mode
|
// Remote exists but no upstream - use from-main mode
|
||||||
fmt.Println("→ No upstream configured, using --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
|
// Pull-first sync: Pull → Merge → Export → Commit → Push
|
||||||
// This eliminates the export-before-pull data loss pattern (#911) by
|
// This eliminates the export-before-pull data loss pattern (#911) by
|
||||||
// seeing remote changes before exporting local state.
|
// 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)
|
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:
|
// When noPull is true, skips the pull/merge steps and just does:
|
||||||
// Export → Commit → Push
|
// 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)
|
beadsDir := filepath.Dir(jsonlPath)
|
||||||
_ = acceptRebase // Reserved for future sync branch force-push detection
|
_ = 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)
|
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})
|
localIssues, err := store.SearchIssues(ctx, "", beads.IssueFilter{IncludeTombstones: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("loading local issues: %w", err)
|
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)
|
// Git-based pull (for git-portable, belt-and-suspenders, or when Dolt not available)
|
||||||
if ShouldExportJSONL(ctx, store) {
|
if ShouldExportJSONL(ctx, store) {
|
||||||
if hasSyncBranchConfig {
|
if sbc.IsConfigured() {
|
||||||
fmt.Printf("→ Pulling from sync branch '%s'...\n", syncBranch)
|
fmt.Printf("→ Pulling from sync branch '%s'...\n", sbc.Branch)
|
||||||
pullResult, err := syncbranch.PullFromSyncBranch(ctx, syncBranchRepoRoot, syncBranch, jsonlPath, false)
|
pullResult, err := syncbranch.PullFromSyncBranch(ctx, sbc.RepoRoot, sbc.Branch, jsonlPath, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("pulling from sync branch: %w", err)
|
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)
|
return fmt.Errorf("exporting: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 8: Check for changes and commit
|
// Step 8 & 9: Commit and push changes
|
||||||
// Step 9: Push to remote
|
if err := commitAndPushBeads(ctx, sbc, jsonlPath, noPush, message); err != nil {
|
||||||
// When sync.branch is configured, use worktree-based commit/push to sync branch
|
return err
|
||||||
// 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 10: Update base state for next sync (after successful push)
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GH#1173: Detect sync-branch configuration and use appropriate commit method
|
||||||
|
sbc := getSyncBranchContext(ctx)
|
||||||
|
|
||||||
fmt.Println("→ Exporting pending changes to JSONL...")
|
fmt.Println("→ Exporting pending changes to JSONL...")
|
||||||
if err := exportToJSONL(ctx, jsonlPath); err != nil {
|
if err := exportToJSONL(ctx, jsonlPath); err != nil {
|
||||||
return fmt.Errorf("exporting: %w", err)
|
return fmt.Errorf("exporting: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for changes and commit
|
// Commit and push using the appropriate method (sync-branch worktree or regular git)
|
||||||
hasChanges, err := gitHasBeadsChanges(ctx)
|
if err := commitAndPushBeads(ctx, sbc, jsonlPath, noPush, message); err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear sync state on successful sync
|
// 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)
|
// Case 1: Already up to date (remote has nothing new)
|
||||||
if remoteAhead == 0 {
|
if remoteAhead == 0 {
|
||||||
result.Pulled = true
|
result.Pulled = true
|
||||||
// Still copy JSONL in case worktree has uncommitted changes
|
// GH#1173: Do NOT copy uncommitted worktree changes to main repo.
|
||||||
if err := copyJSONLToMainRepo(worktreePath, jsonlRelPath, jsonlPath); err != nil {
|
// 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 nil, err
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -655,6 +659,35 @@ func extractJSONLFromCommit(ctx context.Context, worktreePath, commit, filePath
|
|||||||
return output, nil
|
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.
|
// copyJSONLToMainRepo copies JSONL and related files from worktree to main repo.
|
||||||
func copyJSONLToMainRepo(worktreePath, jsonlRelPath, jsonlPath string) error {
|
func copyJSONLToMainRepo(worktreePath, jsonlRelPath, jsonlPath string) error {
|
||||||
// GH#785: Handle bare repo worktrees where jsonlRelPath might include the
|
// GH#785: Handle bare repo worktrees where jsonlRelPath might include the
|
||||||
|
|||||||
Reference in New Issue
Block a user