feat(push): handle diverged branches with pull-and-retry

When push fails due to non-fast-forward rejection (local and remote
have diverged), offer to pull with rebase and retry the push instead
of just failing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-30 07:50:33 +00:00
parent dcb0871b61
commit 58596ea00e
3 changed files with 53 additions and 1 deletions

View file

@ -133,16 +133,48 @@ func runPush(registryPath string, force bool) error {
results := git.PushMultiple(ctx, pushPaths, names) results := git.PushMultiple(ctx, pushPaths, names)
var succeeded, failed int var succeeded, failed int
var divergedRepos []git.PushResult
for _, r := range results { for _, r := range results {
if r.Success { if r.Success {
fmt.Printf(" %s %s\n", successStyle.Render("v"), r.Name) fmt.Printf(" %s %s\n", successStyle.Render("v"), r.Name)
succeeded++ succeeded++
} else { } else {
fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), r.Name, r.Error) // Check if this is a non-fast-forward error (diverged branch)
if git.IsNonFastForward(r.Error) {
fmt.Printf(" %s %s: %s\n", warningStyle.Render("!"), r.Name, i18n.T("cmd.dev.push.diverged"))
divergedRepos = append(divergedRepos, r)
} else {
fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), r.Name, r.Error)
}
failed++ failed++
} }
} }
// Handle diverged repos - offer to pull and retry
if len(divergedRepos) > 0 {
fmt.Println()
fmt.Printf("%s\n", i18n.T("cmd.dev.push.diverged_help"))
if shared.Confirm(i18n.T("cmd.dev.push.pull_and_retry")) {
fmt.Println()
for _, r := range divergedRepos {
fmt.Printf(" %s %s...\n", dimStyle.Render("↓"), r.Name)
if err := git.Pull(ctx, r.Path); err != nil {
fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), r.Name, err)
continue
}
fmt.Printf(" %s %s...\n", dimStyle.Render("↑"), r.Name)
if err := git.Push(ctx, r.Path); err != nil {
fmt.Printf(" %s %s: %s\n", errorStyle.Render("x"), r.Name, err)
continue
}
fmt.Printf(" %s %s\n", successStyle.Render("v"), r.Name)
succeeded++
failed--
}
}
}
// Summary // Summary
fmt.Println() fmt.Println()
fmt.Printf("%s", successStyle.Render(i18n.T("cmd.dev.push.done_pushed", map[string]interface{}{"Count": succeeded}))) fmt.Printf("%s", successStyle.Render(i18n.T("cmd.dev.push.done_pushed", map[string]interface{}{"Count": succeeded})))

View file

@ -146,6 +146,23 @@ func Push(ctx context.Context, path string) error {
return gitInteractive(ctx, path, "push") return gitInteractive(ctx, path, "push")
} }
// Pull pulls changes for a single repository.
// Uses interactive mode to support SSH passphrase prompts.
func Pull(ctx context.Context, path string) error {
return gitInteractive(ctx, path, "pull", "--rebase")
}
// IsNonFastForward checks if an error is a non-fast-forward rejection.
func IsNonFastForward(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "non-fast-forward") ||
strings.Contains(msg, "fetch first") ||
strings.Contains(msg, "tip of your current branch is behind")
}
// gitInteractive runs a git command with terminal attached for user interaction. // gitInteractive runs a git command with terminal attached for user interaction.
func gitInteractive(ctx context.Context, dir string, args ...string) error { func gitInteractive(ctx context.Context, dir string, args ...string) error {
cmd := exec.CommandContext(ctx, "git", args...) cmd := exec.CommandContext(ctx, "git", args...)

View file

@ -187,6 +187,9 @@
"cmd.dev.push.commits_count": "{{.Count}} commit(s)", "cmd.dev.push.commits_count": "{{.Count}} commit(s)",
"cmd.dev.push.confirm_push": "Push {{.Commits}} commit(s) to {{.Repos}} repo(s)?", "cmd.dev.push.confirm_push": "Push {{.Commits}} commit(s) to {{.Repos}} repo(s)?",
"cmd.dev.push.done_pushed": "Done: {{.Count}} pushed", "cmd.dev.push.done_pushed": "Done: {{.Count}} pushed",
"cmd.dev.push.diverged": "branch has diverged from remote",
"cmd.dev.push.diverged_help": "Some repos have diverged (local and remote have different commits).",
"cmd.dev.push.pull_and_retry": "Pull changes and retry push?",
"cmd.dev.pull.all_up_to_date": "All repos up to date. Nothing to pull.", "cmd.dev.pull.all_up_to_date": "All repos up to date. Nothing to pull.",
"cmd.dev.pull.pulling_repos": "Pulling {{.Count}} repo(s):", "cmd.dev.pull.pulling_repos": "Pulling {{.Count}} repo(s):",