Compare commits
2 Commits
main
..
761424b2d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
761424b2d5
|
|||
|
9e7c079f1a
|
@@ -1,3 +1 @@
|
|||||||
/stack-sync
|
/stack-sync
|
||||||
/stack-sync.exe
|
|
||||||
/stack-sync.exe~
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Stack Sync
|
# Stack Sync
|
||||||
|
|
||||||
`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.
|
`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.
|
||||||
|
|
||||||
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.
|
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,38 +10,18 @@ Before a repository is eligible, Stack Sync verifies that it:
|
|||||||
|
|
||||||
- is on a branch (not a detached `HEAD`);
|
- is on a branch (not a detached `HEAD`);
|
||||||
- has at least one remote;
|
- has at least one remote;
|
||||||
- has no merge, rebase, cherry-pick, revert, or bisect in progress.
|
- has no merge, rebase, cherry-pick, revert, or bisect in progress;
|
||||||
|
- has no modified, staged, deleted, conflicted, or untracked files.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
## Build and install
|
||||||
|
|
||||||
`go install` is the simplest cross-platform installation method. From the repository root, run:
|
Requires Go 1.24 or newer, Git, and [hub](https://hub.github.com/).
|
||||||
|
|
||||||
```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
|
```bash
|
||||||
cd stack-sync
|
cd stack-sync
|
||||||
@@ -50,18 +30,6 @@ go build -o stack-sync .
|
|||||||
install -Dm755 stack-sync ~/.local/bin/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
|
## Usage
|
||||||
|
|
||||||
### Interactive TUI
|
### Interactive TUI
|
||||||
@@ -72,15 +40,7 @@ Launch the control deck for the workspace:
|
|||||||
stack-sync tui --root ~/Coding/jwtf
|
stack-sync tui --root ~/Coding/jwtf
|
||||||
```
|
```
|
||||||
|
|
||||||
PowerShell accepts the same options with a Windows path:
|
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
|
|
||||||
stack-sync tui --root C:\Users\you\Coding\jwtf
|
|
||||||
```
|
|
||||||
|
|
||||||
Every eligible repository starts selected. Dirty repositories remain selectable and are labelled `ready (dirty)`; their checked-out branches are protected if an update would affect them. 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
|
### CLI and automation
|
||||||
|
|
||||||
@@ -92,17 +52,13 @@ stack-sync scan --root ~/Coding/jwtf
|
|||||||
stack-sync --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:
|
Review the same plan, confirm it, and sync every eligible repository:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
stack-sync sync --root ~/Coding/jwtf
|
stack-sync sync --root ~/Coding/jwtf
|
||||||
```
|
```
|
||||||
|
|
||||||
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:
|
For automation, suppress the prompt and optionally require the entire workspace to be clean:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
stack-sync sync --root ~/Coding/jwtf --yes --strict
|
stack-sync sync --root ~/Coding/jwtf --yes --strict
|
||||||
@@ -112,14 +68,12 @@ Useful options:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
--jobs 4 maximum concurrent inspections or syncs
|
--jobs 4 maximum concurrent inspections or syncs
|
||||||
--timeout 5m per-repository fetch and sync timeout
|
--timeout 5m per-repository hub sync timeout
|
||||||
--exclude temp skip a directory name anywhere in the tree
|
--exclude temp skip a directory name anywhere in the tree
|
||||||
--exclude Org/old skip a root-relative path
|
--exclude Org/old skip a root-relative path
|
||||||
--json emit structured output
|
--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.
|
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 (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.
|
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.
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
@@ -18,7 +19,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "0.3.1"
|
const version = "0.2.0"
|
||||||
|
|
||||||
var defaultSkippedDirs = map[string]bool{
|
var defaultSkippedDirs = map[string]bool{
|
||||||
".cache": true, ".claude": true, ".codex": true, ".git": true, ".next": true, ".pnpm-store": true,
|
".cache": true, ".claude": true, ".codex": true, ".git": true, ".next": true, ".pnpm-store": true,
|
||||||
@@ -61,13 +62,6 @@ type syncResult struct {
|
|||||||
Skipped bool `json:"skipped"`
|
Skipped bool `json:"skipped"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
Duration string `json:"duration,omitempty"`
|
Duration string `json:"duration,omitempty"`
|
||||||
Branches []branchSyncResult `json:"branches,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type syncProgressEvent struct {
|
|
||||||
Path string
|
|
||||||
Started bool
|
|
||||||
Result syncResult
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -136,17 +130,21 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|||||||
return 0
|
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 {
|
if !opts.json {
|
||||||
printScan(stdout, root, repos)
|
printScan(stdout, root, repos)
|
||||||
}
|
}
|
||||||
eligible := eligibleCount(repos)
|
eligible := eligibleCount(repos)
|
||||||
|
blocked := blockedCount(repos)
|
||||||
if eligible == 0 {
|
if eligible == 0 {
|
||||||
fmt.Fprintln(stderr, "nothing to sync: every repository is blocked")
|
fmt.Fprintln(stderr, "nothing to sync: every repository is blocked")
|
||||||
return 3
|
return 3
|
||||||
}
|
}
|
||||||
strictUnsafe := strictUnsafeCount(repos)
|
if opts.strict && blocked > 0 {
|
||||||
if opts.strict && strictUnsafe > 0 {
|
fmt.Fprintf(stderr, "strict mode: refusing to sync because %d repositories are blocked\n", blocked)
|
||||||
fmt.Fprintf(stderr, "strict mode: refusing to sync because %d repositories are blocked or dirty\n", strictUnsafe)
|
|
||||||
return 3
|
return 3
|
||||||
}
|
}
|
||||||
if !opts.yes {
|
if !opts.yes {
|
||||||
@@ -154,7 +152,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")
|
fmt.Fprintln(stderr, "refusing to sync without confirmation; pass --yes in non-interactive use")
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stdout, "\nSynchronize branches in %d repositories? Merged branches whose upstream was deleted may be removed. [y/N] ", eligible)
|
fmt.Fprintf(stdout, "\nRun hub sync in %d clean repositories? hub may delete local branches it considers merged. [y/N] ", eligible)
|
||||||
answer, _ := bufio.NewReader(stdin).ReadString('\n')
|
answer, _ := bufio.NewReader(stdin).ReadString('\n')
|
||||||
answer = strings.ToLower(strings.TrimSpace(answer))
|
answer = strings.ToLower(strings.TrimSpace(answer))
|
||||||
if answer != "y" && answer != "yes" {
|
if answer != "y" && answer != "yes" {
|
||||||
@@ -163,7 +161,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
results := syncAll(root, repos, opts.jobs, opts.timeout, stdout, opts.json, opts.strict)
|
results := syncAll(root, repos, opts.jobs, opts.timeout, stdout, opts.json)
|
||||||
if opts.json {
|
if opts.json {
|
||||||
writeJSON(stdout, results)
|
writeJSON(stdout, results)
|
||||||
} else {
|
} else {
|
||||||
@@ -192,10 +190,10 @@ 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)")
|
fs.Var(&opts.exclusions, "exclude", "directory name or root-relative path to skip (repeatable)")
|
||||||
if command == "sync" {
|
if command == "sync" {
|
||||||
fs.BoolVar(&opts.yes, "yes", false, "apply without an interactive confirmation")
|
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 or dirty")
|
fs.BoolVar(&opts.strict, "strict", false, "abort the entire run if any repository is blocked")
|
||||||
}
|
}
|
||||||
if command == "sync" || command == "tui" {
|
if command == "sync" || command == "tui" {
|
||||||
fs.DurationVar(&opts.timeout, "timeout", opts.timeout, "timeout for each repository sync")
|
fs.DurationVar(&opts.timeout, "timeout", opts.timeout, "timeout for each hub sync")
|
||||||
}
|
}
|
||||||
fs.Usage = func() { usage(stderr) }
|
fs.Usage = func() { usage(stderr) }
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
@@ -220,7 +218,7 @@ func parseFlags(command string, args []string, stderr io.Writer) (options, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
func usage(w io.Writer) {
|
func usage(w io.Writer) {
|
||||||
fmt.Fprintln(w, `stack-sync safely synchronizes Git branches across a directory tree.
|
fmt.Fprintln(w, `stack-sync safely runs hub sync across a directory tree.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
stack-sync [scan] [options]
|
stack-sync [scan] [options]
|
||||||
@@ -230,14 +228,13 @@ Usage:
|
|||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
scan Discover repositories and report whether each is safe to sync (default)
|
scan Discover repositories and report whether each is safe to sync (default)
|
||||||
sync Scan, confirm, re-check safety, then sync branches in eligible repositories
|
sync Scan, confirm, re-check safety, then run hub sync in eligible repositories
|
||||||
tui Interactively review, select, and sync repositories
|
tui Interactively review, select, and sync repositories
|
||||||
|
|
||||||
Safety:
|
Safety:
|
||||||
Dirty worktrees are reported but remain eligible: their checked-out branch is
|
Repositories with edits, untracked files, detached HEADs, no remotes, or an
|
||||||
protected while safe inactive branches are synchronized. Detached HEADs,
|
in-progress Git operation are never synced. Untracked nested repositories do
|
||||||
repositories without remotes, and in-progress Git operations remain blocked.
|
not falsely dirty their parent. stack-sync never stashes, resets, or commits.
|
||||||
stack-sync never stashes, resets, cleans, or commits.
|
|
||||||
|
|
||||||
Use "stack-sync <command> -h" for command options.`)
|
Use "stack-sync <command> -h" for command options.`)
|
||||||
}
|
}
|
||||||
@@ -339,6 +336,9 @@ func inspect(root, path string, allRepos []string) repo {
|
|||||||
}
|
}
|
||||||
r.Dirty = append(r.Dirty, change.code+" "+change.path)
|
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 == ""
|
r.Eligible = r.BlockReason == ""
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -422,15 +422,11 @@ func gitBytes(path string, args ...string) ([]byte, error) {
|
|||||||
return cmd.Output()
|
return cmd.Output()
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncAll(root string, repos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict bool) []syncResult {
|
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, strict)
|
return syncSelected(root, repos, repos, jobs, timeout, stdout, quiet)
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict bool) []syncResult {
|
func syncSelected(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet bool) []syncResult {
|
||||||
return syncSelectedWithProgress(root, repos, workspaceRepos, jobs, timeout, stdout, quiet, strict, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func syncSelectedWithProgress(root string, repos, workspaceRepos []repo, jobs int, timeout time.Duration, stdout io.Writer, quiet, strict bool, progress func(syncProgressEvent)) []syncResult {
|
|
||||||
type item struct {
|
type item struct {
|
||||||
index int
|
index int
|
||||||
repo repo
|
repo repo
|
||||||
@@ -443,12 +439,6 @@ func syncSelectedWithProgress(root string, repos, workspaceRepos []repo, jobs in
|
|||||||
for _, r := range workspaceRepos {
|
for _, r := range workspaceRepos {
|
||||||
allPaths = append(allPaths, r.Path)
|
allPaths = append(allPaths, r.Path)
|
||||||
}
|
}
|
||||||
record := func(index int, result syncResult) {
|
|
||||||
results[index] = result
|
|
||||||
if progress != nil {
|
|
||||||
progress(syncProgressEvent{Path: result.Path, Result: result})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for range min(jobs, len(repos)) {
|
for range min(jobs, len(repos)) {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
@@ -457,30 +447,31 @@ func syncSelectedWithProgress(root string, repos, workspaceRepos []repo, jobs in
|
|||||||
for next := range work {
|
for next := range work {
|
||||||
r := next.repo
|
r := next.repo
|
||||||
if !r.Eligible {
|
if !r.Eligible {
|
||||||
record(next.index, syncResult{Path: r.RelativePath, Skipped: true, Message: r.BlockReason})
|
results[next.index] = syncResult{Path: r.RelativePath, Skipped: true, Message: r.BlockReason}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Close the scan/apply race: inspect again immediately before mutation.
|
// Close the scan/apply race: inspect again immediately before mutation.
|
||||||
fresh := inspect(root, r.Path, allPaths)
|
fresh := inspect(root, r.Path, allPaths)
|
||||||
if !fresh.Eligible {
|
if !fresh.Eligible {
|
||||||
record(next.index, syncResult{Path: r.RelativePath, Skipped: true, Message: "pre-sync recheck: " + fresh.BlockReason})
|
results[next.index] = syncResult{Path: r.RelativePath, Skipped: true, Message: "pre-sync recheck: " + fresh.BlockReason}
|
||||||
continue
|
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 {
|
if !quiet {
|
||||||
outputMu.Lock()
|
outputMu.Lock()
|
||||||
fmt.Fprintf(stdout, "\nSTART %s (%s)\n", r.RelativePath, r.Branch)
|
fmt.Fprintf(stdout, "\nSTART %s (%s)\n", r.RelativePath, r.Branch)
|
||||||
outputMu.Unlock()
|
outputMu.Unlock()
|
||||||
}
|
}
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
report, err := syncRepository(r.Path, timeout, len(fresh.Dirty) > 0, fresh.NestedRepoEntries)
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
message := report.Message
|
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()
|
||||||
|
}
|
||||||
duration := time.Since(started).Round(time.Millisecond).String()
|
duration := time.Since(started).Round(time.Millisecond).String()
|
||||||
if !quiet {
|
if !quiet {
|
||||||
outputMu.Lock()
|
outputMu.Lock()
|
||||||
@@ -497,7 +488,7 @@ func syncSelectedWithProgress(root string, repos, workspaceRepos []repo, jobs in
|
|||||||
if err != nil && message == "" {
|
if err != nil && message == "" {
|
||||||
message = err.Error()
|
message = err.Error()
|
||||||
}
|
}
|
||||||
record(next.index, syncResult{Path: r.RelativePath, Success: err == nil, Message: message, Duration: duration, Branches: report.Branches})
|
results[next.index] = syncResult{Path: r.RelativePath, Success: err == nil, Message: message, Duration: duration}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -522,8 +513,6 @@ func printScan(w io.Writer, root string, repos []repo) {
|
|||||||
status := "ready"
|
status := "ready"
|
||||||
if !r.Eligible {
|
if !r.Eligible {
|
||||||
status = "BLOCKED: " + r.BlockReason
|
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 {
|
if len(r.NestedRepoEntries) > 0 {
|
||||||
status += fmt.Sprintf("; %d nested repo path(s) ignored", len(r.NestedRepoEntries))
|
status += fmt.Sprintf("; %d nested repo path(s) ignored", len(r.NestedRepoEntries))
|
||||||
@@ -566,15 +555,6 @@ func eligibleCount(repos []repo) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
func blockedCount(repos []repo) int { return len(repos) - eligibleCount(repos) }
|
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) {
|
func writeJSON(w io.Writer, value any) {
|
||||||
enc := json.NewEncoder(w)
|
enc := json.NewEncoder(w)
|
||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
|
|||||||
+56
-16
@@ -67,7 +67,7 @@ func TestNestedRepoDoesNotDirtyParent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDirtyFileIsReportedButRepositoryRemainsEligible(t *testing.T) {
|
func TestDirtyFileBlocksRepository(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
path := filepath.Join(root, "repo")
|
path := filepath.Join(root, "repo")
|
||||||
makeRepo(t, path)
|
makeRepo(t, path)
|
||||||
@@ -76,12 +76,12 @@ func TestDirtyFileIsReportedButRepositoryRemainsEligible(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
got := inspect(root, path, []string{path})
|
got := inspect(root, path, []string{path})
|
||||||
if !got.Eligible || len(got.Dirty) != 1 {
|
if got.Eligible || len(got.Dirty) != 1 {
|
||||||
t.Fatalf("dirty repository was not eligible with its changes reported: %+v", got)
|
t.Fatalf("dirty repository was not blocked: %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUntrackedFileAlongsideNestedRepoIsStillReported(t *testing.T) {
|
func TestUntrackedFileAlongsideNestedRepoStillBlocksParent(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
parent := filepath.Join(root, "parent")
|
parent := filepath.Join(root, "parent")
|
||||||
child := filepath.Join(parent, "child")
|
child := filepath.Join(parent, "child")
|
||||||
@@ -93,8 +93,8 @@ func TestUntrackedFileAlongsideNestedRepoIsStillReported(t *testing.T) {
|
|||||||
|
|
||||||
paths, _ := discover(root, nil)
|
paths, _ := discover(root, nil)
|
||||||
got := inspect(root, parent, paths)
|
got := inspect(root, parent, paths)
|
||||||
if !got.Eligible || len(got.Dirty) != 1 {
|
if got.Eligible || len(got.Dirty) != 1 {
|
||||||
t.Fatalf("untracked file should be reported without blocking parent: %+v", got)
|
t.Fatalf("untracked file should block parent: %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ func TestSkippedDirectoriesAreNotSearched(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStrictSyncRechecksAndSkipsNewlyDirtyRepository(t *testing.T) {
|
func TestSyncRechecksAndSkipsNewlyDirtyRepository(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
path := filepath.Join(root, "repo")
|
path := filepath.Join(root, "repo")
|
||||||
makeRepo(t, path)
|
makeRepo(t, path)
|
||||||
@@ -123,19 +123,59 @@ func TestStrictSyncRechecksAndSkipsNewlyDirtyRepository(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var progress []syncProgressEvent
|
bin := filepath.Join(root, "bin")
|
||||||
results := syncSelectedWithProgress(root, []repo{planned}, []repo{planned}, 1, time.Minute, io.Discard, true, true, func(event syncProgressEvent) {
|
if err := os.Mkdir(bin, 0o755); err != nil {
|
||||||
progress = append(progress, event)
|
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)
|
||||||
if len(results) != 1 || !results[0].Skipped || results[0].Success {
|
if len(results) != 1 || !results[0].Skipped || results[0].Success {
|
||||||
t.Fatalf("syncAll() = %+v", results)
|
t.Fatalf("syncAll() = %+v", results)
|
||||||
}
|
}
|
||||||
if len(progress) != 1 || progress[0].Started || !progress[0].Result.Skipped {
|
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||||
t.Fatalf("progress events = %+v, want one finished skip", progress)
|
t.Fatalf("hub ran despite the newly dirty worktree")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScanReportsDirtyEligibleRepository(t *testing.T) {
|
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) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
path := filepath.Join(root, "repo")
|
path := filepath.Join(root, "repo")
|
||||||
makeRepo(t, path)
|
makeRepo(t, path)
|
||||||
@@ -148,8 +188,8 @@ func TestScanReportsDirtyEligibleRepository(t *testing.T) {
|
|||||||
if code != 0 {
|
if code != 0 {
|
||||||
t.Fatalf("run() code = %d, stderr = %q", code, stderr.String())
|
t.Fatalf("run() code = %d, stderr = %q", code, stderr.String())
|
||||||
}
|
}
|
||||||
if !bytes.Contains(stdout.Bytes(), []byte("dirty worktree (1 changes), checked-out branch protected")) {
|
if !bytes.Contains(stdout.Bytes(), []byte("BLOCKED: dirty worktree")) {
|
||||||
t.Fatalf("scan did not report dirty eligible repository:\n%s", stdout.String())
|
t.Fatalf("scan did not report blocked repository:\n%s", stdout.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,383 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func syncRepository(path string, timeout time.Duration, initiallyDirty bool, ignoredNested []string) (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, gone, 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 !gone {
|
|
||||||
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 len(report.Branches) == 0 {
|
|
||||||
report.Message = "Already up to date."
|
|
||||||
} else {
|
|
||||||
report.Message = formatBranchResults(report.Branches)
|
|
||||||
}
|
|
||||||
return report, 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, gone bool, 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 "", false, err
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
return "", true, nil
|
|
||||||
}
|
|
||||||
return target, false, nil
|
|
||||||
}
|
|
||||||
target = "refs/remotes/" + remote + "/" + branch
|
|
||||||
exists, err := refExists(ctx, path, target)
|
|
||||||
if err != nil {
|
|
||||||
return "", false, err
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
return "", false, nil
|
|
||||||
}
|
|
||||||
return target, false, 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()
|
|
||||||
}
|
|
||||||
return "", "", 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]
|
|
||||||
}
|
|
||||||
-212
@@ -1,212 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type syncFixture struct {
|
|
||||||
root string
|
|
||||||
remote string
|
|
||||||
work string
|
|
||||||
other string
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeSyncFixture(t *testing.T) syncFixture {
|
|
||||||
t.Helper()
|
|
||||||
root := t.TempDir()
|
|
||||||
remote := filepath.Join(root, "remote.git")
|
|
||||||
seed := filepath.Join(root, "seed")
|
|
||||||
work := filepath.Join(root, "work")
|
|
||||||
other := filepath.Join(root, "other")
|
|
||||||
|
|
||||||
if err := os.MkdirAll(seed, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
git(t, root, "init", "--bare", "-q", "--initial-branch=main", remote)
|
|
||||||
git(t, seed, "init", "-q", "--initial-branch=main")
|
|
||||||
configureTestIdentity(t, seed)
|
|
||||||
writeTestFile(t, filepath.Join(seed, "tracked.txt"), "initial\n")
|
|
||||||
git(t, seed, "add", "tracked.txt")
|
|
||||||
git(t, seed, "commit", "-qm", "initial")
|
|
||||||
git(t, seed, "remote", "add", "origin", remote)
|
|
||||||
git(t, seed, "push", "-qu", "origin", "main")
|
|
||||||
|
|
||||||
git(t, root, "clone", "-q", remote, work)
|
|
||||||
git(t, root, "clone", "-q", remote, other)
|
|
||||||
configureTestIdentity(t, work)
|
|
||||||
configureTestIdentity(t, other)
|
|
||||||
|
|
||||||
git(t, work, "checkout", "-qb", "feature")
|
|
||||||
writeTestFile(t, filepath.Join(work, "feature.txt"), "feature\n")
|
|
||||||
git(t, work, "add", "feature.txt")
|
|
||||||
git(t, work, "commit", "-qm", "feature")
|
|
||||||
git(t, work, "push", "-qu", "origin", "feature")
|
|
||||||
|
|
||||||
return syncFixture{root: root, remote: remote, work: work, other: other}
|
|
||||||
}
|
|
||||||
|
|
||||||
func configureTestIdentity(t *testing.T, path string) {
|
|
||||||
t.Helper()
|
|
||||||
git(t, path, "config", "user.name", "Test")
|
|
||||||
git(t, path, "config", "user.email", "test@example.com")
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeTestFile(t *testing.T, path, contents string) {
|
|
||||||
t.Helper()
|
|
||||||
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func advanceRemoteBranch(t *testing.T, fixture syncFixture, branch, filename string) {
|
|
||||||
t.Helper()
|
|
||||||
git(t, fixture.other, "fetch", "-q", "origin")
|
|
||||||
git(t, fixture.other, "checkout", "-qB", branch, "origin/"+branch)
|
|
||||||
writeTestFile(t, filepath.Join(fixture.other, filename), branch+" update\n")
|
|
||||||
git(t, fixture.other, "add", filename)
|
|
||||||
git(t, fixture.other, "commit", "-qm", "advance "+branch)
|
|
||||||
git(t, fixture.other, "push", "-q", "origin", branch)
|
|
||||||
}
|
|
||||||
|
|
||||||
func gitText(t *testing.T, path string, args ...string) string {
|
|
||||||
t.Helper()
|
|
||||||
cmd := append([]string{"-C", path}, args...)
|
|
||||||
output, err := exec.Command("git", cmd...).CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("git %v: %v\n%s", args, err, output)
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDirtyFeatureBranchStillAllowsInactiveMainUpdate(t *testing.T) {
|
|
||||||
fixture := makeSyncFixture(t)
|
|
||||||
writeTestFile(t, filepath.Join(fixture.work, "local-notes.txt"), "unfinished work\n")
|
|
||||||
advanceRemoteBranch(t, fixture, "main", "main-remote.txt")
|
|
||||||
|
|
||||||
beforeFeature := gitText(t, fixture.work, "rev-parse", "feature")
|
|
||||||
report, err := syncRepository(fixture.work, time.Minute, true, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("syncRepository(): %v\n%s", err, report.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got, want := gitText(t, fixture.work, "rev-parse", "main"), gitText(t, fixture.work, "rev-parse", "origin/main"); got != want {
|
|
||||||
t.Fatalf("inactive main was not updated: got %s, want %s", got, want)
|
|
||||||
}
|
|
||||||
if got := gitText(t, fixture.work, "rev-parse", "feature"); got != beforeFeature {
|
|
||||||
t.Fatalf("dirty checked-out feature branch moved: got %s, want %s", got, beforeFeature)
|
|
||||||
}
|
|
||||||
if got := gitText(t, fixture.work, "branch", "--show-current"); got != "feature" {
|
|
||||||
t.Fatalf("checked-out branch = %q, want feature", got)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(fixture.work, "local-notes.txt")); err != nil {
|
|
||||||
t.Fatalf("dirty file was lost: %v", err)
|
|
||||||
}
|
|
||||||
assertBranchAction(t, report.Branches, "main", "updated")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDirtyCheckedOutBranchIsProtectedWhenRemoteAdvances(t *testing.T) {
|
|
||||||
fixture := makeSyncFixture(t)
|
|
||||||
writeTestFile(t, filepath.Join(fixture.work, "feature.txt"), "unfinished feature edit\n")
|
|
||||||
advanceRemoteBranch(t, fixture, "feature", "feature-remote.txt")
|
|
||||||
before := gitText(t, fixture.work, "rev-parse", "feature")
|
|
||||||
|
|
||||||
report, err := syncRepository(fixture.work, time.Minute, true, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("syncRepository(): %v\n%s", err, report.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := gitText(t, fixture.work, "rev-parse", "feature"); got != before {
|
|
||||||
t.Fatalf("dirty checked-out branch moved: got %s, want %s", got, before)
|
|
||||||
}
|
|
||||||
if upstream := gitText(t, fixture.work, "rev-parse", "origin/feature"); upstream == before {
|
|
||||||
t.Fatal("fixture did not fetch the advanced remote feature branch")
|
|
||||||
}
|
|
||||||
contents, err := os.ReadFile(filepath.Join(fixture.work, "feature.txt"))
|
|
||||||
if err != nil || string(contents) != "unfinished feature edit\n" {
|
|
||||||
t.Fatalf("dirty edit changed: contents=%q err=%v", contents, err)
|
|
||||||
}
|
|
||||||
assertBranchAction(t, report.Branches, "feature", "protected")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCleanCheckedOutBranchFastForwards(t *testing.T) {
|
|
||||||
fixture := makeSyncFixture(t)
|
|
||||||
advanceRemoteBranch(t, fixture, "feature", "feature-remote.txt")
|
|
||||||
|
|
||||||
report, err := syncRepository(fixture.work, time.Minute, false, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("syncRepository(): %v\n%s", err, report.Message)
|
|
||||||
}
|
|
||||||
if got, want := gitText(t, fixture.work, "rev-parse", "feature"), gitText(t, fixture.work, "rev-parse", "origin/feature"); got != want {
|
|
||||||
t.Fatalf("checked-out feature was not fast-forwarded: got %s, want %s", got, want)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(filepath.Join(fixture.work, "feature-remote.txt")); err != nil {
|
|
||||||
t.Fatalf("fast-forwarded file is missing from worktree: %v", err)
|
|
||||||
}
|
|
||||||
assertBranchAction(t, report.Branches, "feature", "updated")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDivergentBranchIsWarnedAndLeftUnchanged(t *testing.T) {
|
|
||||||
fixture := makeSyncFixture(t)
|
|
||||||
writeTestFile(t, filepath.Join(fixture.work, "local-only.txt"), "local commit\n")
|
|
||||||
git(t, fixture.work, "add", "local-only.txt")
|
|
||||||
git(t, fixture.work, "commit", "-qm", "local feature work")
|
|
||||||
localOID := gitText(t, fixture.work, "rev-parse", "feature")
|
|
||||||
advanceRemoteBranch(t, fixture, "feature", "remote-only.txt")
|
|
||||||
|
|
||||||
report, err := syncRepository(fixture.work, time.Minute, false, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("syncRepository(): %v\n%s", err, report.Message)
|
|
||||||
}
|
|
||||||
if got := gitText(t, fixture.work, "rev-parse", "feature"); got != localOID {
|
|
||||||
t.Fatalf("divergent feature branch moved: got %s, want %s", got, localOID)
|
|
||||||
}
|
|
||||||
assertBranchAction(t, report.Branches, "feature", "warning")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergedInactiveBranchWithDeletedUpstreamIsDeleted(t *testing.T) {
|
|
||||||
fixture := makeSyncFixture(t)
|
|
||||||
git(t, fixture.work, "branch", "old", "main")
|
|
||||||
git(t, fixture.work, "push", "-qu", "origin", "old")
|
|
||||||
git(t, fixture.work, "push", "-q", "origin", "--delete", "old")
|
|
||||||
|
|
||||||
report, err := syncRepository(fixture.work, time.Minute, false, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("syncRepository(): %v\n%s", err, report.Message)
|
|
||||||
}
|
|
||||||
if err := exec.Command("git", "-C", fixture.work, "show-ref", "--verify", "--quiet", "refs/heads/old").Run(); err == nil {
|
|
||||||
t.Fatal("merged branch with deleted configured upstream still exists")
|
|
||||||
}
|
|
||||||
assertBranchAction(t, report.Branches, "old", "deleted")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBranchCheckedOutInAnotherWorktreeIsProtected(t *testing.T) {
|
|
||||||
fixture := makeSyncFixture(t)
|
|
||||||
mainWorktree := filepath.Join(fixture.root, "main-worktree")
|
|
||||||
git(t, fixture.work, "worktree", "add", "-q", mainWorktree, "main")
|
|
||||||
before := gitText(t, fixture.work, "rev-parse", "main")
|
|
||||||
advanceRemoteBranch(t, fixture, "main", "main-remote.txt")
|
|
||||||
|
|
||||||
report, err := syncRepository(fixture.work, time.Minute, false, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("syncRepository(): %v\n%s", err, report.Message)
|
|
||||||
}
|
|
||||||
if got := gitText(t, fixture.work, "rev-parse", "main"); got != before {
|
|
||||||
t.Fatalf("branch checked out in another worktree moved: got %s, want %s", got, before)
|
|
||||||
}
|
|
||||||
assertBranchAction(t, report.Branches, "main", "protected")
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertBranchAction(t *testing.T, results []branchSyncResult, branch, action string) {
|
|
||||||
t.Helper()
|
|
||||||
for _, result := range results {
|
|
||||||
if result.Branch == branch && result.Action == action {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatalf("missing %s action for branch %s in %+v", action, branch, results)
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sort"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -41,8 +41,6 @@ type syncDoneMsg struct {
|
|||||||
repos []repo
|
repos []repo
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
type syncStartedMsg struct{ path string }
|
|
||||||
type syncFinishedMsg struct{ result syncResult }
|
|
||||||
|
|
||||||
type tuiModel struct {
|
type tuiModel struct {
|
||||||
root string
|
root string
|
||||||
@@ -57,15 +55,6 @@ type tuiModel struct {
|
|||||||
frame int
|
frame int
|
||||||
status string
|
status string
|
||||||
lastResult []syncResult
|
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 {
|
func runTUI(root string, repos []repo, opts options) error {
|
||||||
@@ -107,28 +96,8 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.status = "Workspace refreshed"
|
m.status = "Workspace refreshed"
|
||||||
m.clampViewport()
|
m.clampViewport()
|
||||||
return m, nil
|
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:
|
case syncDoneMsg:
|
||||||
m.mode = modeReady
|
m.mode = modeReady
|
||||||
m.syncEvents = nil
|
|
||||||
m.lastResult = msg.results
|
m.lastResult = msg.results
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.status = "Sync failed: " + msg.err.Error()
|
m.status = "Sync failed: " + msg.err.Error()
|
||||||
@@ -148,10 +117,6 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.status = fmt.Sprintf("Finished: %d synced, %d failed, %d safety-skipped", ok, failed, skipped)
|
m.status = fmt.Sprintf("Finished: %d synced, %d failed, %d safety-skipped", ok, failed, skipped)
|
||||||
if path := firstAttentionPath(msg.results); path != "" {
|
|
||||||
m.focusPath(path)
|
|
||||||
m.status += "; focused first issue"
|
|
||||||
}
|
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
return m.handleKey(msg)
|
return m.handleKey(msg)
|
||||||
@@ -172,16 +137,8 @@ func (m tuiModel) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "y", "Y":
|
case "y", "Y":
|
||||||
selected := m.selectedRepos()
|
selected := m.selectedRepos()
|
||||||
m.mode = modeSyncing
|
m.mode = modeSyncing
|
||||||
m.status = ""
|
m.status = fmt.Sprintf("Syncing %d repositories", len(selected))
|
||||||
m.lastResult = nil
|
return m, tea.Batch(m.syncCmd(selected), tickCmd())
|
||||||
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":
|
case "n", "N", "esc", "q":
|
||||||
m.mode = modeReady
|
m.mode = modeReady
|
||||||
m.status = "Sync cancelled; nothing changed"
|
m.status = "Sync cancelled; nothing changed"
|
||||||
@@ -220,14 +177,12 @@ func (m tuiModel) handleKey(key tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "r":
|
case "r":
|
||||||
m.status = "Refreshing workspace…"
|
m.status = "Refreshing workspace…"
|
||||||
return m, m.scanCmd()
|
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":
|
case "s", "enter":
|
||||||
count := len(m.selectedRepos())
|
count := len(m.selectedRepos())
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
m.status = "Select at least one eligible repository"
|
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 {
|
} else {
|
||||||
m.mode = modeConfirm
|
m.mode = modeConfirm
|
||||||
}
|
}
|
||||||
@@ -276,8 +231,6 @@ func (m tuiModel) View() string {
|
|||||||
state := readyStyle.Render("ready")
|
state := readyStyle.Render("ready")
|
||||||
if !r.Eligible {
|
if !r.Eligible {
|
||||||
state = blockStyle.Render("blocked")
|
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)
|
line := fmt.Sprintf("%s %-3s %-*s %-16s %s", pointer, check, nameWidth, truncateMiddle(r.RelativePath, nameWidth), truncateMiddle(r.Branch, 16), state)
|
||||||
if i == m.cursor {
|
if i == m.cursor {
|
||||||
@@ -297,21 +250,20 @@ func (m tuiModel) View() string {
|
|||||||
|
|
||||||
switch m.mode {
|
switch m.mode {
|
||||||
case modeConfirm:
|
case modeConfirm:
|
||||||
b.WriteString(blockStyle.Bold(true).Render(fmt.Sprintf("Synchronize branches in %d selected repositories?", selected)))
|
b.WriteString(blockStyle.Bold(true).Render(fmt.Sprintf("Run hub sync in %d selected repositories?", selected)))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(mutedStyle.Render("Dirty checked-out branches are protected; merged branches with deleted upstreams may be removed. y confirm • n/esc cancel"))
|
b.WriteString(mutedStyle.Render("hub may delete local branches it considers merged. y confirm • n/esc cancel"))
|
||||||
case modeSyncing:
|
case modeSyncing:
|
||||||
b.WriteString(m.syncProgressView(contentWidth))
|
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."))
|
||||||
default:
|
default:
|
||||||
if m.status != "" {
|
if m.status != "" {
|
||||||
b.WriteString(m.status)
|
b.WriteString(m.status)
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
if len(m.lastResult) > 0 {
|
b.WriteString(mutedStyle.Render("↑/k ↓/j move • space toggle • a all • n none • s/enter sync • r refresh • q quit"))
|
||||||
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())
|
return panel.Width(contentWidth).Render(b.String())
|
||||||
}
|
}
|
||||||
@@ -319,13 +271,7 @@ func (m tuiModel) View() string {
|
|||||||
type rowRange struct{ start, end int }
|
type rowRange struct{ start, end int }
|
||||||
|
|
||||||
func (m tuiModel) visibleRows() rowRange {
|
func (m tuiModel) visibleRows() rowRange {
|
||||||
reserved := 15
|
available := m.height - 15
|
||||||
if m.mode == modeSyncing {
|
|
||||||
reserved = 20
|
|
||||||
} else if len(m.lastResult) > 0 {
|
|
||||||
reserved = 20
|
|
||||||
}
|
|
||||||
available := m.height - reserved
|
|
||||||
if available < 4 {
|
if available < 4 {
|
||||||
available = 4
|
available = 4
|
||||||
}
|
}
|
||||||
@@ -339,11 +285,7 @@ func (m *tuiModel) clampViewport() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.cursor = max(0, min(m.cursor, len(m.repos)-1))
|
m.cursor = max(0, min(m.cursor, len(m.repos)-1))
|
||||||
reserved := 15
|
available := max(4, m.height-15)
|
||||||
if m.mode == modeSyncing || len(m.lastResult) > 0 {
|
|
||||||
reserved = 20
|
|
||||||
}
|
|
||||||
available := max(4, m.height-reserved)
|
|
||||||
if m.cursor < m.offset {
|
if m.cursor < m.offset {
|
||||||
m.offset = m.cursor
|
m.offset = m.cursor
|
||||||
}
|
}
|
||||||
@@ -359,11 +301,7 @@ func (m tuiModel) detailView(width int) string {
|
|||||||
r := m.repos[m.cursor]
|
r := m.repos[m.cursor]
|
||||||
var lines []string
|
var lines []string
|
||||||
if r.Eligible {
|
if r.Eligible {
|
||||||
state := readyStyle.Render("READY")
|
lines = append(lines, readyStyle.Render("READY")+" "+r.RelativePath+" "+mutedStyle.Render(r.Branch))
|
||||||
if len(r.Dirty) > 0 {
|
|
||||||
state = titleStyle.Render("READY · CHECKED-OUT BRANCH PROTECTED")
|
|
||||||
}
|
|
||||||
lines = append(lines, state+" "+r.RelativePath+" "+mutedStyle.Render(r.Branch))
|
|
||||||
} else {
|
} else {
|
||||||
lines = append(lines, blockStyle.Render("BLOCKED")+" "+r.RelativePath+" "+r.BlockReason)
|
lines = append(lines, blockStyle.Render("BLOCKED")+" "+r.RelativePath+" "+r.BlockReason)
|
||||||
}
|
}
|
||||||
@@ -432,198 +370,17 @@ func (m tuiModel) scanCmd() tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m tuiModel) startSyncCmd(selected []repo, events chan tea.Msg) tea.Cmd {
|
func (m tuiModel) syncCmd(selected []repo) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
go func() {
|
results := syncSelected(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true)
|
||||||
results := syncSelectedWithProgress(m.root, selected, m.repos, m.opts.jobs, m.opts.timeout, io.Discard, true, false, 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)
|
paths, err := discover(m.root, m.opts.exclusions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
events <- syncDoneMsg{results: results, err: err}
|
return syncDoneMsg{results: results, err: err}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
events <- syncDoneMsg{results: results, repos: inspectAll(m.root, paths, m.opts.jobs)}
|
return 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 ok, failed, skipped, protected, warnings int
|
|
||||||
var critical, caution []string
|
|
||||||
for _, result := range m.lastResult {
|
|
||||||
switch {
|
|
||||||
case result.Success:
|
|
||||||
ok++
|
|
||||||
case result.Skipped:
|
|
||||||
skipped++
|
|
||||||
critical = append(critical, "↷ "+result.Path+": "+firstLine(result.Message))
|
|
||||||
default:
|
|
||||||
failed++
|
|
||||||
critical = append(critical, "✗ "+result.Path+": "+firstLine(result.Message))
|
|
||||||
}
|
|
||||||
for _, branch := range result.Branches {
|
|
||||||
switch branch.Action {
|
|
||||||
case "protected":
|
|
||||||
protected++
|
|
||||||
caution = append(caution, "◆ "+result.Path+" / "+branch.Branch+": "+branch.Message)
|
|
||||||
case "warning":
|
|
||||||
warnings++
|
|
||||||
caution = append(caution, "! "+result.Path+" / "+branch.Branch+": "+branch.Message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
details := append(critical, caution...)
|
|
||||||
|
|
||||||
header := titleStyle.Render("LAST RUN") + fmt.Sprintf(" %s %s %s", readyStyle.Render(fmt.Sprintf("%d synced", ok)), blockStyle.Render(fmt.Sprintf("%d failed", failed)), mutedStyle.Render(fmt.Sprintf("%d skipped", skipped)))
|
|
||||||
if protected+warnings > 0 {
|
|
||||||
header += titleStyle.Render(fmt.Sprintf(" %d protected · %d warnings", protected, warnings))
|
|
||||||
}
|
|
||||||
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" {
|
|
||||||
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" {
|
|
||||||
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 {
|
func tickCmd() tea.Cmd {
|
||||||
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) })
|
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) })
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-90
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
@@ -11,21 +10,20 @@ import (
|
|||||||
func testTUIRepos() []repo {
|
func testTUIRepos() []repo {
|
||||||
return []repo{
|
return []repo{
|
||||||
{Path: "/workspace/clean", RelativePath: "clean", Branch: "main", Eligible: true},
|
{Path: "/workspace/clean", RelativePath: "clean", Branch: "main", Eligible: true},
|
||||||
{Path: "/workspace/dirty", RelativePath: "dirty", Branch: "develop", Dirty: []string{" M file.txt"}, Eligible: true},
|
{Path: "/workspace/dirty", RelativePath: "dirty", Branch: "develop", BlockReason: "dirty worktree (1 changes)", Dirty: []string{" M file.txt"}},
|
||||||
{Path: "/workspace/blocked", RelativePath: "blocked", BlockReason: "detached HEAD"},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTUIOnlySelectsEligibleRepositories(t *testing.T) {
|
func TestTUIOnlySelectsEligibleRepositories(t *testing.T) {
|
||||||
m := newTUIModel("/workspace", testTUIRepos(), options{})
|
m := newTUIModel("/workspace", testTUIRepos(), options{})
|
||||||
if got := len(m.selectedRepos()); got != 2 {
|
if got := len(m.selectedRepos()); got != 1 {
|
||||||
t.Fatalf("selected repositories = %d, want 2", got)
|
t.Fatalf("selected repositories = %d, want 1", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.cursor = 2
|
m.cursor = 1
|
||||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace})
|
||||||
m = updated.(tuiModel)
|
m = updated.(tuiModel)
|
||||||
if got := len(m.selectedRepos()); got != 2 {
|
if got := len(m.selectedRepos()); got != 1 {
|
||||||
t.Fatalf("blocked repository became selectable; selected = %d", got)
|
t.Fatalf("blocked repository became selectable; selected = %d", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,28 +44,17 @@ func TestTUIRequiresExplicitConfirmation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTUIViewExplainsBlockedRepository(t *testing.T) {
|
func TestTUIViewExplainsBlockedRepository(t *testing.T) {
|
||||||
m := newTUIModel("/workspace", testTUIRepos(), options{})
|
|
||||||
m.width, m.height, m.cursor = 100, 30, 2
|
|
||||||
view := m.View()
|
|
||||||
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 TestTUIViewExplainsDirtyBranchProtection(t *testing.T) {
|
|
||||||
m := newTUIModel("/workspace", testTUIRepos(), options{})
|
m := newTUIModel("/workspace", testTUIRepos(), options{})
|
||||||
m.width, m.height, m.cursor = 100, 30, 1
|
m.width, m.height, m.cursor = 100, 30, 1
|
||||||
view := m.View()
|
view := m.View()
|
||||||
for _, want := range []string{"ready (dirty)", "CHECKED-OUT BRANCH PROTECTED", "file.txt"} {
|
for _, want := range []string{"STACK SYNC", "dirty worktree", "file.txt", "1 blocked"} {
|
||||||
if !strings.Contains(view, want) {
|
if !strings.Contains(view, want) {
|
||||||
t.Fatalf("view missing %q:\n%s", want, view)
|
t.Fatalf("view missing %q:\n%s", want, view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTUIViewShowsLastSyncOutputForFocusedRepository(t *testing.T) {
|
func TestTUIViewShowsLastHubOutputForFocusedRepository(t *testing.T) {
|
||||||
m := newTUIModel("/workspace", testTUIRepos(), options{})
|
m := newTUIModel("/workspace", testTUIRepos(), options{})
|
||||||
m.width, m.height = 100, 30
|
m.width, m.height = 100, 30
|
||||||
m.lastResult = []syncResult{{Path: "clean", Success: true, Message: "Updated branch main", Duration: "120ms"}}
|
m.lastResult = []syncResult{{Path: "clean", Success: true, Message: "Updated branch main", Duration: "120ms"}}
|
||||||
@@ -79,76 +66,6 @@ func TestTUIViewShowsLastSyncOutputForFocusedRepository(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()
|
|
||||||
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()
|
|
||||||
for _, want := range []string{"LAST RUN", "1 synced", "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.cursor = 0
|
|
||||||
|
|
||||||
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) {
|
func TestTruncateMiddle(t *testing.T) {
|
||||||
if got := truncateMiddle("abcdefghij", 7); got != "abc…hij" {
|
if got := truncateMiddle("abcdefghij", 7); got != "abc…hij" {
|
||||||
t.Fatalf("truncateMiddle() = %q", got)
|
t.Fatalf("truncateMiddle() = %q", got)
|
||||||
|
|||||||
Reference in New Issue
Block a user