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:
Charles P. Cross
2025-12-22 17:17:26 -05:00
committed by GitHub
parent 737e65afbd
commit ee016bbb25
2 changed files with 143 additions and 1 deletions

View File

@@ -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)
}