7 Commits

Author SHA1 Message Date
LyAhn a221c63d94 fix(sync): keep the live progress counters correct and race-free
Two defects in the in-place progress line introduced with the reporting
work:

- started() only ran for repositories that actually synced, but
  finished() decrements for every repository that records a result. Any
  repository skipped by the eligibility or pre-sync checks therefore
  decremented a counter it had never incremented, so the running "active"
  count went negative and was displayed as such. Count a repository as
  active before the skip checks can exit.

- clear() mutates the line width but ran under outputMu, while draw()
  mutates the same field under the progress mutex. With parallel jobs one
  worker could erase the line while another repainted it. Route clear()
  through the same lock, keeping outputMu around the surrounding writes.

Verified by reverting the first fix and watching the new test report
"active = -3".

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-09-28 20:47:44 +01:00
LyAhn f4286edff2 fix(sync): stop reporting success when no branch was actually synced
hub sync silently dropped any local branch whose upstream it could not
resolve: it cleared its internal remote branch while leaving the "gone"
flag false, so neither the update nor the delete path ran. A run could
then print "Already up to date" having done nothing, which is why a
workspace-wide sync looked like it worked when it did not.

- report unmatched branches as UNTRACKED with the reason, so
  "Already up to date" is only said when it is true
- resolve the remote default branch via `git ls-remote --symref` when
  refs/remotes/<remote>/HEAD is missing, so merged branches whose
  upstream was deleted are still cleaned up
- make --create-missing opt-in, keeping hub's default of never creating
  a local branch that does not already exist
- summarise runs as changed/unchanged with per-action counts, and list
  deletions and failures in full instead of leaving them in scrollback
- restore per-command flag help, which the custom Usage had dropped
- add an in-place CLI progress line, drawn only to a terminal so piped
  and --json output stay free of cursor control
- group the TUI selection screen by outcome so blocked and dirty
  repositories are visible before a run starts

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-09-28 19:57:42 +01:00
LyAhn 4f6e9c9991 feat(tui): show per-repository sync progress
- emit start and finish events from the concurrent sync worker pool
- show elapsed time, a progress bar, active repositories, result counts, and the latest result
- retain failures, skips, protected branches, and divergence warnings after the run
- focus the first failed repository and add f to cycle through repositories requiring attention
- add progress and summary tests, document the new controls, and set version 0.3.1
2026-09-06 20:19:05 +01:00
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
LyAhn 88143838dd fix(test): run the hub fixture on Windows
- replace the POSIX shell fixture with a compiled Go helper named hub or hub.exe
- prepend the helper directory to PATH in TestMain so tests cannot invoke an installed hub binary
- add PowerShell build, install, and Windows path examples to the README
- ignore the generated stack-sync.exe binary
2026-09-06 19:23:15 +01:00
LyAhn e397da2733 feat(tui): add repository picker
- add the tui command using Bubble Tea and Lip Gloss
- preselect eligible repositories and disable selection for blocked repositories
- add keyboard controls for navigation, selection, refresh, sync, and cancellation
- require confirmation before sync and show the branch-deletion warning
- show dirty paths, block reasons, and hub output for the focused repository
- add TUI model tests and set the embedded version to 0.2.0
2026-09-06 18:34:05 +01:00
LyAhn 717ae0addb feat(cli): add scan and sync commands
- discover nested Git repositories and skip dependency and build directories
- add scan and sync commands with root, exclude, jobs, timeout, JSON, strict, and confirmation options
- block detached heads, missing remotes, active Git operations, and dirty worktrees
- exclude nested repositories from their parent's dirty-worktree check
- recheck repository safety before running hub sync
- add discovery, safety, sync, JSON, and output tests
- set the embedded version to 0.1.1 and document installation and usage
2026-09-06 18:29:50 +01:00
9 changed files with 2003 additions and 180 deletions
+2
View File
@@ -1 +1,3 @@
/stack-sync
/stack-sync.exe
/stack-sync.exe~
+83 -11
View File
@@ -1,6 +1,6 @@
# Stack Sync
`stack-sync` safely runs [`hub sync`](https://hub.github.com/hub-sync.1.html) across every Git repository in a workspace. It is designed for directory trees such as JezzWTF where the root may not be a repository and a parent repository may contain deliberately untracked, nested repositories.
`stack-sync` safely fetches and synchronizes local branches across every Git repository in a workspace. It is designed for directory trees such as JezzWTF where the root may not be a repository and a parent repository may contain deliberately untracked, nested repositories.
It provides both a full-screen [Bubble Tea](https://github.com/charmbracelet/bubbletea) control deck for interactive work and a conventional CLI/JSON interface for SSH sessions and automation. Both interfaces use the same discovery and safety engine.
@@ -10,18 +10,49 @@ Before a repository is eligible, Stack Sync verifies that it:
- is on a branch (not a detached `HEAD`);
- has at least one remote;
- has no merge, rebase, cherry-pick, revert, or bisect in progress;
- has no modified, staged, deleted, conflicted, or untracked files.
- has no merge, rebase, cherry-pick, revert, or bisect in progress.
Untracked paths that are themselves discovered nested Git repositories are excluded from the parent repository's dirty check. All other untracked files still block it.
Modified, staged, deleted, conflicted, and untracked files are reported but do not block the repository by default. If the checked-out branch would otherwise be fast-forwarded or deleted, Stack Sync protects that branch and continues synchronizing safe inactive branches. Branches checked out in another linked worktree are protected too. Use `--strict` when every repository must be completely clean before anything is synchronized.
Stack Sync never runs `git stash`, `git reset`, `git clean`, `git commit`, or any other command that saves or discards work. A repository is checked again immediately before `hub sync` to reduce the chance of a change slipping in between the plan and apply phases. Blocked repositories are skipped; use `--strict` to abort the whole run if even one is blocked.
Untracked paths that are themselves discovered nested Git repositories are excluded from the parent repository's dirty report. All other worktree changes remain visible in the CLI, JSON, and TUI.
Stack Sync intentionally preserves `hub sync` semantics. That means `hub` may delete a local branch when its upstream branch has been deleted and it considers the local branch merged. It warns instead when it finds unpushed or apparently unmerged commits. The interactive confirmation calls this out; review `hub help sync` before using `--yes` in automation.
Stack Sync never runs `git stash`, `git reset`, `git clean`, `git commit`, or any other command that saves or discards work. A repository is checked again immediately before synchronization, and the worktree is checked again before changing its checked-out branch.
The branch behavior is derived from the MIT-licensed `hub sync` implementation and is now built directly into Stack Sync. It fetches and prunes the main remote, fast-forwards outdated branches, warns about divergent/unpushed branches, and removes a branch only when its configured upstream was deleted and the branch is already merged into the remote default branch. It prefers a remote named `upstream`, `github`, or `origin`; a sole differently named remote is also accepted. See [third-party notices](THIRD_PARTY_NOTICES.md) for attribution.
## Branch handling
`hub sync` only ever moved branches that already existed locally, and a branch whose upstream it could not resolve was dropped from the report entirely. Stack Sync keeps hub's safety model but stops the silent behaviour:
- **Unmatched branches are reported, not hidden.** A local branch with no configured upstream and no same-named branch on the remote cannot be fast-forwarded. `hub` set its internal `remoteBranch` to an empty string and left `gone` false, so neither of its branches ran and the branch vanished without a word; the run then reported "Already up to date" while having done nothing. Such a branch is now reported as `UNTRACKED` with the reason. This makes the outcome visible, but it does not fast-forward the branch — there is no upstream to fast-forward from.
- **The remote default branch is resolved properly.** Which branch a deleted-and-merged branch had been merged into was inferred from `refs/remotes/<remote>/HEAD`, a local convenience symref that is missing from plenty of real clones. When it is absent Stack Sync now asks the remote directly with `git ls-remote --symref`, so merged branches are still cleaned up instead of being kept with a vague warning.
By default, like `hub`, Stack Sync never creates a local branch that does not already exist. Pass `--create-missing` to additionally create local branches for branches that exist only on the remote; they are created with tracking configured and reported as `CREATED`, and this never touches the worktree, moves an existing branch, or checks anything out.
Branch actions reported per repository are `UPDATED`, `DELETED`, `CREATED`, `PROTECTED`, `WARNING`, and `UNTRACKED`.
## Requirements
Stack Sync supports Linux and Windows. It requires:
- Go 1.24 or newer to build;
- Git on `PATH`.
There is no separate `hub` dependency.
## Build and install
Requires Go 1.24 or newer, Git, and [hub](https://hub.github.com/).
`go install` is the simplest cross-platform installation method. From the repository root, run:
```text
go install .
```
Go automatically installs `stack-sync` on Linux and `stack-sync.exe` on Windows into your Go binary directory. Make sure that directory is on `PATH` (normally `$HOME/go/bin` on Linux and `%USERPROFILE%\go\bin` on Windows).
To build a binary in the repository instead, use the command for your platform.
### Linux
```bash
cd stack-sync
@@ -30,6 +61,18 @@ go build -o stack-sync .
install -Dm755 stack-sync ~/.local/bin/stack-sync
```
### Windows (PowerShell)
```powershell
Set-Location stack-sync
go test ./...
go build -o stack-sync.exe .
New-Item -ItemType Directory -Force "$env:LOCALAPPDATA\Programs\stack-sync" | Out-Null
Copy-Item .\stack-sync.exe "$env:LOCALAPPDATA\Programs\stack-sync\stack-sync.exe"
```
Add `%LOCALAPPDATA%\Programs\stack-sync` to your user `PATH` if it is not already present. The `.exe` suffix is important when choosing an explicit output name on Windows; `go build -o stack-sync .` creates an extensionless binary there.
## Usage
### Interactive TUI
@@ -40,7 +83,21 @@ Launch the control deck for the workspace:
stack-sync tui --root ~/Coding/jwtf
```
Every eligible repository starts selected. Move with the arrow keys or `j`/`k`, toggle the focused repository with Space, select all with `a`, clear the selection with `n`, and refresh with `r`. Press `s` or Enter to review the destructive-branch warning, then `y` to begin. Blocked repositories cannot be selected and show their first dirty paths in the detail panel. After a run, focusing a repository shows its latest `hub` output.
PowerShell accepts the same options with a Windows path:
```powershell
stack-sync tui --root C:\Users\you\Coding\jwtf
```
Every eligible repository starts selected. The list is grouped by what will happen if you start a run now, so problems are visible before you commit to anything:
- `BLOCKED` — not eligible, and cannot be selected. The reason (detached `HEAD`, merge in progress, no remotes) is shown for the focused row.
- `DIRTY` — eligible, but the checked-out branch will be protected rather than fast-forwarded.
- `READY` — fully eligible.
Move with the arrow keys or `j`/`k`, toggle the focused repository with Space, select all with `a`, clear the selection with `n`, and refresh with `r`. Press `s` or Enter to review the branch-deletion warning, then `y` to begin. Blocked repositories cannot be selected.
During synchronization, the TUI shows overall progress, elapsed time, active repositories, live success/failure/skip totals, and the latest completed result. The final view lists failures and protected or divergent branches, prioritizes and focuses the first failure automatically, and retains per-repository branch details. Press `f` to cycle through every repository that needs attention.
### CLI and automation
@@ -52,13 +109,25 @@ stack-sync scan --root ~/Coding/jwtf
stack-sync --root ~/Coding/jwtf
```
```powershell
stack-sync scan --root C:\Users\you\Coding\jwtf
```
Review the same plan, confirm it, and sync every eligible repository:
```bash
stack-sync sync --root ~/Coding/jwtf
```
For automation, suppress the prompt and optionally require the entire workspace to be clean:
While a run is in progress, an updating line keeps the workspace totals on screen so you can see what is happening without waiting for the final summary:
```text
17/40 · 2 active · 9 changed · 8 updated · 1 deleted · 2 protected · 1 warning · 1 untracked
```
That line is drawn only when stdout is a terminal. Redirected output and `--json` never contain cursor control characters, and `--json` is the right interface for scripts.
For automation, suppress the prompt and optionally require the entire workspace to be clean. In strict mode, either a blocked repository or any worktree change aborts the whole run:
```bash
stack-sync sync --root ~/Coding/jwtf --yes --strict
@@ -68,12 +137,15 @@ Useful options:
```text
--jobs 4 maximum concurrent inspections or syncs
--timeout 5m per-repository hub sync timeout
--timeout 5m per-repository fetch and sync timeout
--create-missing create local branches that exist only on the remote
--exclude temp skip a directory name anywhere in the tree
--exclude Org/old skip a root-relative path
--json emit structured output
```
Both `/` and `\` are accepted in Windows paths. Quote a root or exclusion containing spaces, for example `--root "C:\Users\you\Source Repositories"`.
Dependency caches, build outputs, and tool-managed directories such as `node_modules`, `target`, `.claude`, and `.codex` are skipped during discovery by default. These exclusions only affect repository discovery; they never make real changes inside a discovered repository disappear from its dirty-worktree check.
Exit codes are `0` for a successful scan or sync (planned dirty repositories may be safely skipped), `1` for an operational/sync failure, `2` for invalid or unconfirmed non-interactive use, and `3` when strict mode refuses the plan or a previously eligible repository fails its immediate pre-sync safety check.
Exit codes are `0` for a successful scan or sync (including protected branches and divergence warnings), `1` for an operational/sync failure, `2` for invalid or unconfirmed non-interactive use, and `3` when strict mode refuses the plan or a previously eligible repository fails its immediate pre-sync safety check.
+13
View File
@@ -0,0 +1,13 @@
# Third-party notices
The branch synchronization behavior in `sync.go` is adapted from the `hub sync` command in [hub](https://github.com/mislav/hub), version 2.14.2.
## hub
Copyright (c) 2009 Chris Wanstrath
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+250 -45
View File
@@ -3,7 +3,6 @@ package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"flag"
@@ -17,9 +16,10 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
)
const version = "0.2.0"
const version = "0.4.0"
var defaultSkippedDirs = map[string]bool{
".cache": true, ".claude": true, ".codex": true, ".git": true, ".next": true, ".pnpm-store": true,
@@ -35,6 +35,7 @@ type options struct {
jobs int
timeout time.Duration
exclusions stringList
createMissing bool
}
type stringList []string
@@ -62,6 +63,13 @@ type syncResult struct {
Skipped bool `json:"skipped"`
Message string `json:"message,omitempty"`
Duration string `json:"duration,omitempty"`
Branches []branchSyncResult `json:"branches,omitempty"`
}
type syncProgressEvent struct {
Path string
Started bool
Result syncResult
}
func main() {
@@ -130,21 +138,17 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 0
}
if _, err := exec.LookPath("hub"); err != nil {
fmt.Fprintln(stderr, "hub is required for sync but was not found on PATH")
return 1
}
if !opts.json {
printScan(stdout, root, repos)
}
eligible := eligibleCount(repos)
blocked := blockedCount(repos)
if eligible == 0 {
fmt.Fprintln(stderr, "nothing to sync: every repository is blocked")
return 3
}
if opts.strict && blocked > 0 {
fmt.Fprintf(stderr, "strict mode: refusing to sync because %d repositories are blocked\n", blocked)
strictUnsafe := strictUnsafeCount(repos)
if opts.strict && strictUnsafe > 0 {
fmt.Fprintf(stderr, "strict mode: refusing to sync because %d repositories are blocked or dirty\n", strictUnsafe)
return 3
}
if !opts.yes {
@@ -152,7 +156,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "refusing to sync without confirmation; pass --yes in non-interactive use")
return 2
}
fmt.Fprintf(stdout, "\nRun hub sync in %d clean repositories? hub may delete local branches it considers merged. [y/N] ", eligible)
fmt.Fprintf(stdout, "\nSynchronize branches in %d repositories? Merged branches whose upstream was deleted may be removed. [y/N] ", eligible)
answer, _ := bufio.NewReader(stdin).ReadString('\n')
answer = strings.ToLower(strings.TrimSpace(answer))
if answer != "y" && answer != "yes" {
@@ -161,7 +165,12 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
}
}
results := syncAll(root, repos, opts.jobs, opts.timeout, stdout, opts.json)
var liveMu sync.Mutex
live := newLiveProgress(stdout, &liveMu, len(repos))
results := syncAllWithLive(root, repos, opts.jobs, opts.timeout, stdout, opts.json, opts.strict, opts.createMissing, live)
liveMu.Lock()
live.clear()
liveMu.Unlock()
if opts.json {
writeJSON(stdout, results)
} else {
@@ -190,12 +199,20 @@ func parseFlags(command string, args []string, stderr io.Writer) (options, error
fs.Var(&opts.exclusions, "exclude", "directory name or root-relative path to skip (repeatable)")
if command == "sync" {
fs.BoolVar(&opts.yes, "yes", false, "apply without an interactive confirmation")
fs.BoolVar(&opts.strict, "strict", false, "abort the entire run if any repository is blocked")
fs.BoolVar(&opts.strict, "strict", false, "abort the entire run if any repository is blocked or dirty")
}
if command == "sync" || command == "tui" {
fs.DurationVar(&opts.timeout, "timeout", opts.timeout, "timeout for each hub sync")
fs.DurationVar(&opts.timeout, "timeout", opts.timeout, "timeout for each repository sync")
fs.BoolVar(&opts.createMissing, "create-missing", false, "create local branches that exist only on the remote")
}
fs.Usage = func() {
usage(stderr)
// PrintDefaults was dropped along with the stock usage function, which
// left "use <command> -h for command options" promising flags that
// were never actually listed.
fmt.Fprintf(stderr, "\nOptions for %s:\n", command)
fs.PrintDefaults()
}
fs.Usage = func() { usage(stderr) }
if err := fs.Parse(args); err != nil {
return opts, err
}
@@ -218,7 +235,7 @@ func parseFlags(command string, args []string, stderr io.Writer) (options, error
}
func usage(w io.Writer) {
fmt.Fprintln(w, `stack-sync safely runs hub sync across a directory tree.
fmt.Fprintln(w, `stack-sync safely synchronizes Git branches across a directory tree.
Usage:
stack-sync [scan] [options]
@@ -228,13 +245,21 @@ Usage:
Commands:
scan Discover repositories and report whether each is safe to sync (default)
sync Scan, confirm, re-check safety, then run hub sync in eligible repositories
sync Scan, confirm, re-check safety, then sync branches in eligible repositories
tui Interactively review, select, and sync repositories
Safety:
Repositories with edits, untracked files, detached HEADs, no remotes, or an
in-progress Git operation are never synced. Untracked nested repositories do
not falsely dirty their parent. stack-sync never stashes, resets, or commits.
Dirty worktrees are reported but remain eligible: their checked-out branch is
protected while safe inactive branches are synchronized. Detached HEADs,
repositories without remotes, and in-progress Git operations remain blocked.
stack-sync never stashes, resets, cleans, or commits.
Branch handling:
Outdated local branches are fast-forwarded, and a local branch whose upstream
was deleted is removed once it is merged into the remote default branch.
A local branch with no upstream and no matching remote branch cannot be
fast-forwarded, so it is reported as UNTRACKED and left alone. Pass
--create-missing to also create branches that exist only on the remote.
Use "stack-sync <command> -h" for command options.`)
}
@@ -336,9 +361,6 @@ func inspect(root, path string, allRepos []string) repo {
}
r.Dirty = append(r.Dirty, change.code+" "+change.path)
}
if len(r.Dirty) > 0 {
setBlock(&r, fmt.Sprintf("dirty worktree (%d changes)", len(r.Dirty)))
}
r.Eligible = r.BlockReason == ""
return r
}
@@ -422,11 +444,20 @@ func gitBytes(path string, args ...string) ([]byte, error) {
return cmd.Output()
}
func syncAll(root string, repos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet bool) []syncResult {
return syncSelected(root, repos, repos, jobs, timeout, stdout, quiet)
func syncAll(root string, repos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict, createMissing bool) []syncResult {
return syncSelected(root, repos, repos, jobs, timeout, stdout, quiet, strict, createMissing, nil)
}
func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet bool) []syncResult {
// syncAllWithLive is syncAll with the in-place progress line attached.
func syncAllWithLive(root string, repos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict, createMissing bool, live *liveProgress) []syncResult {
return syncSelected(root, repos, repos, jobs, timeout, stdout, quiet, strict, createMissing, live)
}
func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict, createMissing bool, live *liveProgress) []syncResult {
return syncSelectedWithProgress(root, repos, workspaceRepos, jobs, timeout, stdout, quiet, strict, createMissing, live, nil)
}
func syncSelectedWithProgress(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict, createMissing bool, live *liveProgress, progress func(syncProgressEvent)) []syncResult {
type item struct {
index int
repo repo
@@ -439,6 +470,13 @@ func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout t
for _, r := range workspaceRepos {
allPaths = append(allPaths, r.Path)
}
record := func(index int, result syncResult) {
results[index] = result
if progress != nil {
progress(syncProgressEvent{Path: result.Path, Result: result})
}
live.update(func() { live.finished(result); live.draw() })
}
for range min(jobs, len(repos)) {
wg.Add(1)
@@ -446,35 +484,40 @@ func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout t
defer wg.Done()
for next := range work {
r := next.repo
// Counted active before any skip path can record: finished()
// decrements again, so an early exit that skipped started()
// would drive the active count negative.
live.update(func() { live.started(); live.draw() })
if !r.Eligible {
results[next.index] = syncResult{Path: r.RelativePath, Skipped: true, Message: r.BlockReason}
record(next.index, syncResult{Path: r.RelativePath, Skipped: true, Message: r.BlockReason})
continue
}
// Close the scan/apply race: inspect again immediately before mutation.
fresh := inspect(root, r.Path, allPaths)
if !fresh.Eligible {
results[next.index] = syncResult{Path: r.RelativePath, Skipped: true, Message: "pre-sync recheck: " + fresh.BlockReason}
record(next.index, syncResult{Path: r.RelativePath, Skipped: true, Message: "pre-sync recheck: " + fresh.BlockReason})
continue
}
if strict && len(fresh.Dirty) > 0 {
record(next.index, syncResult{Path: r.RelativePath, Skipped: true, Message: fmt.Sprintf("pre-sync recheck: dirty worktree (%d changes)", len(fresh.Dirty))})
continue
}
if progress != nil {
progress(syncProgressEvent{Path: r.RelativePath, Started: true})
}
if !quiet {
outputMu.Lock()
live.update(func() { live.clear() })
fmt.Fprintf(stdout, "\nSTART %s (%s)\n", r.RelativePath, r.Branch)
outputMu.Unlock()
}
started := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
cmd := exec.CommandContext(ctx, "hub", "sync", "--color=never")
cmd.Dir = r.Path
combined, err := cmd.CombinedOutput()
cancel()
message := strings.TrimSpace(string(combined))
if ctx.Err() == context.DeadlineExceeded {
err = ctx.Err()
message = "timed out after " + timeout.String()
}
report, err := syncRepositoryWith(r.Path, timeout, len(fresh.Dirty) > 0, fresh.NestedRepoEntries, syncOptions{CreateMissing: createMissing})
message := report.Message
duration := time.Since(started).Round(time.Millisecond).String()
if !quiet {
outputMu.Lock()
live.update(func() { live.clear() })
state := "DONE"
if err != nil {
state = "FAIL"
@@ -488,7 +531,7 @@ func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout t
if err != nil && message == "" {
message = err.Error()
}
results[next.index] = syncResult{Path: r.RelativePath, Success: err == nil, Message: message, Duration: duration}
record(next.index, syncResult{Path: r.RelativePath, Success: err == nil, Message: message, Duration: duration, Branches: report.Branches})
}
}()
}
@@ -513,6 +556,8 @@ func printScan(w io.Writer, root string, repos []repo) {
status := "ready"
if !r.Eligible {
status = "BLOCKED: " + r.BlockReason
} else if len(r.Dirty) > 0 {
status = fmt.Sprintf("ready; dirty worktree (%d changes), checked-out branch protected", len(r.Dirty))
}
if len(r.NestedRepoEntries) > 0 {
status += fmt.Sprintf("; %d nested repo path(s) ignored", len(r.NestedRepoEntries))
@@ -525,24 +570,168 @@ func printScan(w io.Writer, root string, repos []repo) {
}
}
func printSummary(w io.Writer, results []syncResult) {
var ok, failed, skipped int
for _, r := range results {
// actionOrder fixes the order actions are summarised in, so the same workspace
// always reads the same way from one run to the next.
var actionOrder = []string{"updated", "deleted", "created", "protected", "warning", "untracked"}
// liveProgress redraws a single line in place while repositories sync, so
// workspace totals are visible without waiting for the final summary. It is only
// enabled when stdout is a terminal: writing cursor control into a pipe or a
// --json document would corrupt the captured output, so callers get a nil
// *liveProgress in those cases and every method here is a no-op.
type liveProgress struct {
w io.Writer
mu *sync.Mutex
total int
done int
active int
changed int
failed int
skipped int
counts map[string]int
lastWidth int
}
func newLiveProgress(w io.Writer, mu *sync.Mutex, total int) *liveProgress {
if !isTerminalWriter(w) {
return nil
}
return &liveProgress{w: w, mu: mu, total: total, counts: make(map[string]int, len(actionOrder))}
}
// started records that a repository has begun syncing.
func (p *liveProgress) started() {
if p == nil {
return
}
p.active++
}
// finished folds a completed repository into the running totals.
func (p *liveProgress) finished(result syncResult) {
if p == nil {
return
}
p.done++
p.active--
for _, branch := range result.Branches {
p.counts[branch.Action]++
}
switch {
case result.Skipped:
p.skipped++
case !result.Success:
p.failed++
case repositoryChanged(result):
p.changed++
}
}
func (p *liveProgress) text() string {
parts := []string{fmt.Sprintf("%d/%d", p.done, p.total)}
if p.active > 0 {
parts = append(parts, fmt.Sprintf("%d active", p.active))
}
parts = append(parts, fmt.Sprintf("%d changed", p.changed))
if line := formatActionCounts(p.counts); line != "" {
parts = append(parts, line)
}
if p.skipped > 0 {
parts = append(parts, fmt.Sprintf("%d skipped", p.skipped))
}
if p.failed > 0 {
parts = append(parts, fmt.Sprintf("%d FAILED", p.failed))
}
return " " + strings.Join(parts, " · ")
}
// draw repaints the line. Callers must already hold p.mu when drawing.
func (p *liveProgress) draw() {
if p == nil {
return
}
line := p.text()
fmt.Fprintf(p.w, "\r%s\r%s", strings.Repeat(" ", p.lastWidth), line)
p.lastWidth = utf8.RuneCountInString(line)
}
// clear erases the line so ordinary output can be written on top of it.
func (p *liveProgress) clear() {
if p == nil {
return
}
p.lastWidth = 0
}
func (p *liveProgress) update(fn func()) {
if p == nil {
return
}
p.mu.Lock()
defer p.mu.Unlock()
fn()
}
func printSummary(w io.Writer, results []syncResult) {
counts := make(map[string]int, len(actionOrder))
var changed, unchanged, failed, skipped int
for _, r := range results {
for _, branch := range r.Branches {
counts[branch.Action]++
}
switch {
case r.Success:
ok++
case r.Skipped:
skipped++
default:
case !r.Success:
failed++
case repositoryChanged(r):
changed++
default:
unchanged++
}
}
fmt.Fprintf(w, "\nSummary: %d synced, %d failed, %d skipped\n", ok, failed, skipped)
// "synced" counted every repository that ran, including ones where nothing
// was actually touched, so a run that changed nothing still looked busy.
fmt.Fprintf(w, "\nSummary: %d changed, %d unchanged, %d failed, %d skipped\n", changed, unchanged, failed, skipped)
if line := formatActionCounts(counts); line != "" {
fmt.Fprintf(w, " %s\n", line)
}
// Deletions and failures are destructive or unexpected, so they are listed
// in full here rather than left to be found in the scrolling log above.
if failed > 0 || counts["deleted"] > 0 {
fmt.Fprintln(w, "\nNeeds review:")
for _, r := range results {
if !r.Success && !r.Skipped {
fmt.Fprintf(w, " FAILED %s: %s\n", r.Path, firstLine(r.Message))
}
for _, branch := range r.Branches {
if branch.Action == "deleted" {
fmt.Fprintf(w, " DELETED %s / %s: %s\n", r.Path, branch.Branch, branch.Message)
}
}
}
}
}
func repositoryChanged(r syncResult) bool {
for _, branch := range r.Branches {
switch branch.Action {
case "updated", "deleted", "created":
return true
}
}
return false
}
func formatActionCounts(counts map[string]int) string {
parts := make([]string, 0, len(actionOrder))
for _, action := range actionOrder {
if n := counts[action]; n > 0 {
parts = append(parts, fmt.Sprintf("%d %s", n, action))
}
}
return strings.Join(parts, " · ")
}
func eligibleCount(repos []repo) int {
@@ -555,6 +744,15 @@ func eligibleCount(repos []repo) int {
return n
}
func blockedCount(repos []repo) int { return len(repos) - eligibleCount(repos) }
func strictUnsafeCount(repos []repo) int {
n := 0
for _, r := range repos {
if !r.Eligible || len(r.Dirty) > 0 {
n++
}
}
return n
}
func writeJSON(w io.Writer, value any) {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
@@ -585,3 +783,10 @@ func isTerminal(file *os.File) bool {
info, err := file.Stat()
return err == nil && info.Mode()&os.ModeCharDevice != 0
}
// isTerminalWriter reports whether w is a real terminal, so callers can avoid
// emitting cursor control sequences into redirected or buffered output.
func isTerminalWriter(w io.Writer) bool {
file, ok := w.(*os.File)
return ok && isTerminal(file)
}
+169 -56
View File
@@ -6,6 +6,8 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
@@ -67,7 +69,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)
@@ -76,12 +78,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")
@@ -93,8 +95,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)
}
}
@@ -111,7 +113,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)
@@ -123,59 +125,19 @@ 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")
script := "#!/bin/sh\ntouch \"$STACK_SYNC_TEST_MARKER\"\n"
if err := os.WriteFile(filepath.Join(bin, "hub"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("STACK_SYNC_TEST_MARKER", marker)
results := syncAll(root, []repo{planned}, 1, time.Minute, io.Discard, true)
var progress []syncProgressEvent
results := syncSelectedWithProgress(root, []repo{planned}, []repo{planned}, 1, time.Minute, io.Discard, true, true, false, nil, func(event syncProgressEvent) {
progress = append(progress, event)
})
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")
if len(progress) != 1 || progress[0].Started || !progress[0].Result.Skipped {
t.Fatalf("progress events = %+v, want one finished skip", progress)
}
}
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")
script := "#!/bin/sh\nprintf '%s' \"$*\" > \"$STACK_SYNC_TEST_MARKER\"\n"
if err := os.WriteFile(filepath.Join(bin, "hub"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
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)
@@ -188,8 +150,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())
}
}
@@ -200,3 +162,154 @@ func TestIndentNormalizesGitProgressOutput(t *testing.T) {
t.Fatalf("indent() = %q, want %q", got, want)
}
}
// The summary must say what actually changed, not just how many repositories
// ran. "synced" previously counted repositories where nothing was touched.
func TestSummaryReportsPerActionCountsAndDeletions(t *testing.T) {
results := []syncResult{
{Path: "api", Success: true, Branches: []branchSyncResult{
{Branch: "main", Action: "updated"},
{Branch: "stale", Action: "deleted", Message: "upstream was deleted and the branch was merged into main"},
}},
{Path: "web", Success: true, Branches: []branchSyncResult{
{Branch: "wip", Action: "protected"},
}},
{Path: "cli", Success: true},
{Path: "docs", Message: "fetch origin: authentication failed"},
}
var out bytes.Buffer
printSummary(&out, results)
got := out.String()
for _, want := range []string{
"1 changed", "2 unchanged", "1 failed", "0 skipped",
"1 updated · 1 deleted · 1 protected",
"Needs review:",
"DELETED api / stale: upstream was deleted and the branch was merged into main",
"FAILED docs: fetch origin: authentication failed",
} {
if !strings.Contains(got, want) {
t.Fatalf("summary missing %q:\n%s", want, got)
}
}
// A workspace where nothing changed must not claim to have synced anything,
// and must not open a "Needs review" section with nothing to review.
var quiet bytes.Buffer
printSummary(&quiet, []syncResult{{Path: "web", Success: true, Branches: []branchSyncResult{{Branch: "wip", Action: "protected"}}}})
if strings.Contains(quiet.String(), "1 changed") {
t.Fatalf("a protected-only run was reported as changed:\n%s", quiet.String())
}
if strings.Contains(quiet.String(), "Needs review") {
t.Fatalf("a run with no failures or deletions opened a review section:\n%s", quiet.String())
}
}
// The in-place progress line must never be written to a pipe, a buffer, or a
// --json document, where the cursor control would corrupt the captured output.
func TestLiveProgressIsDisabledForNonTerminalOutput(t *testing.T) {
var mu sync.Mutex
if p := newLiveProgress(&bytes.Buffer{}, &mu, 3); p != nil {
t.Fatal("newLiveProgress() enabled the live line for a buffered writer")
}
}
func TestLiveProgressRendersRunningTotals(t *testing.T) {
var mu sync.Mutex
buf := &bytes.Buffer{}
p := &liveProgress{w: buf, mu: &mu, total: 3, counts: map[string]int{}}
p.update(func() { p.started(); p.draw() })
if !strings.Contains(buf.String(), "0/3") || !strings.Contains(buf.String(), "1 active") {
t.Fatalf("start did not render progress: %q", buf.String())
}
buf.Reset()
p.update(func() {
p.finished(syncResult{Path: "api", Success: true, Branches: []branchSyncResult{{Branch: "main", Action: "updated"}}})
p.draw()
})
for _, want := range []string{"1/3", "1 changed", "1 updated"} {
if !strings.Contains(buf.String(), want) {
t.Fatalf("totals missing %q: %q", want, buf.String())
}
}
buf.Reset()
p.update(func() {
p.finished(syncResult{Path: "web", Message: "fetch origin: authentication failed"})
p.draw()
})
for _, want := range []string{"2/3", "1 FAILED"} {
if !strings.Contains(buf.String(), want) {
t.Fatalf("failure not surfaced live: %q", buf.String())
}
}
if strings.Contains(buf.String(), "2 changed") {
t.Fatalf("a failed repository was counted as changed: %q", buf.String())
}
}
// End-to-end guard: a non-interactive run must emit no carriage returns at all.
func TestSyncOutputToBufferHasNoCarriageReturns(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "repo")
makeRepo(t, path)
var stdout, stderr bytes.Buffer
code := run([]string{"sync", "--root", root, "--yes"}, bytes.NewReader(nil), &stdout, &stderr)
if code > 1 {
t.Fatalf("run() code = %d, stderr = %q", code, stderr.String())
}
if strings.Contains(stdout.String(), "\r") {
t.Fatalf("buffered output contained cursor control:\n%q", stdout.String())
}
if !strings.Contains(stdout.String(), "Summary:") {
t.Fatalf("run did not print a summary:\n%q", stdout.String())
}
}
// A repository skipped before it ever syncs must still be counted active and
// then finished. Counting it as active only after the skip checks left the
// running "active" total negative, because finished() decrements it regardless.
func TestLiveProgressBalancesSkippedRepositories(t *testing.T) {
root := t.TempDir()
var repos []repo
for _, name := range []string{"blocked-a", "blocked-b", "blocked-c"} {
path := filepath.Join(root, name)
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
// Not Eligible, so the worker takes the early-exit skip path.
repos = append(repos, repo{Path: path, RelativePath: name, BlockReason: "no remotes"})
}
var mu sync.Mutex
buf := &bytes.Buffer{}
live := &liveProgress{w: buf, mu: &mu, total: len(repos), counts: map[string]int{}}
results := syncSelectedWithProgress(root, repos, repos, 2, time.Minute, io.Discard, true, false, false, live, nil)
if len(results) != len(repos) {
t.Fatalf("results = %d, want %d", len(results), len(repos))
}
for _, result := range results {
if !result.Skipped {
t.Fatalf("%s was not skipped: %+v", result.Path, result)
}
}
if live.done != len(repos) {
t.Fatalf("done = %d, want %d", live.done, len(repos))
}
if live.active != 0 {
t.Fatalf("active = %d after every repository finished, want 0", live.active)
}
if live.skipped != len(repos) {
t.Fatalf("skipped = %d, want %d", live.skipped, len(repos))
}
// Intermediate frames legitimately report active repositories; only the
// last one must be settled.
frames := strings.Split(buf.String(), "\r")
if last := frames[len(frames)-1]; strings.Contains(last, "active") {
t.Fatalf("a settled run still reports active repositories: %q", last)
}
}
+538
View File
@@ -0,0 +1,538 @@
package main
// The branch synchronization behavior in this file is adapted from hub's
// `hub sync` command. See THIRD_PARTY_NOTICES.md for attribution and license.
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"sort"
"strings"
"time"
)
type branchSyncResult struct {
Branch string `json:"branch"`
Action string `json:"action"`
Message string `json:"message,omitempty"`
}
type repositorySyncReport struct {
Message string
Branches []branchSyncResult
}
type localBranch struct {
name string
ref string
oid string
}
// upstreamState describes what stack-sync could work out about where a local
// branch comes from on the main remote.
type upstreamState int
const (
// upstreamTracked means the local branch has a resolvable remote branch.
upstreamTracked upstreamState = iota
// upstreamDeleted means the branch had a configured upstream which the
// fetch has just pruned, so the remote branch is gone.
upstreamDeleted
// upstreamUnmatched means the branch has no configured upstream and no
// same-named branch on the remote. Such a branch cannot be fast-forwarded
// automatically, and it used to be dropped from the report entirely, which
// made the run claim to be "Already up to date" while silently doing
// nothing at all for it.
upstreamUnmatched
)
// syncOptions tunes a single repository sync.
type syncOptions struct {
// CreateMissing creates a local branch for every remote branch that has no
// local counterpart, so work started on another machine shows up here.
CreateMissing bool
}
func syncRepository(path string, timeout time.Duration, initiallyDirty bool, ignoredNested []string) (repositorySyncReport, error) {
return syncRepositoryWith(path, timeout, initiallyDirty, ignoredNested, syncOptions{})
}
func syncRepositoryWith(path string, timeout time.Duration, initiallyDirty bool, ignoredNested []string, opts syncOptions) (repositorySyncReport, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
var report repositorySyncReport
remote, err := mainRemote(ctx, path)
if err != nil {
return failedReport(report, ctx, timeout, err)
}
fetchOutput, err := gitCombinedContext(ctx, path, "fetch", "--prune", "--quiet", "--progress", remote)
if err != nil {
return failedReport(report, ctx, timeout, gitCommandError("fetch "+remote, fetchOutput, err))
}
if operation := gitOperation(path); operation != "" {
return failedReport(report, ctx, timeout, fmt.Errorf("%s started during sync", operation))
}
current, checkedOut, err := worktreeState(ctx, path)
if err != nil {
return failedReport(report, ctx, timeout, err)
}
branches, err := localBranches(ctx, path)
if err != nil {
return failedReport(report, ctx, timeout, err)
}
defaultRef, defaultBranch, err := remoteDefaultBranch(ctx, path, remote)
if err != nil {
return failedReport(report, ctx, timeout, err)
}
for _, branch := range branches {
target, state, err := upstreamForBranch(ctx, path, remote, branch.name)
if err != nil {
return failedReport(report, ctx, timeout, err)
}
if target != "" {
targetOID, err := resolveCommit(ctx, path, target)
if err != nil {
return failedReport(report, ctx, timeout, fmt.Errorf("resolve upstream for %s: %w", branch.name, err))
}
if targetOID == branch.oid {
continue
}
behind, err := isAncestor(ctx, path, branch.oid, targetOID)
if err != nil {
return failedReport(report, ctx, timeout, fmt.Errorf("compare branch %s with its upstream: %w", branch.name, err))
}
if !behind {
report.add(branch.name, "warning", "contains commits that are not in its upstream")
continue
}
current, checkedOut, err = worktreeState(ctx, path)
if err != nil {
return failedReport(report, ctx, timeout, err)
}
if branch.name == current {
dirty, err := worktreeDirty(ctx, path, ignoredNested)
if err != nil {
return failedReport(report, ctx, timeout, err)
}
if initiallyDirty || dirty {
report.add(branch.name, "protected", "checked-out branch has worktree changes")
continue
}
output, err := gitCombinedContext(ctx, path, "merge", "--ff-only", "--quiet", target)
if err != nil {
return failedReport(report, ctx, timeout, gitCommandError("fast-forward branch "+branch.name, output, err))
}
} else if checkedOut[branch.ref] {
report.add(branch.name, "protected", "checked out in another worktree")
continue
} else {
output, err := gitCombinedContext(ctx, path, "update-ref", branch.ref, targetOID, branch.oid)
if err != nil {
return failedReport(report, ctx, timeout, gitCommandError("fast-forward branch "+branch.name, output, err))
}
}
report.add(branch.name, "updated", "fast-forwarded from "+abbreviateOID(branch.oid))
continue
}
if state == upstreamUnmatched {
report.add(branch.name, "untracked", "no branch "+branch.name+" on "+remote+" and no upstream configured; left unchanged")
continue
}
if defaultRef == "" {
report.add(branch.name, "warning", "upstream was deleted; kept because the remote default branch is unknown")
continue
}
merged, err := isAncestor(ctx, path, branch.oid, defaultRef)
if err != nil {
return failedReport(report, ctx, timeout, fmt.Errorf("check whether branch %s is merged: %w", branch.name, err))
}
if !merged {
report.add(branch.name, "warning", "upstream was deleted, but the branch is not merged into "+defaultBranch)
continue
}
current, checkedOut, err = worktreeState(ctx, path)
if err != nil {
return failedReport(report, ctx, timeout, err)
}
if checkedOut[branch.ref] {
if branch.name != current {
report.add(branch.name, "protected", "upstream was deleted, but the branch is checked out in another worktree")
continue
}
dirty, err := worktreeDirty(ctx, path, ignoredNested)
if err != nil {
return failedReport(report, ctx, timeout, err)
}
if initiallyDirty || dirty {
report.add(branch.name, "protected", "upstream was deleted, but the checked-out branch has worktree changes")
continue
}
if !hasLocalBranch(branches, defaultBranch) {
report.add(branch.name, "protected", "upstream was deleted, but the local default branch does not exist")
continue
}
output, err := gitCombinedContext(ctx, path, "checkout", "--quiet", defaultBranch)
if err != nil {
return failedReport(report, ctx, timeout, gitCommandError("check out default branch "+defaultBranch, output, err))
}
current = defaultBranch
}
output, err := gitCombinedContext(ctx, path, "branch", "-D", "--", branch.name)
if err != nil {
return failedReport(report, ctx, timeout, gitCommandError("delete merged branch "+branch.name, output, err))
}
report.add(branch.name, "deleted", "upstream was deleted and the branch was merged into "+defaultBranch)
}
if opts.CreateMissing {
created, err := createMissingBranches(ctx, path, remote, branches)
if err != nil {
return failedReport(report, ctx, timeout, err)
}
for _, name := range created {
report.add(name, "created", "no local branch; created from "+remote+"/"+name)
}
}
if len(report.Branches) == 0 {
report.Message = "Already up to date."
} else {
report.Message = formatBranchResults(report.Branches)
}
return report, nil
}
// createMissingBranches creates a local branch for each remote branch that has
// no local counterpart. This never touches the worktree, never moves an
// existing branch, and never checks anything out: it only fills in branches
// that exist on the remote so that a workspace which is "in sync" really is.
func createMissingBranches(ctx context.Context, path, remote string, existing []localBranch) ([]string, error) {
remoteBranches, err := remoteTrackingBranches(ctx, path, remote)
if err != nil {
return nil, err
}
have := make(map[string]bool, len(existing))
for _, branch := range existing {
have[branch.name] = true
}
var created []string
for _, remoteBranch := range remoteBranches {
if have[remoteBranch.name] {
continue
}
// --track records the upstream, so later runs report and fast-forward
// this branch like any other.
output, err := gitCombinedContext(ctx, path, "branch", "--track", remoteBranch.name, remote+"/"+remoteBranch.name)
if err != nil {
// A branch created between the listing and here is not a failure.
if exists, existsErr := refExists(ctx, path, "refs/heads/"+remoteBranch.name); existsErr == nil && exists {
have[remoteBranch.name] = true
continue
}
return nil, gitCommandError("create branch "+remoteBranch.name+" from "+remote, output, err)
}
created = append(created, remoteBranch.name)
}
return created, nil
}
// remoteTrackingBranches lists the branches the main remote advertises, as
// resolved into refs/remotes. The remote's symbolic HEAD is not a branch and is
// excluded.
func remoteTrackingBranches(ctx context.Context, path, remote string) ([]localBranch, error) {
prefix := "refs/remotes/" + remote + "/"
output, err := gitBytesContext(ctx, path, "for-each-ref", "--format=%(refname)%00%(refname:short)%00%(objectname)", prefix)
if err != nil {
return nil, fmt.Errorf("list %s branches: %w", remote, err)
}
var branches []localBranch
for _, line := range bytes.Split(bytes.TrimSpace(output), []byte{'\n'}) {
parts := bytes.Split(line, []byte{0})
if len(parts) != 3 {
continue
}
// Derive the name from the full ref rather than %(refname:short):
// git shortens refs to the shortest unambiguous form, so
// refs/remotes/origin/HEAD is reported as plain "origin" and would
// otherwise be mistaken for a real branch called "origin".
name, ok := strings.CutPrefix(string(parts[0]), prefix)
if !ok || name == "" || name == "HEAD" {
continue
}
branches = append(branches, localBranch{ref: string(parts[0]), name: name, oid: string(parts[2])})
}
sort.Slice(branches, func(i, j int) bool { return branches[i].name < branches[j].name })
return branches, nil
}
func (r *repositorySyncReport) add(branch, action, message string) {
r.Branches = append(r.Branches, branchSyncResult{Branch: branch, Action: action, Message: message})
}
func failedReport(report repositorySyncReport, ctx context.Context, timeout time.Duration, err error) (repositorySyncReport, error) {
if ctx.Err() == context.DeadlineExceeded {
err = fmt.Errorf("timed out after %s", timeout)
}
report.Message = err.Error()
return report, err
}
func mainRemote(ctx context.Context, path string) (string, error) {
output, err := gitOutputContext(ctx, path, "remote")
if err != nil {
return "", fmt.Errorf("list remotes: %w", err)
}
remotes := strings.Fields(output)
if len(remotes) == 0 {
return "", errors.New("no remotes")
}
for _, preferred := range []string{"upstream", "github", "origin"} {
for _, remote := range remotes {
if remote == preferred {
return remote, nil
}
}
}
if len(remotes) == 1 {
return remotes[0], nil
}
return "", fmt.Errorf("cannot choose a main remote from %s; name one upstream, github, or origin", strings.Join(remotes, ", "))
}
func localBranches(ctx context.Context, path string) ([]localBranch, error) {
output, err := gitBytesContext(ctx, path, "for-each-ref", "--format=%(refname)%00%(refname:short)%00%(objectname)", "refs/heads")
if err != nil {
return nil, fmt.Errorf("list local branches: %w", err)
}
var branches []localBranch
for _, line := range bytes.Split(bytes.TrimSpace(output), []byte{'\n'}) {
parts := bytes.Split(line, []byte{0})
if len(parts) != 3 {
continue
}
branches = append(branches, localBranch{ref: string(parts[0]), name: string(parts[1]), oid: string(parts[2])})
}
sort.Slice(branches, func(i, j int) bool { return branches[i].name < branches[j].name })
return branches, nil
}
func upstreamForBranch(ctx context.Context, path, remote, branch string) (target string, state upstreamState, err error) {
configuredRemote, _ := gitOutputContext(ctx, path, "config", "--get", "branch."+branch+".remote")
configuredRemote = strings.TrimSpace(configuredRemote)
mergeRef, _ := gitOutputContext(ctx, path, "config", "--get", "branch."+branch+".merge")
mergeRef = strings.TrimSpace(mergeRef)
if configuredRemote == remote && mergeRef != "" {
target = "refs/remotes/" + remote + "/" + strings.TrimPrefix(mergeRef, "refs/heads/")
exists, err := refExists(ctx, path, target)
if err != nil {
return "", upstreamTracked, err
}
if !exists {
return "", upstreamDeleted, nil
}
return target, upstreamTracked, nil
}
target = "refs/remotes/" + remote + "/" + branch
exists, err := refExists(ctx, path, target)
if err != nil {
return "", upstreamTracked, err
}
if !exists {
return "", upstreamUnmatched, nil
}
return target, upstreamTracked, nil
}
func remoteDefaultBranch(ctx context.Context, path, remote string) (ref, branch string, err error) {
head := "refs/remotes/" + remote + "/HEAD"
if output, symbolicErr := gitOutputContext(ctx, path, "symbolic-ref", "--quiet", head); symbolicErr == nil {
ref = strings.TrimSpace(output)
exists, existsErr := refExists(ctx, path, ref)
if existsErr != nil {
return "", "", existsErr
}
if exists {
return ref, strings.TrimPrefix(ref, "refs/remotes/"+remote+"/"), nil
}
} else if ctx.Err() != nil {
return "", "", ctx.Err()
}
// refs/remotes/<remote>/HEAD is a local convenience symref and is absent
// in plenty of real clones. Without it we could not tell which branch a
// deleted-and-merged branch had been merged into, so merged branches were
// kept with a vague "remote default branch is unknown" warning. Ask the
// remote itself instead of giving up.
name, oid, err := remoteHead(ctx, path, remote)
if err != nil {
if ctx.Err() != nil {
return "", "", ctx.Err()
}
// The remote simply did not tell us; fall back to "unknown".
return "", "", nil
}
ref = "refs/remotes/" + remote + "/" + name
if exists, existsErr := refExists(ctx, path, ref); existsErr == nil && exists {
return ref, name, nil
}
// The fetch has just run, so the tracking ref should be there. If it is
// not, the raw object id is still a valid commit for ancestry checks.
if oid != "" {
return oid, name, nil
}
return "", "", nil
}
// remoteHead asks the remote which branch its HEAD points at. It returns the
// short branch name and, when advertised, the object id of that commit.
func remoteHead(ctx context.Context, path, remote string) (branch, oid string, err error) {
output, err := gitBytesContext(ctx, path, "ls-remote", "--symref", remote, "HEAD")
if err != nil {
return "", "", err
}
for _, line := range strings.Split(string(output), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if rest, ok := strings.CutPrefix(line, "ref: "); ok {
name, _, _ := strings.Cut(rest, "\t")
name = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(name), "refs/heads/"))
if name != "" {
return name, oid, nil
}
continue
}
if fields := strings.Fields(line); len(fields) == 2 {
oid = fields[0]
}
}
if branch == "" && oid == "" {
return "", "", errors.New("remote did not advertise HEAD")
}
return "", oid, nil
}
func checkedOutBranches(ctx context.Context, path string) (map[string]bool, error) {
output, err := gitOutputContext(ctx, path, "worktree", "list", "--porcelain")
if err != nil {
return nil, fmt.Errorf("list worktrees: %w", err)
}
branches := make(map[string]bool)
for _, line := range strings.Split(output, "\n") {
if strings.HasPrefix(line, "branch ") {
branches[strings.TrimSpace(strings.TrimPrefix(line, "branch "))] = true
}
}
return branches, nil
}
func worktreeState(ctx context.Context, path string) (string, map[string]bool, error) {
current, err := gitOutputContext(ctx, path, "symbolic-ref", "--quiet", "--short", "HEAD")
if err != nil {
return "", nil, errors.New("cannot determine the checked-out branch after fetch")
}
checkedOut, err := checkedOutBranches(ctx, path)
if err != nil {
return "", nil, err
}
return strings.TrimSpace(current), checkedOut, nil
}
func worktreeDirty(ctx context.Context, path string, ignoredNested []string) (bool, error) {
output, err := gitBytesContext(ctx, path, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none")
if err != nil {
return false, fmt.Errorf("read worktree status: %w", err)
}
for _, change := range parsePorcelain(output) {
if change.code != "??" || !belongsToNestedRepo(change.path, ignoredNested) {
return true, nil
}
}
return false, nil
}
func resolveCommit(ctx context.Context, path, ref string) (string, error) {
output, err := gitOutputContext(ctx, path, "rev-parse", "--verify", ref+"^{commit}")
return strings.TrimSpace(output), err
}
func refExists(ctx context.Context, path, ref string) (bool, error) {
cmd := exec.CommandContext(ctx, "git", "-C", path, "show-ref", "--verify", "--quiet", ref)
err := cmd.Run()
if err == nil {
return true, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return false, nil
}
return false, err
}
func isAncestor(ctx context.Context, path, older, newer string) (bool, error) {
cmd := exec.CommandContext(ctx, "git", "-C", path, "merge-base", "--is-ancestor", older, newer)
err := cmd.Run()
if err == nil {
return true, nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
return false, nil
}
return false, err
}
func hasLocalBranch(branches []localBranch, name string) bool {
for _, branch := range branches {
if branch.name == name {
return true
}
}
return false
}
func gitOutputContext(ctx context.Context, path string, args ...string) (string, error) {
output, err := gitBytesContext(ctx, path, args...)
return string(output), err
}
func gitBytesContext(ctx context.Context, path string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", path}, args...)...)
return cmd.Output()
}
func gitCombinedContext(ctx context.Context, path string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", path}, args...)...)
return cmd.CombinedOutput()
}
func gitCommandError(action string, output []byte, err error) error {
detail := strings.TrimSpace(string(output))
if detail == "" {
return fmt.Errorf("%s: %w", action, err)
}
return fmt.Errorf("%s: %s", action, firstLine(detail))
}
func formatBranchResults(results []branchSyncResult) string {
lines := make([]string, 0, len(results))
for _, result := range results {
lines = append(lines, fmt.Sprintf("%-9s %s: %s", strings.ToUpper(result.Action), result.Branch, result.Message))
}
return strings.Join(lines, "\n")
}
func abbreviateOID(oid string) string {
if len(oid) <= 7 {
return oid
}
return oid[:7]
}
+364
View File
@@ -0,0 +1,364 @@
package main
import (
"io"
"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)
}
// A local branch with neither a configured upstream nor a same-named branch on
// the remote cannot be fast-forwarded. It used to be dropped from the report
// entirely, so the run claimed "Already up to date" while doing nothing at all.
func TestLocalBranchWithoutUpstreamIsReportedNotSilentlyDropped(t *testing.T) {
fixture := makeSyncFixture(t)
git(t, fixture.work, "branch", "stray", "main")
strayBefore := gitText(t, fixture.work, "rev-parse", "stray")
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)
}
assertBranchAction(t, report.Branches, "stray", "untracked")
if report.Message == "Already up to date." {
t.Fatalf("report claimed to be up to date while a branch was left alone: %q", report.Message)
}
// The untracked branch must genuinely be left untouched, even though a
// sibling branch in the same run did move.
if got := gitText(t, fixture.work, "rev-parse", "stray"); got != strayBefore {
t.Fatalf("stray moved: got %s, want %s", got, strayBefore)
}
// A branch it *can* fix is still fixed in the same run.
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)
}
}
func TestRemoteBranchWithoutLocalCounterpartIsCreated(t *testing.T) {
fixture := makeSyncFixture(t)
git(t, fixture.work, "push", "-qu", "origin", "main:refs/heads/brand-new")
report, err := syncRepositoryWith(fixture.work, time.Minute, false, nil, syncOptions{CreateMissing: true})
if err != nil {
t.Fatalf("syncRepositoryWith(): %v\n%s", err, report.Message)
}
assertBranchAction(t, report.Branches, "brand-new", "created")
if got, want := gitText(t, fixture.work, "rev-parse", "brand-new"), gitText(t, fixture.work, "rev-parse", "origin/main"); got != want {
t.Fatalf("created branch is at the wrong commit: got %s, want %s", got, want)
}
if upstream := gitText(t, fixture.work, "rev-parse", "--abbrev-ref", "brand-new@{upstream}"); upstream != "origin/brand-new" {
t.Fatalf("created branch upstream = %q, want origin/brand-new", upstream)
}
// Creating a branch must never move the branch that is checked out.
if got := gitText(t, fixture.work, "branch", "--show-current"); got != "feature" {
t.Fatalf("checked-out branch = %q, want feature", got)
}
}
// Like hub, stack-sync does not create remote-only branches unless asked.
func TestRemoteBranchIsNotCreatedByDefault(t *testing.T) {
fixture := makeSyncFixture(t)
git(t, fixture.work, "push", "-qu", "origin", "main:refs/heads/brand-new")
if _, err := syncRepositoryWith(fixture.work, time.Minute, false, nil, syncOptions{CreateMissing: false}); err != nil {
t.Fatalf("syncRepositoryWith(): %v", err)
}
if err := exec.Command("git", "-C", fixture.work, "show-ref", "--verify", "--quiet", "refs/heads/brand-new").Run(); err == nil {
t.Fatal("brand-new was created even though CreateMissing is false")
}
// The zero value of syncOptions must also mean "do not create".
if _, err := syncRepository(fixture.work, time.Minute, false, nil); err != nil {
t.Fatalf("syncRepository(): %v", err)
}
if err := exec.Command("git", "-C", fixture.work, "show-ref", "--verify", "--quiet", "refs/heads/brand-new").Run(); err == nil {
t.Fatal("brand-new was created by the default syncRepository()")
}
}
// The flag is opt-in. Guard against the default silently flipping back on, which
// previously happened because the value was resolved before flags were parsed.
func TestCreateMissingFlagIsOptIn(t *testing.T) {
opts, err := parseFlags("sync", nil, io.Discard)
if err != nil {
t.Fatalf("parseFlags(): %v", err)
}
if opts.createMissing {
t.Fatal("--create-missing must default to false")
}
opts, err = parseFlags("sync", []string{"--create-missing"}, io.Discard)
if err != nil {
t.Fatalf("parseFlags(): %v", err)
}
if !opts.createMissing {
t.Fatal("--create-missing did not enable branch creation")
}
}
// git shortens refs/remotes/origin/HEAD to the shortest unambiguous form, which
// is plain "origin". Treating that as a branch name produced a bogus
// "create branch origin" failure.
func TestRemoteHeadSymrefIsNotListedAsABranch(t *testing.T) {
fixture := makeSyncFixture(t)
git(t, fixture.work, "fetch", "-q", "origin")
branches, err := remoteTrackingBranches(t.Context(), fixture.work, "origin")
if err != nil {
t.Fatalf("remoteTrackingBranches(): %v", err)
}
var names []string
for _, branch := range branches {
names = append(names, branch.name)
if branch.name == "origin" || branch.name == "HEAD" {
t.Fatalf("remote HEAD symref leaked into the branch list as %q: %v", branch.name, names)
}
}
if len(names) == 0 {
t.Fatal("expected the remote's real branches to be listed")
}
}
// refs/remotes/<remote>/HEAD is a local convenience symref and is missing from
// plenty of real clones. Without resolving the default branch another way, merged
// branches with a deleted upstream were kept with a vague warning.
func TestRemoteDefaultBranchFallsBackToAskingTheRemote(t *testing.T) {
fixture := makeSyncFixture(t)
git(t, fixture.work, "remote", "set-head", "origin", "-d")
if err := exec.Command("git", "-C", fixture.work, "symbolic-ref", "--quiet", "--verify", "refs/remotes/origin/HEAD").Run(); err == nil {
t.Skip("git kept the remote HEAD symref; fallback not exercised")
}
ref, branch, err := remoteDefaultBranch(t.Context(), fixture.work, "origin")
if err != nil {
t.Fatalf("remoteDefaultBranch(): %v", err)
}
if branch != "main" {
t.Fatalf("default branch = %q, want main", branch)
}
if ref == "" {
t.Fatal("remoteDefaultBranch() returned no ref")
}
}
func TestMergedBranchIsDeletedWhenRemoteHeadSymrefIsMissing(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")
git(t, fixture.work, "remote", "set-head", "origin", "-d")
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.Fatalf("merged branch was kept without origin/HEAD: %s", report.Message)
}
assertBranchAction(t, report.Branches, "old", "deleted")
}
+378 -32
View File
@@ -3,7 +3,7 @@ package main
import (
"fmt"
"io"
"os/exec"
"sort"
"strings"
"time"
@@ -41,6 +41,8 @@ type syncDoneMsg struct {
repos []repo
err error
}
type syncStartedMsg struct{ path string }
type syncFinishedMsg struct{ result syncResult }
type tuiModel struct {
root string
@@ -55,6 +57,15 @@ type tuiModel struct {
frame int
status string
lastResult []syncResult
syncEvents <-chan tea.Msg
syncTotal int
syncDone int
syncOK int
syncFailed int
syncSkipped int
syncActive map[string]bool
syncRecent []syncResult
syncStarted time.Time
}
func runTUI(root string, repos []repo, opts options) error {
@@ -64,7 +75,52 @@ func runTUI(root string, repos []repo, opts options) error {
return err
}
// repoGroup is the outcome a repository will have if a run starts right now.
// The selection screen is grouped by it so problems are visible before the user
// commits to anything, rather than only after a run.
type repoGroup int
const (
groupBlocked repoGroup = iota
groupDirty
groupReady
)
func groupOf(r repo) repoGroup {
switch {
case !r.Eligible:
return groupBlocked
case len(r.Dirty) > 0:
return groupDirty
default:
return groupReady
}
}
func groupLabel(g repoGroup) string {
switch g {
case groupBlocked:
return "BLOCKED · not eligible, fix by hand"
case groupDirty:
return "DIRTY · checked-out branch protected"
default:
return "READY"
}
}
// sortReposByOutcome puts the repositories that need attention first, then
// groups equal outcomes together in a stable, name-ordered list.
func sortReposByOutcome(repos []repo) {
sort.SliceStable(repos, func(i, j int) bool {
if gi, gj := groupOf(repos[i]), groupOf(repos[j]); gi != gj {
return gi < gj
}
return repos[i].RelativePath < repos[j].RelativePath
})
}
func newTUIModel(root string, repos []repo, opts options) tuiModel {
sortReposByOutcome(repos)
selected := make(map[string]bool)
for _, r := range repos {
selected[r.Path] = r.Eligible
@@ -92,12 +148,33 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.repos = msg.repos
sortReposByOutcome(m.repos)
m.reconcileSelection()
m.status = "Workspace refreshed"
m.clampViewport()
return m, nil
case syncStartedMsg:
m.syncActive[msg.path] = true
return m, waitSyncEventCmd(m.syncEvents)
case syncFinishedMsg:
delete(m.syncActive, msg.result.Path)
m.syncDone++
switch {
case msg.result.Success:
m.syncOK++
case msg.result.Skipped:
m.syncSkipped++
default:
m.syncFailed++
}
m.syncRecent = append(m.syncRecent, msg.result)
if len(m.syncRecent) > 4 {
m.syncRecent = m.syncRecent[len(m.syncRecent)-4:]
}
return m, waitSyncEventCmd(m.syncEvents)
case syncDoneMsg:
m.mode = modeReady
m.syncEvents = nil
m.lastResult = msg.results
if msg.err != nil {
m.status = "Sync failed: " + msg.err.Error()
@@ -105,18 +182,24 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.repos = msg.repos
m.reconcileSelection()
var ok, failed, skipped int
var ok, unchanged, failed, skipped int
for _, result := range msg.results {
switch {
case result.Success:
ok++
case result.Skipped:
skipped++
default:
case !result.Success:
failed++
case repositoryChanged(result):
ok++
default:
unchanged++
}
}
m.status = fmt.Sprintf("Finished: %d synced, %d failed, %d safety-skipped", ok, failed, skipped)
m.status = fmt.Sprintf("Finished: %d changed, %d unchanged, %d failed, %d safety-skipped", ok, unchanged, failed, skipped)
if path := firstAttentionPath(msg.results); path != "" {
m.focusPath(path)
m.status += "; focused first issue"
}
return m, nil
case tea.KeyMsg:
return m.handleKey(msg)
@@ -137,8 +220,16 @@ func (m tuiModel) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
case "y", "Y":
selected := m.selectedRepos()
m.mode = modeSyncing
m.status = fmt.Sprintf("Syncing %d repositories", len(selected))
return m, tea.Batch(m.syncCmd(selected), tickCmd())
m.status = ""
m.lastResult = nil
m.syncTotal = len(selected)
m.syncDone, m.syncOK, m.syncFailed, m.syncSkipped = 0, 0, 0, 0
m.syncActive = make(map[string]bool)
m.syncRecent = nil
m.syncStarted = time.Now()
events := make(chan tea.Msg, max(4, len(selected)*2+1))
m.syncEvents = events
return m, tea.Batch(m.startSyncCmd(selected, events), tickCmd())
case "n", "N", "esc", "q":
m.mode = modeReady
m.status = "Sync cancelled; nothing changed"
@@ -177,12 +268,14 @@ func (m tuiModel) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
case "r":
m.status = "Refreshing workspace…"
return m, m.scanCmd()
case "f":
if !m.focusNextAttention() {
m.status = "The last run has no failures, skips, warnings, or protected branches"
}
case "s", "enter":
count := len(m.selectedRepos())
if count == 0 {
m.status = "Select at least one eligible repository"
} else if _, err := exec.LookPath("hub"); err != nil {
m.status = "hub is required for sync but was not found on PATH"
} else {
m.mode = modeConfirm
}
@@ -218,6 +311,10 @@ func (m tuiModel) View() string {
b.WriteString("\n")
for i := visible.start; i < visible.end; i++ {
r := m.repos[i]
if i == 0 || groupOf(r) != groupOf(m.repos[i-1]) {
b.WriteString(mutedStyle.Render(fmt.Sprintf(" %s", groupLabel(groupOf(r)))))
b.WriteString("\n")
}
pointer := " "
if i == m.cursor {
pointer = "›"
@@ -231,6 +328,8 @@ func (m tuiModel) View() string {
state := readyStyle.Render("ready")
if !r.Eligible {
state = blockStyle.Render("blocked")
} else if len(r.Dirty) > 0 {
state = titleStyle.Render("ready (dirty)")
}
line := fmt.Sprintf("%s %-3s %-*s %-16s %s", pointer, check, nameWidth, truncateMiddle(r.RelativePath, nameWidth), truncateMiddle(r.Branch, 16), state)
if i == m.cursor {
@@ -250,33 +349,71 @@ func (m tuiModel) View() string {
switch m.mode {
case modeConfirm:
b.WriteString(blockStyle.Bold(true).Render(fmt.Sprintf("Run hub sync in %d selected repositories?", selected)))
b.WriteString(blockStyle.Bold(true).Render(fmt.Sprintf("Synchronize branches in %d selected repositories?", selected)))
b.WriteString("\n")
b.WriteString(mutedStyle.Render("hub may delete local branches it considers merged. y confirm • n/esc cancel"))
b.WriteString(mutedStyle.Render("Dirty checked-out branches are protected; merged branches with deleted upstreams may be removed. y confirm • n/esc cancel"))
case modeSyncing:
spinner := []string{"◐", "◓", "◑", "◒"}[m.frame%4]
b.WriteString(titleStyle.Render(fmt.Sprintf("%s Syncing %d repositories…", spinner, selected)))
b.WriteString("\n")
b.WriteString(mutedStyle.Render("Safety is rechecked immediately before each repository. Please wait."))
b.WriteString(m.syncProgressView(contentWidth))
default:
if m.status != "" {
b.WriteString(m.status)
b.WriteString("\n")
}
b.WriteString(mutedStyle.Render("↑/k ↓/j move • space toggle • a all • n none • s/enter sync • r refresh • q quit"))
if len(m.lastResult) > 0 {
b.WriteString(m.lastRunView(contentWidth))
b.WriteString("\n")
}
b.WriteString(mutedStyle.Render("↑/k ↓/j move • space toggle • a all • n none • s/enter sync • r refresh • f next issue • q quit"))
}
return panel.Width(contentWidth).Render(b.String())
}
type rowRange struct{ start, end int }
func (m tuiModel) visibleRows() rowRange {
available := m.height - 15
if available < 4 {
available = 4
// listHeight is how many screen rows the repository list may occupy, leaving
// room for the header, detail pane, and footer.
func (m tuiModel) listHeight() int {
reserved := 15
if m.mode == modeSyncing || len(m.lastResult) > 0 {
reserved = 20
}
end := min(len(m.repos), m.offset+available)
return rowRange{start: min(m.offset, end), end: end}
return max(4, m.height-reserved)
}
// groupHeaderRows counts the group headings rendered inside [start,end). A
// heading appears wherever the group changes, so scrolling into a new group
// labels it.
func (m tuiModel) groupHeaderRows(start, end int) int {
rows := 0
for i := max(0, start); i < end; i++ {
if i == 0 || groupOf(m.repos[i]) != groupOf(m.repos[i-1]) {
rows++
}
}
return rows
}
// windowRows is the number of screen rows the slice occupies, headings included.
func (m tuiModel) windowRows(start, end int) int {
if start >= end {
return 0
}
return end - start + m.groupHeaderRows(start, end)
}
func (m tuiModel) visibleRows() rowRange {
available := m.listHeight()
start := min(m.offset, len(m.repos))
end := start
for end < len(m.repos) && m.windowRows(start, end+1) <= available {
end++
}
// Reclaiming rows above the offset keeps a short list from scrolling the
// top out of view when the offset no longer reflects a full window.
for start > 0 && m.windowRows(start-1, end) <= available {
start--
}
return rowRange{start: start, end: end}
}
func (m *tuiModel) clampViewport() {
@@ -285,12 +422,17 @@ func (m *tuiModel) clampViewport() {
return
}
m.cursor = max(0, min(m.cursor, len(m.repos)-1))
available := max(4, m.height-15)
if m.cursor < m.offset {
if m.offset > m.cursor {
m.offset = m.cursor
}
if m.cursor >= m.offset+available {
m.offset = m.cursor - available + 1
// Headings share the budget, so the window can be shorter than the row
// count alone suggests. Step forward until the cursor is on screen; the
// loop always terminates because an offset equal to the cursor renders it.
for m.offset < m.cursor {
if m.cursor < m.visibleRows().end {
break
}
m.offset++
}
}
@@ -301,7 +443,11 @@ func (m tuiModel) detailView(width int) string {
r := m.repos[m.cursor]
var lines []string
if r.Eligible {
lines = append(lines, readyStyle.Render("READY")+" "+r.RelativePath+" "+mutedStyle.Render(r.Branch))
state := readyStyle.Render("READY")
if len(r.Dirty) > 0 {
state = titleStyle.Render("READY · CHECKED-OUT BRANCH PROTECTED")
}
lines = append(lines, state+" "+r.RelativePath+" "+mutedStyle.Render(r.Branch))
} else {
lines = append(lines, blockStyle.Render("BLOCKED")+" "+r.RelativePath+" "+r.BlockReason)
}
@@ -370,17 +516,217 @@ func (m tuiModel) scanCmd() tea.Cmd {
}
}
func (m tuiModel) syncCmd(selected []repo) tea.Cmd {
func (m tuiModel) startSyncCmd(selected []repo, events chan tea.Msg) tea.Cmd {
return func() tea.Msg {
results := syncSelected(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true)
go func() {
results := syncSelectedWithProgress(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true, false, m.opts.createMissing, nil, func(event syncProgressEvent) {
if event.Started {
events <- syncStartedMsg{path: event.Path}
} else {
events <- syncFinishedMsg{result: event.Result}
}
})
paths, err := discover(m.root, m.opts.exclusions)
if err != nil {
return syncDoneMsg{results: results, err: err}
events <- syncDoneMsg{results: results, err: err}
return
}
return syncDoneMsg{results: results, repos: inspectAll(m.root, paths, m.opts.jobs)}
events <- syncDoneMsg{results: results, repos: inspectAll(m.root, paths, m.opts.jobs)}
}()
return <-events
}
}
func waitSyncEventCmd(events <-chan tea.Msg) tea.Cmd {
if events == nil {
return nil
}
return func() tea.Msg { return <-events }
}
func (m tuiModel) syncProgressView(width int) string {
spinner := []string{"◐", "◓", "◑", "◒"}[m.frame%4]
phase := "Synchronizing repositories"
if m.syncDone == m.syncTotal && m.syncTotal > 0 {
phase = "Refreshing workspace"
}
elapsed := time.Since(m.syncStarted).Round(time.Second)
if elapsed < 0 {
elapsed = 0
}
barWidth := max(12, min(32, width-30))
filled := 0
if m.syncTotal > 0 {
filled = barWidth * m.syncDone / m.syncTotal
}
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
var lines []string
lines = append(lines, titleStyle.Render(fmt.Sprintf("%s %s", spinner, phase))+mutedStyle.Render(" "+elapsed.String()))
lines = append(lines, readyStyle.Render(bar)+fmt.Sprintf(" %d/%d", m.syncDone, m.syncTotal))
lines = append(lines, fmt.Sprintf("%s %s %s", readyStyle.Render(fmt.Sprintf("%d synced", m.syncOK)), blockStyle.Render(fmt.Sprintf("%d failed", m.syncFailed)), mutedStyle.Render(fmt.Sprintf("%d skipped", m.syncSkipped))))
if len(m.syncActive) > 0 {
active := make([]string, 0, len(m.syncActive))
for path := range m.syncActive {
active = append(active, path)
}
sort.Strings(active)
lines = append(lines, mutedStyle.Render("Active: ")+truncateMiddle(strings.Join(active, ", "), max(10, width-8)))
}
if len(m.syncRecent) > 0 {
recent := m.syncRecent[len(m.syncRecent)-1]
label := readyStyle.Render("Latest ✓")
if recent.Skipped {
label = titleStyle.Render("Latest ↷")
} else if !recent.Success {
label = blockStyle.Render("Latest ✗")
}
lines = append(lines, label+" "+truncateMiddle(recent.Path+resultHeadline(recent), max(10, width-12)))
}
return strings.Join(lines, "\n")
}
func (m tuiModel) lastRunView(width int) string {
var changed, unchanged, failed, skipped, protected, warnings, untracked, deleted, updated, created int
var critical, caution []string
for _, result := range m.lastResult {
switch {
case result.Skipped:
skipped++
critical = append(critical, "↷ "+result.Path+": "+firstLine(result.Message))
case !result.Success:
failed++
critical = append(critical, "✗ "+result.Path+": "+firstLine(result.Message))
case repositoryChanged(result):
changed++
default:
unchanged++
}
for _, branch := range result.Branches {
switch branch.Action {
case "updated":
updated++
case "created":
created++
case "deleted":
deleted++
critical = append(critical, "✗ "+result.Path+" / "+branch.Branch+" was deleted: "+branch.Message)
case "protected":
protected++
caution = append(caution, "◆ "+result.Path+" / "+branch.Branch+": "+branch.Message)
case "warning":
warnings++
caution = append(caution, "! "+result.Path+" / "+branch.Branch+": "+branch.Message)
case "untracked":
untracked++
caution = append(caution, "◦ "+result.Path+" / "+branch.Branch+": "+branch.Message)
}
}
}
details := append(critical, caution...)
header := titleStyle.Render("LAST RUN") + fmt.Sprintf(" %s %s %s %s",
readyStyle.Render(fmt.Sprintf("%d changed", changed)),
mutedStyle.Render(fmt.Sprintf("%d unchanged", unchanged)),
blockStyle.Render(fmt.Sprintf("%d failed", failed)),
mutedStyle.Render(fmt.Sprintf("%d skipped", skipped)))
if updated+deleted+created > 0 {
header += titleStyle.Render(fmt.Sprintf(" %d updated · %d deleted · %d created", updated, deleted, created))
}
if protected+warnings+untracked > 0 {
header += titleStyle.Render(fmt.Sprintf(" %d protected · %d warnings · %d untracked", protected, warnings, untracked))
}
lines := []string{header}
shown := min(3, len(details))
for _, detail := range details[:shown] {
lines = append(lines, " "+truncateMiddle(detail, max(10, width-2)))
}
if len(details) > shown {
lines = append(lines, mutedStyle.Render(fmt.Sprintf(" … %d more; press f to cycle through issues", len(details)-shown)))
}
return strings.Join(lines, "\n")
}
func resultHeadline(result syncResult) string {
if result.Success && len(result.Branches) == 0 {
return " · up to date"
}
if result.Message == "" {
return ""
}
return " · " + firstLine(result.Message)
}
func resultNeedsAttention(result syncResult) bool {
if !result.Success || result.Skipped {
return true
}
for _, branch := range result.Branches {
if branch.Action == "warning" || branch.Action == "protected" || branch.Action == "untracked" {
return true
}
}
return false
}
func firstAttentionPath(results []syncResult) string {
for _, result := range results {
if !result.Success && !result.Skipped {
return result.Path
}
}
for _, result := range results {
if result.Skipped {
return result.Path
}
}
for _, result := range results {
for _, branch := range result.Branches {
if branch.Action == "warning" || branch.Action == "protected" || branch.Action == "untracked" {
return result.Path
}
}
}
return ""
}
func (m *tuiModel) focusPath(path string) bool {
for i, repo := range m.repos {
if repo.RelativePath == path {
m.cursor = i
m.clampViewport()
return true
}
}
return false
}
func (m *tuiModel) focusNextAttention() bool {
var paths []string
for _, result := range m.lastResult {
if resultNeedsAttention(result) {
paths = append(paths, result.Path)
}
}
if len(paths) == 0 {
return false
}
current := ""
if len(m.repos) > 0 {
current = m.repos[m.cursor].RelativePath
}
next := paths[0]
for i, path := range paths {
if path == current {
next = paths[(i+1)%len(paths)]
break
}
}
m.focusPath(next)
m.status = "Focused issue in " + next
return true
}
func tickCmd() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) })
}
+178 -8
View File
@@ -1,8 +1,11 @@
package main
import (
"fmt"
"reflect"
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
)
@@ -10,20 +13,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 = focusRepo(t, m, "blocked")
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,19 +49,48 @@ 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 = 100, 30
m = focusRepo(t, m, "blocked")
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) {
// focusRepo points the cursor at a repository by name. The selection screen is
// grouped by outcome, so list positions are not stable across changes to the
// grouping rules and tests must not depend on them.
func focusRepo(t *testing.T, m tuiModel, name string) tuiModel {
t.Helper()
for i, r := range m.repos {
if r.RelativePath == name {
m.cursor = i
return m
}
}
t.Fatalf("repository %q not found in %+v", name, m.repos)
return m
}
func TestTUIViewExplainsDirtyBranchProtection(t *testing.T) {
m := newTUIModel("/workspace", testTUIRepos(), options{})
m.width, m.height = 100, 30
m = focusRepo(t, m, "dirty")
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"}}
m = focusRepo(t, m, "clean")
view := m.View()
for _, want := range []string{"LAST SYNC", "120ms", "Updated branch main"} {
if !strings.Contains(view, want) {
@@ -66,8 +99,145 @@ func TestTUIViewShowsLastHubOutputForFocusedRepository(t *testing.T) {
}
}
func TestTUISyncProgressShowsLiveCountsAndLatestResult(t *testing.T) {
events := make(chan tea.Msg, 4)
m := newTUIModel("/workspace", testTUIRepos(), options{})
m.width, m.height = 100, 30
m.mode = modeSyncing
m.syncEvents = events
m.syncTotal = 3
m.syncActive = make(map[string]bool)
m.syncStarted = time.Now().Add(-5 * time.Second)
updated, wait := m.Update(syncStartedMsg{path: "clean"})
m = updated.(tuiModel)
if wait == nil || !m.syncActive["clean"] {
t.Fatalf("start event did not register an active repository: %+v", m.syncActive)
}
updated, wait = m.Update(syncFinishedMsg{result: syncResult{Path: "clean", Success: true, Message: "Already up to date."}})
m = updated.(tuiModel)
if wait == nil || m.syncDone != 1 || m.syncOK != 1 || m.syncActive["clean"] {
t.Fatalf("finish event did not update progress: done=%d ok=%d active=%+v", m.syncDone, m.syncOK, m.syncActive)
}
view := m.View()
// The live progress view counts finished repositories, not changed ones.
for _, want := range []string{"1/3", "1 synced", "0 failed", "Latest", "clean", "up to date"} {
if !strings.Contains(view, want) {
t.Fatalf("progress view missing %q:\n%s", want, view)
}
}
}
func TestTUICompletionSummarizesAndFocusesFirstIssue(t *testing.T) {
m := newTUIModel("/workspace", testTUIRepos(), options{})
m.width, m.height = 100, 30
m.mode = modeSyncing
results := []syncResult{
{Path: "clean", Success: true, Branches: []branchSyncResult{{Branch: "main", Action: "protected", Message: "checked out elsewhere"}}},
{Path: "dirty", Message: "fetch origin: authentication failed"},
}
updated, _ := m.Update(syncDoneMsg{results: results, repos: testTUIRepos()})
m = updated.(tuiModel)
if m.mode != modeReady || m.cursor != 1 || !strings.Contains(m.status, "focused first issue") {
t.Fatalf("completion did not focus the first issue: mode=%v cursor=%d status=%q", m.mode, m.cursor, m.status)
}
view := m.View()
// "clean" only had a protected branch, so under the changed/unchanged
// accounting it is unchanged rather than changed.
for _, want := range []string{"LAST RUN", "0 changed", "1 unchanged", "1 failed", "dirty", "authentication failed", "f next"} {
if !strings.Contains(view, want) {
t.Fatalf("completion view missing %q:\n%s", want, view)
}
}
}
func TestTUIFocusNextIssueCyclesThroughAttentionResults(t *testing.T) {
m := newTUIModel("/workspace", testTUIRepos(), options{})
m.lastResult = []syncResult{
{Path: "clean", Success: true, Branches: []branchSyncResult{{Branch: "main", Action: "protected", Message: "checked out elsewhere"}}},
{Path: "dirty", Message: "fetch failed"},
}
m = focusRepo(t, m, "clean")
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}})
m = updated.(tuiModel)
if got := m.repos[m.cursor].RelativePath; got != "dirty" {
t.Fatalf("focused repository = %q, want dirty", got)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'f'}})
m = updated.(tuiModel)
if got := m.repos[m.cursor].RelativePath; got != "clean" {
t.Fatalf("focused repository = %q, want clean after cycling", got)
}
}
func TestTruncateMiddle(t *testing.T) {
if got := truncateMiddle("abcdefghij", 7); got != "abc…hij" {
t.Fatalf("truncateMiddle() = %q", got)
}
}
// Problems must be visible on the selection screen without starting a run.
func TestSelectionScreenGroupsRepositoriesByOutcome(t *testing.T) {
repos := []repo{
{Path: "/w/zeta", RelativePath: "zeta", Branch: "main", Eligible: true},
{Path: "/w/dirty-b", RelativePath: "dirty-b", Branch: "main", Dirty: []string{" M a"}, Eligible: true},
{Path: "/w/blocked", RelativePath: "blocked", BlockReason: "detached HEAD"},
{Path: "/w/alpha", RelativePath: "alpha", Branch: "main", Eligible: true},
{Path: "/w/dirty-a", RelativePath: "dirty-a", Branch: "main", Dirty: []string{" M b"}, Eligible: true},
}
m := newTUIModel("/w", repos, options{})
m.width, m.height = 100, 40
var order []string
for _, r := range m.repos {
order = append(order, r.RelativePath)
}
want := []string{"blocked", "dirty-a", "dirty-b", "alpha", "zeta"}
if !reflect.DeepEqual(order, want) {
t.Fatalf("grouped order = %v, want %v", order, want)
}
view := m.View()
for _, heading := range []string{"BLOCKED ·", "DIRTY ·", "READY"} {
if !strings.Contains(view, heading) {
t.Fatalf("view missing group heading %q:\n%s", heading, view)
}
}
// Headings must precede the repositories they describe.
if strings.Index(view, "DIRTY ·") > strings.Index(view, "dirty-a") {
t.Fatalf("DIRTY heading rendered after its repositories:\n%s", view)
}
}
// Group headings share the list height, so a tall list must still keep the
// cursor on screen and must not loop or stall.
func TestGroupedViewportKeepsCursorVisible(t *testing.T) {
var repos []repo
for i := range 40 {
relative := fmt.Sprintf("repo-%02d", i)
r := repo{Path: "/w/" + relative, RelativePath: relative, Branch: "main", Eligible: true}
if i%5 == 0 {
r.Dirty = []string{" M file"}
}
repos = append(repos, r)
}
m := newTUIModel("/w", repos, options{})
m.width, m.height = 100, 24
for _, cursor := range []int{0, 1, 7, 19, 39} {
m.cursor = cursor
m.clampViewport()
rows := m.visibleRows()
if m.cursor < rows.start || m.cursor >= rows.end {
t.Fatalf("cursor %d outside window %+v", m.cursor, rows)
}
if got := m.windowRows(rows.start, rows.end); got > m.listHeight() {
t.Fatalf("window uses %d rows, only %d available", got, m.listHeight())
}
if !strings.Contains(m.View(), m.repos[m.cursor].RelativePath) {
t.Fatalf("cursor %d (%s) not rendered", m.cursor, m.repos[m.cursor].RelativePath)
}
}
}