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:
+212
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user