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
+212
View File
@@ -0,0 +1,212 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
type syncFixture struct {
root string
remote string
work string
other string
}
func makeSyncFixture(t *testing.T) syncFixture {
t.Helper()
root := t.TempDir()
remote := filepath.Join(root, "remote.git")
seed := filepath.Join(root, "seed")
work := filepath.Join(root, "work")
other := filepath.Join(root, "other")
if err := os.MkdirAll(seed, 0o755); err != nil {
t.Fatal(err)
}
git(t, root, "init", "--bare", "-q", "--initial-branch=main", remote)
git(t, seed, "init", "-q", "--initial-branch=main")
configureTestIdentity(t, seed)
writeTestFile(t, filepath.Join(seed, "tracked.txt"), "initial\n")
git(t, seed, "add", "tracked.txt")
git(t, seed, "commit", "-qm", "initial")
git(t, seed, "remote", "add", "origin", remote)
git(t, seed, "push", "-qu", "origin", "main")
git(t, root, "clone", "-q", remote, work)
git(t, root, "clone", "-q", remote, other)
configureTestIdentity(t, work)
configureTestIdentity(t, other)
git(t, work, "checkout", "-qb", "feature")
writeTestFile(t, filepath.Join(work, "feature.txt"), "feature\n")
git(t, work, "add", "feature.txt")
git(t, work, "commit", "-qm", "feature")
git(t, work, "push", "-qu", "origin", "feature")
return syncFixture{root: root, remote: remote, work: work, other: other}
}
func configureTestIdentity(t *testing.T, path string) {
t.Helper()
git(t, path, "config", "user.name", "Test")
git(t, path, "config", "user.email", "test@example.com")
}
func writeTestFile(t *testing.T, path, contents string) {
t.Helper()
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
}
func advanceRemoteBranch(t *testing.T, fixture syncFixture, branch, filename string) {
t.Helper()
git(t, fixture.other, "fetch", "-q", "origin")
git(t, fixture.other, "checkout", "-qB", branch, "origin/"+branch)
writeTestFile(t, filepath.Join(fixture.other, filename), branch+" update\n")
git(t, fixture.other, "add", filename)
git(t, fixture.other, "commit", "-qm", "advance "+branch)
git(t, fixture.other, "push", "-q", "origin", branch)
}
func gitText(t *testing.T, path string, args ...string) string {
t.Helper()
cmd := append([]string{"-C", path}, args...)
output, err := exec.Command("git", cmd...).CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, output)
}
return strings.TrimSpace(string(output))
}
func TestDirtyFeatureBranchStillAllowsInactiveMainUpdate(t *testing.T) {
fixture := makeSyncFixture(t)
writeTestFile(t, filepath.Join(fixture.work, "local-notes.txt"), "unfinished work\n")
advanceRemoteBranch(t, fixture, "main", "main-remote.txt")
beforeFeature := gitText(t, fixture.work, "rev-parse", "feature")
report, err := syncRepository(fixture.work, time.Minute, true, nil)
if err != nil {
t.Fatalf("syncRepository(): %v\n%s", err, report.Message)
}
if got, want := gitText(t, fixture.work, "rev-parse", "main"), gitText(t, fixture.work, "rev-parse", "origin/main"); got != want {
t.Fatalf("inactive main was not updated: got %s, want %s", got, want)
}
if got := gitText(t, fixture.work, "rev-parse", "feature"); got != beforeFeature {
t.Fatalf("dirty checked-out feature branch moved: got %s, want %s", got, beforeFeature)
}
if got := gitText(t, fixture.work, "branch", "--show-current"); got != "feature" {
t.Fatalf("checked-out branch = %q, want feature", got)
}
if _, err := os.Stat(filepath.Join(fixture.work, "local-notes.txt")); err != nil {
t.Fatalf("dirty file was lost: %v", err)
}
assertBranchAction(t, report.Branches, "main", "updated")
}
func TestDirtyCheckedOutBranchIsProtectedWhenRemoteAdvances(t *testing.T) {
fixture := makeSyncFixture(t)
writeTestFile(t, filepath.Join(fixture.work, "feature.txt"), "unfinished feature edit\n")
advanceRemoteBranch(t, fixture, "feature", "feature-remote.txt")
before := gitText(t, fixture.work, "rev-parse", "feature")
report, err := syncRepository(fixture.work, time.Minute, true, nil)
if err != nil {
t.Fatalf("syncRepository(): %v\n%s", err, report.Message)
}
if got := gitText(t, fixture.work, "rev-parse", "feature"); got != before {
t.Fatalf("dirty checked-out branch moved: got %s, want %s", got, before)
}
if upstream := gitText(t, fixture.work, "rev-parse", "origin/feature"); upstream == before {
t.Fatal("fixture did not fetch the advanced remote feature branch")
}
contents, err := os.ReadFile(filepath.Join(fixture.work, "feature.txt"))
if err != nil || string(contents) != "unfinished feature edit\n" {
t.Fatalf("dirty edit changed: contents=%q err=%v", contents, err)
}
assertBranchAction(t, report.Branches, "feature", "protected")
}
func TestCleanCheckedOutBranchFastForwards(t *testing.T) {
fixture := makeSyncFixture(t)
advanceRemoteBranch(t, fixture, "feature", "feature-remote.txt")
report, err := syncRepository(fixture.work, time.Minute, false, nil)
if err != nil {
t.Fatalf("syncRepository(): %v\n%s", err, report.Message)
}
if got, want := gitText(t, fixture.work, "rev-parse", "feature"), gitText(t, fixture.work, "rev-parse", "origin/feature"); got != want {
t.Fatalf("checked-out feature was not fast-forwarded: got %s, want %s", got, want)
}
if _, err := os.Stat(filepath.Join(fixture.work, "feature-remote.txt")); err != nil {
t.Fatalf("fast-forwarded file is missing from worktree: %v", err)
}
assertBranchAction(t, report.Branches, "feature", "updated")
}
func TestDivergentBranchIsWarnedAndLeftUnchanged(t *testing.T) {
fixture := makeSyncFixture(t)
writeTestFile(t, filepath.Join(fixture.work, "local-only.txt"), "local commit\n")
git(t, fixture.work, "add", "local-only.txt")
git(t, fixture.work, "commit", "-qm", "local feature work")
localOID := gitText(t, fixture.work, "rev-parse", "feature")
advanceRemoteBranch(t, fixture, "feature", "remote-only.txt")
report, err := syncRepository(fixture.work, time.Minute, false, nil)
if err != nil {
t.Fatalf("syncRepository(): %v\n%s", err, report.Message)
}
if got := gitText(t, fixture.work, "rev-parse", "feature"); got != localOID {
t.Fatalf("divergent feature branch moved: got %s, want %s", got, localOID)
}
assertBranchAction(t, report.Branches, "feature", "warning")
}
func TestMergedInactiveBranchWithDeletedUpstreamIsDeleted(t *testing.T) {
fixture := makeSyncFixture(t)
git(t, fixture.work, "branch", "old", "main")
git(t, fixture.work, "push", "-qu", "origin", "old")
git(t, fixture.work, "push", "-q", "origin", "--delete", "old")
report, err := syncRepository(fixture.work, time.Minute, false, nil)
if err != nil {
t.Fatalf("syncRepository(): %v\n%s", err, report.Message)
}
if err := exec.Command("git", "-C", fixture.work, "show-ref", "--verify", "--quiet", "refs/heads/old").Run(); err == nil {
t.Fatal("merged branch with deleted configured upstream still exists")
}
assertBranchAction(t, report.Branches, "old", "deleted")
}
func TestBranchCheckedOutInAnotherWorktreeIsProtected(t *testing.T) {
fixture := makeSyncFixture(t)
mainWorktree := filepath.Join(fixture.root, "main-worktree")
git(t, fixture.work, "worktree", "add", "-q", mainWorktree, "main")
before := gitText(t, fixture.work, "rev-parse", "main")
advanceRemoteBranch(t, fixture, "main", "main-remote.txt")
report, err := syncRepository(fixture.work, time.Minute, false, nil)
if err != nil {
t.Fatalf("syncRepository(): %v\n%s", err, report.Message)
}
if got := gitText(t, fixture.work, "rev-parse", "main"); got != before {
t.Fatalf("branch checked out in another worktree moved: got %s, want %s", got, before)
}
assertBranchAction(t, report.Branches, "main", "protected")
}
func assertBranchAction(t *testing.T, results []branchSyncResult, branch, action string) {
t.Helper()
for _, result := range results {
if result.Branch == branch && result.Action == action {
return
}
}
t.Fatalf("missing %s action for branch %s in %+v", action, branch, results)
}