Files
LyAhn bf67f1a005 feat(sync): remove the hub dependency
- add sync.go to fetch and prune the selected upstream, github, origin, or sole remote
- fast-forward the current branch with merge --ff-only and inactive branches with update-ref
- protect a dirty current branch and branches checked out in linked worktrees
- warn without changing divergent branches
- delete gone-upstream branches only after confirming they are merged into the remote default branch
- allow dirty repositories by default and retain the all-clean policy behind --strict
- return per-branch results in CLI, JSON, and TUI output
- add bare-remote integration tests, the hub MIT notice, and version 0.3.0
2026-09-06 19:41:53 +01:00

213 lines
7.8 KiB
Go

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)
}