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
This commit is contained in:
+11
-102
@@ -2,63 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if os.Getenv("STACK_SYNC_TEST_HELPER") == "hub" {
|
||||
marker := os.Getenv("STACK_SYNC_TEST_MARKER")
|
||||
if err := os.WriteFile(marker, []byte(strings.Join(os.Args[1:], " ")), 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func installFakeHub(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
source, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := "hub"
|
||||
if runtime.GOOS == "windows" {
|
||||
name += ".exe"
|
||||
}
|
||||
destination := filepath.Join(dir, name)
|
||||
if err := os.Link(source, destination); err == nil {
|
||||
return destination
|
||||
}
|
||||
|
||||
input, err := os.Open(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer input.Close()
|
||||
output, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o755)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.Copy(output, input); err != nil {
|
||||
_ = output.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := output.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
func git(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
||||
@@ -116,7 +67,7 @@ func TestNestedRepoDoesNotDirtyParent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirtyFileBlocksRepository(t *testing.T) {
|
||||
func TestDirtyFileIsReportedButRepositoryRemainsEligible(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "repo")
|
||||
makeRepo(t, path)
|
||||
@@ -125,12 +76,12 @@ func TestDirtyFileBlocksRepository(t *testing.T) {
|
||||
}
|
||||
|
||||
got := inspect(root, path, []string{path})
|
||||
if got.Eligible || len(got.Dirty) != 1 {
|
||||
t.Fatalf("dirty repository was not blocked: %+v", got)
|
||||
if !got.Eligible || len(got.Dirty) != 1 {
|
||||
t.Fatalf("dirty repository was not eligible with its changes reported: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUntrackedFileAlongsideNestedRepoStillBlocksParent(t *testing.T) {
|
||||
func TestUntrackedFileAlongsideNestedRepoIsStillReported(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
parent := filepath.Join(root, "parent")
|
||||
child := filepath.Join(parent, "child")
|
||||
@@ -142,8 +93,8 @@ func TestUntrackedFileAlongsideNestedRepoStillBlocksParent(t *testing.T) {
|
||||
|
||||
paths, _ := discover(root, nil)
|
||||
got := inspect(root, parent, paths)
|
||||
if got.Eligible || len(got.Dirty) != 1 {
|
||||
t.Fatalf("untracked file should block parent: %+v", got)
|
||||
if !got.Eligible || len(got.Dirty) != 1 {
|
||||
t.Fatalf("untracked file should be reported without blocking parent: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +111,7 @@ func TestSkippedDirectoriesAreNotSearched(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncRechecksAndSkipsNewlyDirtyRepository(t *testing.T) {
|
||||
func TestStrictSyncRechecksAndSkipsNewlyDirtyRepository(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "repo")
|
||||
makeRepo(t, path)
|
||||
@@ -172,55 +123,13 @@ func TestSyncRechecksAndSkipsNewlyDirtyRepository(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
bin := filepath.Join(root, "bin")
|
||||
if err := os.Mkdir(bin, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker := filepath.Join(root, "hub-was-called")
|
||||
installFakeHub(t, bin)
|
||||
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("STACK_SYNC_TEST_HELPER", "hub")
|
||||
t.Setenv("STACK_SYNC_TEST_MARKER", marker)
|
||||
|
||||
results := syncAll(root, []repo{planned}, 1, time.Minute, io.Discard, true)
|
||||
results := syncAll(root, []repo{planned}, 1, time.Minute, io.Discard, true, true)
|
||||
if len(results) != 1 || !results[0].Skipped || results[0].Success {
|
||||
t.Fatalf("syncAll() = %+v", results)
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("hub ran despite the newly dirty worktree")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncInvokesHubForCleanRepository(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "repo")
|
||||
makeRepo(t, path)
|
||||
planned := inspect(root, path, []string{path})
|
||||
|
||||
bin := filepath.Join(root, "bin")
|
||||
if err := os.Mkdir(bin, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker := filepath.Join(root, "hub-was-called")
|
||||
installFakeHub(t, bin)
|
||||
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("STACK_SYNC_TEST_HELPER", "hub")
|
||||
t.Setenv("STACK_SYNC_TEST_MARKER", marker)
|
||||
|
||||
results := syncAll(root, []repo{planned}, 1, time.Minute, io.Discard, true)
|
||||
if len(results) != 1 || !results[0].Success || results[0].Skipped {
|
||||
t.Fatalf("syncAll() = %+v", results)
|
||||
}
|
||||
args, err := os.ReadFile(marker)
|
||||
if err != nil {
|
||||
t.Fatalf("hub was not called: %v", err)
|
||||
}
|
||||
if string(args) != "sync --color=never" {
|
||||
t.Fatalf("hub arguments = %q", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanWithBlockedRepositoryIsAValidReport(t *testing.T) {
|
||||
func TestScanReportsDirtyEligibleRepository(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "repo")
|
||||
makeRepo(t, path)
|
||||
@@ -233,8 +142,8 @@ func TestScanWithBlockedRepositoryIsAValidReport(t *testing.T) {
|
||||
if code != 0 {
|
||||
t.Fatalf("run() code = %d, stderr = %q", code, stderr.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte("BLOCKED: dirty worktree")) {
|
||||
t.Fatalf("scan did not report blocked repository:\n%s", stdout.String())
|
||||
if !bytes.Contains(stdout.Bytes(), []byte("dirty worktree (1 changes), checked-out branch protected")) {
|
||||
t.Fatalf("scan did not report dirty eligible repository:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user