feat: own the branch synchronization engine

Replace the external hub sync dependency with an attributed internal implementation that fetches and updates branches independently. Allow dirty repositories while protecting affected checked-out branches, retain all-clean strict mode, add real-remote safety tests, and update the CLI, TUI, JSON output, version, and documentation.
This commit is contained in:
2026-09-06 19:41:53 +01:00
parent ca53874b48
commit a980153b3e
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}