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
+20 -8
View File
@@ -10,20 +10,21 @@ import (
func testTUIRepos() []repo {
return []repo{
{Path: "/workspace/clean", RelativePath: "clean", Branch: "main", Eligible: true},
{Path: "/workspace/dirty", RelativePath: "dirty", Branch: "develop", BlockReason: "dirty worktree (1 changes)", Dirty: []string{" M file.txt"}},
{Path: "/workspace/dirty", RelativePath: "dirty", Branch: "develop", Dirty: []string{" M file.txt"}, Eligible: true},
{Path: "/workspace/blocked", RelativePath: "blocked", BlockReason: "detached HEAD"},
}
}
func TestTUIOnlySelectsEligibleRepositories(t *testing.T) {
m := newTUIModel("/workspace", testTUIRepos(), options{})
if got := len(m.selectedRepos()); got != 1 {
t.Fatalf("selected repositories = %d, want 1", got)
if got := len(m.selectedRepos()); got != 2 {
t.Fatalf("selected repositories = %d, want 2", got)
}
m.cursor = 1
m.cursor = 2
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
m = updated.(tuiModel)
if got := len(m.selectedRepos()); got != 1 {
if got := len(m.selectedRepos()); got != 2 {
t.Fatalf("blocked repository became selectable; selected = %d", got)
}
}
@@ -45,16 +46,27 @@ func TestTUIRequiresExplicitConfirmation(t *testing.T) {
func TestTUIViewExplainsBlockedRepository(t *testing.T) {
m := newTUIModel("/workspace", testTUIRepos(), options{})
m.width, m.height, m.cursor = 100, 30, 1
m.width, m.height, m.cursor = 100, 30, 2
view := m.View()
for _, want := range []string{"STACK SYNC", "dirty worktree", "file.txt", "1 blocked"} {
for _, want := range []string{"STACK SYNC", "detached HEAD", "1 blocked"} {
if !strings.Contains(view, want) {
t.Fatalf("view missing %q:\n%s", want, view)
}
}
}
func TestTUIViewShowsLastHubOutputForFocusedRepository(t *testing.T) {
func TestTUIViewExplainsDirtyBranchProtection(t *testing.T) {
m := newTUIModel("/workspace", testTUIRepos(), options{})
m.width, m.height, m.cursor = 100, 30, 1
view := m.View()
for _, want := range []string{"ready (dirty)", "CHECKED-OUT BRANCH PROTECTED", "file.txt"} {
if !strings.Contains(view, want) {
t.Fatalf("view missing %q:\n%s", want, view)
}
}
}
func TestTUIViewShowsLastSyncOutputForFocusedRepository(t *testing.T) {
m := newTUIModel("/workspace", testTUIRepos(), options{})
m.width, m.height = 100, 30
m.lastResult = []syncResult{{Path: "clean", Success: true, Message: "Updated branch main", Duration: "120ms"}}