feat(sync): replace hub with an internal branch engine

Fetch and prune the preferred remote, then reconcile every local branch
independently. Fast-forward branches that are behind, preserve divergent work,
and delete a branch only when its configured upstream is gone and its commits
are already contained by the remote default branch.

Keep dirty repositories eligible while protecting the active branch and refs
checked out in linked worktrees. Preserve the previous all-clean policy behind
--strict, expose structured per-branch results, add real bare-remote integration
tests, and retain the upstream MIT notice for the adapted hub sync behavior.
This commit is contained in:
2026-09-06 19:41:53 +01:00
parent 351eab3743
commit b80713b334
8 changed files with 703 additions and 170 deletions
+10 -7
View File
@@ -3,7 +3,6 @@ package main
import (
"fmt"
"io"
"os/exec"
"strings"
"time"
@@ -181,8 +180,6 @@ func (m tuiModel) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
count := len(m.selectedRepos())
if count == 0 {
m.status = "Select at least one eligible repository"
} else if _, err := exec.LookPath("hub"); err != nil {
m.status = "hub is required for sync but was not found on PATH"
} else {
m.mode = modeConfirm
}
@@ -231,6 +228,8 @@ func (m tuiModel) View() string {
state := readyStyle.Render("ready")
if !r.Eligible {
state = blockStyle.Render("blocked")
} else if len(r.Dirty) > 0 {
state = titleStyle.Render("ready (dirty)")
}
line := fmt.Sprintf("%s %-3s %-*s %-16s %s", pointer, check, nameWidth, truncateMiddle(r.RelativePath, nameWidth), truncateMiddle(r.Branch, 16), state)
if i == m.cursor {
@@ -250,9 +249,9 @@ func (m tuiModel) View() string {
switch m.mode {
case modeConfirm:
b.WriteString(blockStyle.Bold(true).Render(fmt.Sprintf("Run hub sync in %d selected repositories?", selected)))
b.WriteString(blockStyle.Bold(true).Render(fmt.Sprintf("Synchronize branches in %d selected repositories?", selected)))
b.WriteString("\n")
b.WriteString(mutedStyle.Render("hub may delete local branches it considers merged. y confirm • n/esc cancel"))
b.WriteString(mutedStyle.Render("Dirty checked-out branches are protected; merged branches with deleted upstreams may be removed. y confirm • n/esc cancel"))
case modeSyncing:
spinner := []string{"◐", "◓", "◑", "◒"}[m.frame%4]
b.WriteString(titleStyle.Render(fmt.Sprintf("%s Syncing %d repositories…", spinner, selected)))
@@ -301,7 +300,11 @@ func (m tuiModel) detailView(width int) string {
r := m.repos[m.cursor]
var lines []string
if r.Eligible {
lines = append(lines, readyStyle.Render("READY")+" "+r.RelativePath+" "+mutedStyle.Render(r.Branch))
state := readyStyle.Render("READY")
if len(r.Dirty) > 0 {
state = titleStyle.Render("READY · CHECKED-OUT BRANCH PROTECTED")
}
lines = append(lines, state+" "+r.RelativePath+" "+mutedStyle.Render(r.Branch))
} else {
lines = append(lines, blockStyle.Render("BLOCKED")+" "+r.RelativePath+" "+r.BlockReason)
}
@@ -372,7 +375,7 @@ func (m tuiModel) scanCmd() tea.Cmd {
func (m tuiModel) syncCmd(selected []repo) tea.Cmd {
return func() tea.Msg {
results := syncSelected(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true)
results := syncSelected(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true, false)
paths, err := discover(m.root, m.opts.exclusions)
if err != nil {
return syncDoneMsg{results: results, err: err}