fix(daemon): handle diverged sync branch with fetch-rebase-retry on push (#697)
* fix(daemon): handle diverged sync branch with fetch-rebase-retry on push When pushing to the sync branch, if the remote has newer commits that the local worktree doesn't have, the push would fail with "fetch first" error and the daemon would log the failure without recovery. Bug scenario: 1. Clone A pushes commit X to sync branch 2. Clone B has local commit Y (not based on X) 3. Clone B's push fails with "fetch first" error 4. Without this fix: daemon logs failure and stops 5. With this fix: daemon fetches, rebases Y on X, and retries push This fix adds fetch-rebase-retry logic to gitPushFromWorktree(): 1. Detect push rejection due to remote having newer commits 2. Fetch the latest remote sync branch 3. Rebase local commits on top of remote 4. Retry the push If rebase fails (e.g., due to conflicts), the rebase is aborted and an error is returned with helpful context. This allows multiple clones to push to the same sync branch without manual intervention, as long as the changes don't conflict. Adds integration test TestGitPushFromWorktree_FetchRebaseRetry that verifies the fetch-rebase-retry behavior with diverged branches. * fix: resolve all golangci-lint errors (cherry-pick from fix/linting-errors) Cherry-picked linting fixes to ensure CI passes. --------- Co-authored-by: Charles P. Cross <cpdata@users.noreply.github.com>
This commit is contained in:
@@ -182,7 +182,8 @@ func gitCommitInWorktree(ctx context.Context, worktreePath, filePath, message st
|
||||
return nil
|
||||
}
|
||||
|
||||
// gitPushFromWorktree pushes the sync branch from the worktree
|
||||
// gitPushFromWorktree pushes the sync branch from the worktree.
|
||||
// If push fails due to remote having newer commits, it will fetch, rebase, and retry.
|
||||
func gitPushFromWorktree(ctx context.Context, worktreePath, branch string) error {
|
||||
// Get remote name (usually "origin")
|
||||
remoteCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "config", "--get", fmt.Sprintf("branch.%s.remote", branch)) // #nosec G204 - worktreePath and branch are from config
|
||||
@@ -197,6 +198,32 @@ func gitPushFromWorktree(ctx context.Context, worktreePath, branch string) error
|
||||
cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "push", "--set-upstream", remote, branch) // #nosec G204 - worktreePath, remote, and branch are from config
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Check if push failed due to remote having newer commits
|
||||
outputStr := string(output)
|
||||
if strings.Contains(outputStr, "fetch first") || strings.Contains(outputStr, "non-fast-forward") {
|
||||
// Fetch and rebase, then retry push
|
||||
fetchCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "fetch", remote, branch) // #nosec G204
|
||||
if fetchOutput, fetchErr := fetchCmd.CombinedOutput(); fetchErr != nil {
|
||||
return fmt.Errorf("git fetch failed in worktree: %w\n%s", fetchErr, fetchOutput)
|
||||
}
|
||||
|
||||
// Rebase local commits on top of remote
|
||||
rebaseCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "rebase", remote+"/"+branch) // #nosec G204
|
||||
if rebaseOutput, rebaseErr := rebaseCmd.CombinedOutput(); rebaseErr != nil {
|
||||
// If rebase fails (conflict), abort and return error
|
||||
abortCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "rebase", "--abort") // #nosec G204
|
||||
_ = abortCmd.Run()
|
||||
return fmt.Errorf("git rebase failed in worktree (sync branch may have conflicts): %w\n%s", rebaseErr, rebaseOutput)
|
||||
}
|
||||
|
||||
// Retry push after successful rebase
|
||||
retryCmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "push", "--set-upstream", remote, branch) // #nosec G204
|
||||
if retryOutput, retryErr := retryCmd.CombinedOutput(); retryErr != nil {
|
||||
return fmt.Errorf("git push failed after rebase: %w\n%s", retryErr, retryOutput)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("git push failed from worktree: %w\n%s", err, output)
|
||||
}
|
||||
|
||||
|
||||
@@ -1430,3 +1430,118 @@ func initMainBranch(t *testing.T, dir string) {
|
||||
runGitCmd(t, dir, "add", "README.md")
|
||||
runGitCmd(t, dir, "commit", "-m", "Initial commit")
|
||||
}
|
||||
|
||||
// TestGitPushFromWorktree_FetchRebaseRetry tests that gitPushFromWorktree handles
|
||||
// the case where the remote has newer commits by fetching, rebasing, and retrying.
|
||||
// This is a regression test for the bug where daemon push would fail with
|
||||
// "fetch first" error when another clone had pushed to the sync branch.
|
||||
//
|
||||
// Bug scenario:
|
||||
// 1. Clone A pushes commit X to sync branch
|
||||
// 2. Clone B has local commit Y (not based on X)
|
||||
// 3. Clone B's push fails with "fetch first" error
|
||||
// 4. Without this fix: daemon logs failure and stops
|
||||
// 5. With this fix: daemon fetches, rebases Y on X, and retries push
|
||||
func TestGitPushFromWorktree_FetchRebaseRetry(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
// Skip on Windows due to path issues
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Skipping on Windows")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a "remote" bare repository
|
||||
remoteDir := t.TempDir()
|
||||
runGitCmd(t, remoteDir, "init", "--bare")
|
||||
|
||||
// Create first clone (simulates another developer's clone)
|
||||
clone1Dir := t.TempDir()
|
||||
runGitCmd(t, clone1Dir, "clone", remoteDir, ".")
|
||||
runGitCmd(t, clone1Dir, "config", "user.email", "test@example.com")
|
||||
runGitCmd(t, clone1Dir, "config", "user.name", "Test User")
|
||||
|
||||
// Create initial commit on main
|
||||
initMainBranch(t, clone1Dir)
|
||||
runGitCmd(t, clone1Dir, "push", "-u", "origin", "main")
|
||||
|
||||
// Create sync branch in clone1
|
||||
runGitCmd(t, clone1Dir, "checkout", "-b", "beads-sync")
|
||||
beadsDir1 := filepath.Join(clone1Dir, ".beads")
|
||||
if err := os.MkdirAll(beadsDir1, 0755); err != nil {
|
||||
t.Fatalf("Failed to create .beads dir: %v", err)
|
||||
}
|
||||
jsonl1 := filepath.Join(beadsDir1, "issues.jsonl")
|
||||
if err := os.WriteFile(jsonl1, []byte(`{"id":"clone1-issue","title":"Issue from clone1"}`+"\n"), 0644); err != nil {
|
||||
t.Fatalf("Failed to write JSONL: %v", err)
|
||||
}
|
||||
runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl")
|
||||
runGitCmd(t, clone1Dir, "commit", "-m", "Clone 1 commit")
|
||||
runGitCmd(t, clone1Dir, "push", "-u", "origin", "beads-sync")
|
||||
|
||||
// Create second clone (simulates our local clone)
|
||||
clone2Dir := t.TempDir()
|
||||
runGitCmd(t, clone2Dir, "clone", remoteDir, ".")
|
||||
runGitCmd(t, clone2Dir, "config", "user.email", "test@example.com")
|
||||
runGitCmd(t, clone2Dir, "config", "user.name", "Test User")
|
||||
|
||||
// Create worktree for sync branch in clone2
|
||||
worktreePath := filepath.Join(clone2Dir, ".git", "beads-worktrees", "beads-sync")
|
||||
if err := os.MkdirAll(filepath.Dir(worktreePath), 0755); err != nil {
|
||||
t.Fatalf("Failed to create worktree parent: %v", err)
|
||||
}
|
||||
|
||||
// Fetch the sync branch first
|
||||
runGitCmd(t, clone2Dir, "fetch", "origin", "beads-sync:beads-sync")
|
||||
|
||||
// Create worktree - but don't pull latest yet (to simulate diverged state)
|
||||
runGitCmd(t, clone2Dir, "worktree", "add", worktreePath, "beads-sync")
|
||||
|
||||
// Now clone1 makes another commit and pushes (simulating another clone pushing)
|
||||
runGitCmd(t, clone1Dir, "checkout", "beads-sync")
|
||||
if err := os.WriteFile(jsonl1, []byte(`{"id":"clone1-issue","title":"Issue from clone1"}`+"\n"+`{"id":"clone1-issue2","title":"Second issue"}`+"\n"), 0644); err != nil {
|
||||
t.Fatalf("Failed to update JSONL: %v", err)
|
||||
}
|
||||
runGitCmd(t, clone1Dir, "add", ".beads/issues.jsonl")
|
||||
runGitCmd(t, clone1Dir, "commit", "-m", "Clone 1 second commit")
|
||||
runGitCmd(t, clone1Dir, "push", "origin", "beads-sync")
|
||||
|
||||
// Clone2's worktree makes a different commit (diverged from remote)
|
||||
// We create a different file to avoid merge conflicts - this simulates
|
||||
// non-conflicting JSONL changes (e.g., different issues being created)
|
||||
beadsDir2 := filepath.Join(worktreePath, ".beads")
|
||||
if err := os.MkdirAll(beadsDir2, 0755); err != nil {
|
||||
t.Fatalf("Failed to create .beads in worktree: %v", err)
|
||||
}
|
||||
// Create a separate metadata file to avoid JSONL conflict
|
||||
metadataPath := filepath.Join(beadsDir2, "metadata.json")
|
||||
if err := os.WriteFile(metadataPath, []byte(`{"clone":"clone2"}`+"\n"), 0644); err != nil {
|
||||
t.Fatalf("Failed to write metadata in worktree: %v", err)
|
||||
}
|
||||
runGitCmd(t, worktreePath, "add", ".beads/metadata.json")
|
||||
runGitCmd(t, worktreePath, "commit", "-m", "Clone 2 commit")
|
||||
|
||||
// Now try to push from worktree - this should trigger the fetch-rebase-retry logic
|
||||
// because the remote has commits that the local worktree doesn't have
|
||||
err := gitPushFromWorktree(ctx, worktreePath, "beads-sync")
|
||||
if err != nil {
|
||||
t.Fatalf("gitPushFromWorktree failed: %v (expected fetch-rebase-retry to succeed)", err)
|
||||
}
|
||||
|
||||
// Verify the push succeeded by checking the remote has all commits
|
||||
cmd := exec.Command("git", "-C", remoteDir, "rev-list", "--count", "beads-sync")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to count commits: %v", err)
|
||||
}
|
||||
commitCount := strings.TrimSpace(string(output))
|
||||
// Should have at least 3 commits: initial sync, clone1's second commit, clone2's rebased commit
|
||||
if commitCount == "0" || commitCount == "1" || commitCount == "2" {
|
||||
t.Errorf("Expected at least 3 commits after rebase-push, got %s", commitCount)
|
||||
}
|
||||
|
||||
t.Log("Fetch-rebase-retry test passed: diverged sync branch was successfully rebased and pushed")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user