diff --git a/.demos/progress-arc/README.md b/.demos/progress-arc/README.md new file mode 100644 index 0000000..dd67005 --- /dev/null +++ b/.demos/progress-arc/README.md @@ -0,0 +1,47 @@ +# Progress arc deterministic recordings + +These VHS sources exercise the real Sentei binary against a fixed, local-only +bare repository. Generated binaries, repositories, GIFs, and inspection frames +stay under `/tmp/sentei-vhs-progress-arc` and are never committed. + +```bash +.demos/progress-arc/setup-fixture_test.sh +.demos/progress-arc/setup-fixture.sh +mkdir -p /tmp/sentei-vhs-progress-arc/bin +git worktree add --detach /tmp/sentei-vhs-progress-arc/source-before 778421a +(cd /tmp/sentei-vhs-progress-arc/source-before && go build -o /tmp/sentei-vhs-progress-arc/bin/sentei-before .) +printf '%s\n' 778421a >/tmp/sentei-vhs-progress-arc/bin/sentei-before.sha +go build -o /tmp/sentei-vhs-progress-arc/bin/sentei-after . +git rev-parse --short=12 HEAD >/tmp/sentei-vhs-progress-arc/bin/sentei-after.sha +vhs validate .demos/progress-arc/*.tape +vhs .demos/progress-arc/removal-success.tape +``` + +Each tape first changes to `/`, runs `setup-fixture.sh`, chains setup with +`&&`, then changes to the fixture repository with another fail-closed `&&` +before launching Sentei. A missing fixture can therefore never fall back to +the caller's working directory. Setup recreates +only named children of the exact fixture root. It rejects symlinked, non-owned, +or canonically mismatched fixture paths, including symlinks anywhere beneath +retained output trees, before resetting anything. It isolates HOME/XDG and Git +identity and installs runtime shims. The Git shim denies +network-capable verbs and +rejects absolute worktree paths outside the fixture. Network clients, package +managers, and `gh` fail closed. The `ccc` shim proves presence and initialization, +then exits 17 from `ccc index`, making the integration-failure tape show the +failed index and skipped dependent work. + +The tapes set the PTY to 80 columns by 24 rows and use `SENTEI_MOTION=off`. +Their 960×672 pixel canvas leaves enough physical room for all 24 rows at the +recording font and line height. + +After rendering, verify GIF metadata with `ffprobe`. Decode from frame zero +before selecting representative active, failure, and final frames; seeking +before the GIF input can inspect an incomplete delta frame. For example: + +```bash +ffmpeg -i /tmp/sentei-vhs-progress-arc/outputs/removal-success.gif \ + -ss 9 -frames:v 1 /tmp/sentei-vhs-progress-arc/frames/removal-final.png +``` + +Inspect the resulting PNGs before accepting the recordings. diff --git a/.demos/progress-arc/before-after.tape b/.demos/progress-arc/before-after.tape new file mode 100644 index 0000000..50846d8 --- /dev/null +++ b/.demos/progress-arc/before-after.tape @@ -0,0 +1,42 @@ +Output "/tmp/sentei-vhs-progress-arc/outputs/before-after.gif" +Set Shell "bash" +Set Width 960 +Set Height 672 +Set FontSize 16 +Set TypingSpeed 15ms +Set Framerate 30 +Hide +Type "cd / && /Users/abiswas/code/personal/sentei/progress-arc/.demos/progress-arc/setup-fixture.sh >/dev/null && source /tmp/sentei-vhs-progress-arc/demo.env && cd /tmp/sentei-vhs-progress-arc/repo/demo.git && stty rows 24 cols 80" Enter +Show +Type "printf 'BEFORE '; cat /tmp/sentei-vhs-progress-arc/bin/sentei-before.sha" Enter +Type "cd /tmp/sentei-vhs-progress-arc/repo/demo.git && /tmp/sentei-vhs-progress-arc/bin/sentei-before" Enter +Sleep 3s +Down 2 +Enter +Sleep 3s +Type "a" +Sleep 1s +Enter +Sleep 1s +Type "y" +Sleep 5s +Type "q" +Sleep 1s +Type "q" +Sleep 1s +Hide +Type "cd / && /Users/abiswas/code/personal/sentei/progress-arc/.demos/progress-arc/setup-fixture.sh >/dev/null && source /tmp/sentei-vhs-progress-arc/demo.env && cd /tmp/sentei-vhs-progress-arc/repo/demo.git && stty rows 24 cols 80" Enter +Show +Type "printf 'AFTER '; cat /tmp/sentei-vhs-progress-arc/bin/sentei-after.sha" Enter +Type "cd /tmp/sentei-vhs-progress-arc/repo/demo.git && /tmp/sentei-vhs-progress-arc/bin/sentei-after" Enter +Sleep 3s +Down 2 +Enter +Sleep 3s +Type "a" +Sleep 1s +Enter +Sleep 1s +Type "y" +Sleep 5s +Type "q" diff --git a/.demos/progress-arc/integration-failure.tape b/.demos/progress-arc/integration-failure.tape new file mode 100644 index 0000000..e6933bc --- /dev/null +++ b/.demos/progress-arc/integration-failure.tape @@ -0,0 +1,23 @@ +Output "/tmp/sentei-vhs-progress-arc/outputs/integration-failure.gif" +Set Shell "bash" +Set Width 960 +Set Height 672 +Set FontSize 16 +Set TypingSpeed 20ms +Set Framerate 30 +Hide +Type "cd / && /Users/abiswas/code/personal/sentei/progress-arc/.demos/progress-arc/setup-fixture.sh >/dev/null && source /tmp/sentei-vhs-progress-arc/demo.env && cd /tmp/sentei-vhs-progress-arc/repo/demo.git && stty rows 24 cols 80" Enter +Show +Type "cd /tmp/sentei-vhs-progress-arc/repo/demo.git && /tmp/sentei-vhs-progress-arc/bin/sentei-after" Enter +Sleep 3s +Down +Sleep 1s +Enter +Sleep 3s +Down +Sleep 1s +Space +Sleep 1s +Enter +Sleep 5s +Type "q" diff --git a/.demos/progress-arc/removal-success.tape b/.demos/progress-arc/removal-success.tape new file mode 100644 index 0000000..2edaebc --- /dev/null +++ b/.demos/progress-arc/removal-success.tape @@ -0,0 +1,22 @@ +Output "/tmp/sentei-vhs-progress-arc/outputs/removal-success.gif" +Set Shell "bash" +Set Width 960 +Set Height 672 +Set FontSize 16 +Set TypingSpeed 20ms +Set Framerate 30 +Hide +Type "cd / && /Users/abiswas/code/personal/sentei/progress-arc/.demos/progress-arc/setup-fixture.sh >/dev/null && source /tmp/sentei-vhs-progress-arc/demo.env && cd /tmp/sentei-vhs-progress-arc/repo/demo.git && stty rows 24 cols 80" Enter +Show +Type "cd /tmp/sentei-vhs-progress-arc/repo/demo.git && /tmp/sentei-vhs-progress-arc/bin/sentei-after" Enter +Sleep 3s +Down 2 +Enter +Sleep 1s +Type "a" +Sleep 1s +Enter +Sleep 1s +Type "y" +Sleep 5s +Type "q" diff --git a/.demos/progress-arc/setup-fixture.sh b/.demos/progress-arc/setup-fixture.sh new file mode 100755 index 0000000..0a197bd --- /dev/null +++ b/.demos/progress-arc/setup-fixture.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=/tmp/sentei-vhs-progress-arc +case "$ROOT" in + /tmp/sentei-vhs-progress-arc) ;; + *) printf 'refusing unsafe fixture root: %s\n' "$ROOT" >&2; exit 64 ;; +esac + +refuse() { + printf 'refusing unsafe fixture path: %s (%s)\n' "$1" "$2" >&2 + exit 64 +} + +owner_uid() { + local path=$1 + local uid + if uid=$(stat -f '%u' "$path" 2>/dev/null); then + printf '%s\n' "$uid" + return + fi + stat -c '%u' "$path" +} + +canonical_dir() { + (cd -P "$1" 2>/dev/null && pwd -P) +} + +ROOT_PARENT=$(canonical_dir "$(dirname "$ROOT")") || refuse "$ROOT" 'parent cannot be resolved' +EXPECTED_ROOT="$ROOT_PARENT/$(basename "$ROOT")" + +[[ ! -L "$ROOT" ]] || refuse "$ROOT" 'fixture root is a symlink' +if [[ -e "$ROOT" ]]; then + [[ -d "$ROOT" ]] || refuse "$ROOT" 'fixture root is not a directory' + [[ $(owner_uid "$ROOT") == "$(id -u)" ]] || refuse "$ROOT" 'fixture root has a different owner' + CANONICAL_ROOT=$(canonical_dir "$ROOT") || refuse "$ROOT" 'fixture root cannot be resolved' + [[ "$CANONICAL_ROOT" == "$EXPECTED_ROOT" ]] || refuse "$ROOT" 'fixture root resolves outside its boundary' +else + mkdir "$ROOT" + CANONICAL_ROOT=$(canonical_dir "$ROOT") || refuse "$ROOT" 'created fixture root cannot be resolved' + [[ "$CANONICAL_ROOT" == "$EXPECTED_ROOT" ]] || refuse "$ROOT" 'created fixture root resolves outside its boundary' +fi + +for child in outputs frames; do + retained="$ROOT/$child" + [[ ! -L "$retained" ]] || refuse "$retained" 'retained directory is a symlink' + if [[ -e "$retained" ]]; then + [[ -d "$retained" ]] || refuse "$retained" 'retained path is not a directory' + [[ $(owner_uid "$retained") == "$(id -u)" ]] || refuse "$retained" 'retained directory has a different owner' + retained_canonical=$(canonical_dir "$retained") || refuse "$retained" 'retained directory cannot be resolved' + [[ "$retained_canonical" == "$CANONICAL_ROOT/$child" ]] || refuse "$retained" 'retained directory resolves outside its boundary' + descendant_symlink=$(find "$retained" -type l -print -quit) || refuse "$retained" 'retained directory cannot be inspected' + [[ -z "$descendant_symlink" ]] || refuse "$descendant_symlink" 'symlink beneath retained directory' + fi +done + +for child in home xdg-config xdg-cache xdg-data repo seed shims logs; do + rm -rf "${ROOT:?}/$child" + mkdir -p "$ROOT/$child" +done +mkdir -p "$ROOT/outputs" "$ROOT/frames" + +export HOME="$ROOT/home" +export XDG_CONFIG_HOME="$ROOT/xdg-config" +export XDG_CACHE_HOME="$ROOT/xdg-cache" +export XDG_DATA_HOME="$ROOT/xdg-data" +export GIT_CONFIG_GLOBAL="$ROOT/home/gitconfig" +export GIT_CONFIG_SYSTEM=/dev/null +export GIT_AUTHOR_NAME='Sentei Demo' +export GIT_AUTHOR_EMAIL='sentei-demo@example.invalid' +export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME" +export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL" +export LC_ALL=C +export TZ=UTC +export TERM=xterm-256color + +GIT=/usr/bin/git +"$GIT" config --global init.defaultBranch main +"$GIT" init "$ROOT/seed" >/dev/null +printf '# deterministic sentei demo\n' >"$ROOT/seed/README.md" +"$GIT" -C "$ROOT/seed" add README.md +"$GIT" -C "$ROOT/seed" commit -m 'fixture: initial content' >/dev/null +"$GIT" clone --bare "$ROOT/seed" "$ROOT/repo/demo.git" >/dev/null + +for branch in alpha beta gamma; do + path="$ROOT/repo/worktrees/$branch" + mkdir -p "$(dirname "$path")" + "$GIT" --git-dir="$ROOT/repo/demo.git" worktree add -b "demo/$branch" "$path" main >/dev/null + printf '%s\n' "$branch" >"$path/$branch.txt" + "$GIT" -C "$path" add "$branch.txt" + "$GIT" -C "$path" commit -m "fixture: $branch" >/dev/null +done + +cat >"$ROOT/shims/git" <<'SHIM' +#!/usr/bin/env bash +set -euo pipefail +ROOT=/tmp/sentei-vhs-progress-arc +printf 'git %q ' "$@" >>"$ROOT/logs/git.log" +printf '\n' >>"$ROOT/logs/git.log" +for arg in "$@"; do + case "$arg" in + *://*|git@*) printf 'network git argument denied: %s\n' "$arg" >&2; exit 90 ;; + esac +done +case " ${*} " in + *' fetch '*|*' pull '*|*' push '*|*' clone '*|*' ls-remote '*) + printf 'network-capable git verb denied\n' >&2 + exit 91 + ;; +esac +if [[ " ${*} " == *' worktree remove '* ]]; then + for arg in "$@"; do + case "$arg" in + /*) + case "$arg" in "$ROOT"/*) ;; *) printf 'worktree escape denied: %s\n' "$arg" >&2; exit 92 ;; esac + ;; + esac + done + sleep 0.35 +fi +exec /usr/bin/git "$@" +SHIM + +cat >"$ROOT/shims/ccc" <<'SHIM' +#!/usr/bin/env bash +set -euo pipefail +ROOT=/tmp/sentei-vhs-progress-arc +printf 'ccc %s\n' "$*" >>"$ROOT/logs/ccc.log" +case "${1:-}" in + init|--version) exit 0 ;; + index) printf 'deterministic index failure\n' >&2; exit 17 ;; + *) exit 0 ;; +esac +SHIM + +cat >"$ROOT/shims/python3" <<'SHIM' +#!/usr/bin/env bash +exit 0 +SHIM +cat >"$ROOT/shims/uv" <<'SHIM' +#!/usr/bin/env bash +exit 0 +SHIM + +for denied in gh curl wget brew npm npx pnpm yarn pip pip3 pipx; do + cat >"$ROOT/shims/$denied" <<'SHIM' +#!/usr/bin/env bash +printf 'network or package-manager command denied: %s\n' "$0" >&2 +exit 93 +SHIM +done +chmod +x "$ROOT/shims/"* + +cat >"$ROOT/demo.env" <&2 + exit 1 +} + +make_test_setup() { + local case_dir=$1 + local fixture_root="$case_dir/sentei-vhs-progress-arc" + mkdir -p "$case_dir" + sed "s|$PRODUCTION_ROOT|$fixture_root|g" \ + "$SOURCE_DIR/setup-fixture.sh" >"$case_dir/setup-fixture.sh" + chmod +x "$case_dir/setup-fixture.sh" +} + +assert_rejected() { + local setup=$1 + if "$setup" >"$setup.stdout" 2>"$setup.stderr"; then + fail "unsafe fixture was accepted: $setup" + fi +} + +test_symlinked_root_is_rejected_without_touching_target() { + local case_dir="$SANDBOX/root-symlink" + local external="$case_dir/external" + make_test_setup "$case_dir" + mkdir -p "$external/repo" + printf 'keep\n' >"$external/repo/sentinel" + ln -s "$external" "$case_dir/sentei-vhs-progress-arc" + + assert_rejected "$case_dir/setup-fixture.sh" + + [[ -f "$external/repo/sentinel" ]] || fail 'symlinked root target was mutated' +} + +test_retained_symlinks_are_rejected_without_touching_targets() { + local child + for child in outputs frames; do + local case_dir="$SANDBOX/$child-symlink" + local fixture_root="$case_dir/sentei-vhs-progress-arc" + local external="$case_dir/external" + make_test_setup "$case_dir" + mkdir -p "$fixture_root" "$external" + printf 'keep\n' >"$external/sentinel" + ln -s "$external" "$fixture_root/$child" + + assert_rejected "$case_dir/setup-fixture.sh" + + [[ -f "$external/sentinel" ]] || fail "$child symlink target was deleted" + [[ ! -e "$external/unexpected" ]] || fail "$child symlink target was written" + done +} + +test_symlinks_beneath_retained_directories_are_rejected() { + local case_dir="$SANDBOX/retained-descendant-symlinks" + local fixture_root="$case_dir/sentei-vhs-progress-arc" + local external="$case_dir/external" + make_test_setup "$case_dir" + mkdir -p "$fixture_root/outputs" "$fixture_root/frames" "$external/directory" + printf 'keep-file\n' >"$external/render.gif" + printf 'keep-directory\n' >"$external/directory/sentinel" + ln -s "$external/render.gif" "$fixture_root/outputs/removal-success.gif" + ln -s "$external/directory" "$fixture_root/frames/nested" + + assert_rejected "$case_dir/setup-fixture.sh" + + [[ $(cat "$external/render.gif") == keep-file ]] || fail 'retained file symlink target was overwritten' + [[ -f "$external/directory/sentinel" ]] || fail 'nested retained symlink target was mutated' +} + +test_non_owned_root_is_rejected_before_reset() { + local case_dir="$SANDBOX/non-owned" + local fixture_root="$case_dir/sentei-vhs-progress-arc" + local fake_bin="$case_dir/fake-bin" + make_test_setup "$case_dir" + mkdir -p "$fixture_root/repo" "$fake_bin" + printf 'keep\n' >"$fixture_root/repo/sentinel" + cat >"$fake_bin/id" <<'SHIM' +#!/usr/bin/env bash +if [[ "${1:-}" == -u ]]; then + printf '2147483647\n' +else + exec /usr/bin/id "$@" +fi +SHIM + chmod +x "$fake_bin/id" + + if PATH="$fake_bin:$PATH" "$case_dir/setup-fixture.sh" \ + >"$case_dir/setup-fixture.sh.stdout" 2>"$case_dir/setup-fixture.sh.stderr"; then + fail 'non-owned fixture root was accepted' + fi + + [[ -f "$fixture_root/repo/sentinel" ]] || fail 'non-owned root was reset' +} + +test_valid_fixture_remains_idempotent_and_retains_outputs() { + local case_dir="$SANDBOX/idempotent" + local fixture_root="$case_dir/sentei-vhs-progress-arc" + make_test_setup "$case_dir" + + "$case_dir/setup-fixture.sh" >/dev/null + printf 'retained\n' >"$fixture_root/outputs/existing.gif" + printf 'stale\n' >"$fixture_root/logs/stale.log" + "$case_dir/setup-fixture.sh" >/dev/null + + [[ -f "$fixture_root/outputs/existing.gif" ]] || fail 'outputs were not retained' + [[ ! -e "$fixture_root/logs/stale.log" ]] || fail 'reset child was not recreated' + [[ -f "$fixture_root/seed/README.md" ]] || fail 'fixture was not recreated' +} + +test_symlinked_root_is_rejected_without_touching_target +test_retained_symlinks_are_rejected_without_touching_targets +test_symlinks_beneath_retained_directories_are_rejected +test_non_owned_root_is_rejected_before_reset +test_valid_fixture_remains_idempotent_and_retains_outputs +printf 'setup-fixture safety tests passed\n' diff --git a/.impeccable.md b/.impeccable.md index b03cc4c..92ccae7 100644 --- a/.impeccable.md +++ b/.impeccable.md @@ -61,6 +61,11 @@ Light values preserve the dark theme's prominence ordering by inverting the ligh - **2026-06-12 P3 polish (Wave 3d):** sort arrows describe the displayed values' order — the Age column flips relative to its date sort (▼ = older first on screen); portal scroll hints render only when content scrolls; option footers carry `j/k navigate`; tab lands at the end of prefilled inputs; option views move to `▸` cursors and the GitHub auth status's stray `●` becomes `✦`. (this change) - **2026-06-12 huh verdict — no-go, wins backported:** the spike (branch `spike/huh-clone-input`) converted the clone input to charm.land/huh/v2: visually indistinguishable after a ~15-line theme map, half the code, and two real UX wins — but permanent costs: a second styling system to keep palette-synced, black-box tests slowed ~3.5x by cursor-blink tick sleeps, and integration traps (forms need Init() per entry path, completion rides huh-internal messages, bound values reset on rebuild). Verdict: stay bespoke; the wins ship without the library — all input views render fields persistently (focus moves the accent, never the geometry; `(empty)` retired) and the clone destination preview tracks the URL live. Found en route: v2 textinput renders only the first placeholder rune when Width is unset (`formInputWidth` now set on every text input). (this change) +- **2026-06-12 Progress consolidation:** four parallel progress vocabularies (internal/pipeline, integration.ManagerEvent, worktree.DeletionEvent + bespoke tui messages, and the dead internal/progress Tracker with zero importers) collapsed into one `internal/progress` package owning the status enum, Event, runner helpers, and the exported fold (`Snapshot`/`PhaseState`, formerly tui's buildPhaseDisplays). Dialect types were replaced at the emitters, never adapted; behavior-neutral by criterion (golden tests byte-identical, E2E unchanged). The worktree removal phase name is now declared once (`worktree.RemovalPhaseName`). This is the landing zone for plan declarations and checkpoints. (this change) + +- **2026-06-12 Plan declarations + checkpoints:** the parked "upfront step plans" item shipped as plan-in-stream: flows declare their certain work as a Pending burst plus phase-close markers (`progress.Declare`/`ClosePhase`; the fold's first-mention-creates-step behavior flips from bug to feature), steps may declare checkpoints (`Event.Checkpoint/Of`, monotonic, clamped), and `PhaseState.Settled()` (closed && fully resolved) is the single predicate behind ✦/collapse/green — a settled phase can never reopen. The apply runs worktree-outer so each phase closes exactly once; teardown's plan is scanned at confirm time (no more 0/1 placeholder); removal steps declare start/finish checkpoints so parallel removals move the bar at start; headers keep counting steps while the bar counts checkpoints. The OverallDone/OverallTotal override died with its only consumer. Invariant + property tests pin totals-monotonic, checkpoints-never-regress, and new-step-after-close. (this change) +- **2026-06-12 Completion settle for every run:** the audit proved the timing guarantees were playground-only (real runs cut at 10-86% fill; even held runs settled 0.16s because the floor was event-relative against a ~1.2s spring). The settle is now state-relative and unconditional: after the final event the view advances only once the displayed fill has sat at its target for `progressSettleBeat` (600ms, chosen from the frame-verified tape), observed on the motion clock and spring frames with a 3s hard-timeout probe so the view cannot wedge; playground keeps its entry hold on top; `q` still quits immediately; failed flows get the same truth-hold without the success gradient (green now requires Completed && no failures). (this change) +- **2026-06-12 Truth polish:** elapsed renders only at >= 2s (reserve kept, no reflow); failed phase headers drop the percentage (`✗ name 2/2` — percent is success vocabulary); the apply failure summary has its own title ("Apply finished with errors") and leads with the failed count; skipped steps leave a dim audit trace (`– Install ccc – skipped (already installed)`) in progress and summary, so detection decisions are visible — the ccc incident class now has a surface. (this change) ### Visual Evolution As sentei grows from a cleanup tool to a full worktree manager, the visual identity should evolve with scope. New views (creation flows, repo operations) can introduce richer patterns — step indicators, section headers, progress sequences — while maintaining the core color vocabulary and Charm-style polish. diff --git a/cmd/cli_e2e_test.go b/cmd/cli_e2e_test.go index 0c417f9..904c700 100644 --- a/cmd/cli_e2e_test.go +++ b/cmd/cli_e2e_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/abiswas97/sentei/internal/testtmp" ) func buildBinary(t *testing.T) string { @@ -24,6 +26,7 @@ func TestEcosystemsCLI(t *testing.T) { bin := buildBinary(t) cmd := exec.Command(bin, "ecosystems") + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("sentei ecosystems failed: %v\n%s", err, out) @@ -41,6 +44,7 @@ func TestUnknownCommandCLI(t *testing.T) { bin := buildBinary(t) cmd := exec.Command(bin, "foobar") + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err == nil { t.Fatal("expected non-zero exit for unknown command") @@ -56,6 +60,7 @@ func TestCleanupNonInteractive_MissingMode(t *testing.T) { bin := buildBinary(t) cmd := exec.Command(bin, "cleanup", "--non-interactive", "--force") + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err == nil { t.Fatal("expected non-zero exit for missing --mode") @@ -71,6 +76,7 @@ func TestCleanupNonInteractive_DestructiveWithoutForce(t *testing.T) { bin := buildBinary(t) cmd := exec.Command(bin, "cleanup", "--non-interactive") + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err == nil { t.Fatal("expected non-zero exit for destructive without --force") @@ -101,6 +107,7 @@ func TestCleanupNonInteractive_PrunesStaleWorktrees(t *testing.T) { // Run cleanup — should prune the stale worktree. cmd := exec.Command(bin, "cleanup", "--mode", "safe", "--non-interactive", "--force", bareRepo) + cmd.Env = testtmp.HermeticGitEnv() result, err := cmd.CombinedOutput() if err != nil { t.Fatalf("sentei cleanup failed: %v\n%s", err, result) @@ -122,6 +129,7 @@ func runGitCmdOutput(t *testing.T, dir string, args ...string) string { t.Helper() c := exec.Command("git", args...) c.Dir = dir + c.Env = testtmp.HermeticGitEnv() out, err := c.CombinedOutput() if err != nil { t.Fatalf("git %v failed in %s: %v\n%s", args, dir, err, out) @@ -137,6 +145,7 @@ func TestCleanupNonInteractive_SafeMode(t *testing.T) { setupGitRepo(t, repoDir) cmd := exec.Command(bin, "cleanup", "--mode", "safe", "--non-interactive", "--force", repoDir) + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("sentei cleanup --mode safe --non-interactive --force failed: %v\n%s", err, out) @@ -151,6 +160,7 @@ func TestCleanupNonInteractive_InvalidMode(t *testing.T) { bin := buildBinary(t) cmd := exec.Command(bin, "cleanup", "--mode", "invalid", "--non-interactive", "--force") + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err == nil { t.Fatal("expected non-zero exit for invalid mode") @@ -170,6 +180,7 @@ func setupGitRepo(t *testing.T, dir string) { {"-C", dir, "config", "user.name", "Test"}, } { c := exec.Command("git", args...) + c.Env = testtmp.HermeticGitEnv() if out, err := c.CombinedOutput(); err != nil { t.Fatalf("git %v failed: %v\n%s", args, err, out) } @@ -180,6 +191,7 @@ func TestCloneNonInteractive_MissingURL(t *testing.T) { bin := buildBinary(t) cmd := exec.Command(bin, "clone", "--non-interactive") + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err == nil { t.Fatal("expected non-zero exit for missing --url") @@ -217,6 +229,7 @@ func runGitCmd(t *testing.T, dir string, args ...string) { t.Helper() c := exec.Command("git", args...) c.Dir = dir + c.Env = testtmp.HermeticGitEnv() if out, err := c.CombinedOutput(); err != nil { t.Fatalf("git %v failed in %s: %v\n%s", args, dir, err, out) } @@ -227,6 +240,7 @@ func TestCreateNonInteractive_Success(t *testing.T) { bareRepo := setupBareRepo(t) cmd := exec.Command(bin, "create", "--branch", "feature/e2e-test", "--base", "main", "--non-interactive", bareRepo) + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("sentei create --non-interactive failed: %v\n%s", err, out) @@ -248,6 +262,7 @@ func TestCreateNonInteractive_MissingBranch(t *testing.T) { bin := buildBinary(t) cmd := exec.Command(bin, "create", "--non-interactive") + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err == nil { t.Fatal("expected non-zero exit for missing --branch") @@ -263,6 +278,7 @@ func TestMigrateNonInteractive_DestructiveWithoutForce(t *testing.T) { bin := buildBinary(t) cmd := exec.Command(bin, "migrate", "--non-interactive") + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err == nil { t.Fatal("expected non-zero exit for destructive without --force") @@ -291,6 +307,7 @@ func TestMigrateNonInteractive_Success(t *testing.T) { runGitCmd(t, repoDir, "commit", "-m", "initial commit") cmd := exec.Command(bin, "migrate", "--force", "--non-interactive", repoDir) + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("sentei migrate --force --non-interactive failed: %v\n%s", err, out) @@ -344,6 +361,7 @@ func TestRemoveNonInteractive_MergedBranch(t *testing.T) { bareRepo := setupBareRepoWithMergedBranch(t) cmd := exec.Command(bin, "remove", "--merged", "--force", "--non-interactive", bareRepo) + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("sentei remove --merged --force --non-interactive failed: %v\n%s", err, out) @@ -359,6 +377,7 @@ func TestRemoveNonInteractive_DestructiveWithoutForce(t *testing.T) { bin := buildBinary(t) cmd := exec.Command(bin, "remove", "--merged", "--non-interactive") + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err == nil { t.Fatal("expected non-zero exit for destructive without --force") @@ -374,6 +393,7 @@ func TestRemoveNonInteractive_NoFilters(t *testing.T) { bin := buildBinary(t) cmd := exec.Command(bin, "remove", "--force", "--non-interactive") + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err == nil { t.Fatal("expected non-zero exit for no filters") @@ -390,6 +410,7 @@ func TestRemoveNonInteractive_DryRun(t *testing.T) { bareRepo := setupBareRepoWithMergedBranch(t) cmd := exec.Command(bin, "remove", "--merged", "--dry-run", "--force", "--non-interactive", bareRepo) + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("sentei remove --merged --dry-run failed: %v\n%s", err, out) @@ -414,6 +435,7 @@ func TestIntegrationsCLI(t *testing.T) { bin := buildBinary(t) cmd := exec.Command(bin, "integrations") + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("sentei integrations failed: %v\n%s", err, out) diff --git a/cmd/clone.go b/cmd/clone.go index 730e393..4bf38df 100644 --- a/cmd/clone.go +++ b/cmd/clone.go @@ -6,7 +6,7 @@ import ( "path/filepath" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" ) @@ -39,12 +39,15 @@ func RunClone(args []string) error { } result := repo.Clone(runner, cloneOpts, printCloneEvent) + if err := cloneResultError(result); err != nil { + return err + } fmt.Println() for _, phase := range result.Phases { if phase.HasFailures() { for _, step := range phase.Steps { - if step.Status == pipeline.StepFailed { + if step.Status == progress.StepFailed { fmt.Fprintf(os.Stderr, "%s✗%s %s: %v\n", yellow, nc, step.Name, step.Error) } } @@ -56,19 +59,26 @@ func RunClone(args []string) error { return nil } -func printCloneEvent(e pipeline.Event) { +func cloneResultError(result repo.CloneResult) error { + if result.Err != nil { + return fmt.Errorf("clone failed: %w", result.Err) + } + return nil +} + +func printCloneEvent(e progress.Event) { switch e.Status { - case pipeline.StepRunning: + case progress.StepRunning: fmt.Printf("%s→%s [%s] %s\n", blue, nc, e.Phase, e.Step) - case pipeline.StepDone: + case progress.StepDone: msg := "" if e.Message != "" { msg = fmt.Sprintf(" (%s)", e.Message) } fmt.Printf("%s✓%s [%s] %s%s\n", green, nc, e.Phase, e.Step, msg) - case pipeline.StepFailed: + case progress.StepFailed: fmt.Printf("%s✗%s [%s] %s: %v\n", yellow, nc, e.Phase, e.Step, e.Error) - case pipeline.StepSkipped: + case progress.StepSkipped: msg := "" if e.Message != "" { msg = fmt.Sprintf(" (%s)", e.Message) diff --git a/cmd/clone_run_test.go b/cmd/clone_run_test.go index 97476b4..0f79df3 100644 --- a/cmd/clone_run_test.go +++ b/cmd/clone_run_test.go @@ -1,12 +1,22 @@ package cmd import ( + "errors" "os" "path/filepath" "strings" "testing" + + "github.com/abiswas97/sentei/internal/repo" ) +func TestCloneResultErrorPropagatesContractError(t *testing.T) { + want := errors.New("delivery") + if got := cloneResultError(repo.CloneResult{Err: want}); !errors.Is(got, want) { + t.Fatalf("error = %v", got) + } +} + func TestRunClone_ParseError(t *testing.T) { err := RunClone([]string{"--no-such-flag"}) if err == nil { diff --git a/cmd/create.go b/cmd/create.go index 1d86e8f..3526867 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -9,7 +9,7 @@ import ( "github.com/abiswas97/sentei/internal/config" "github.com/abiswas97/sentei/internal/creator" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" ) @@ -71,35 +71,45 @@ func RunCreate(args []string) error { fmt.Printf("Creating worktree %q from %s...\n", opts.Branch, opts.Base) - result := creator.Run(runner, shell, creatorOpts, func(e pipeline.Event) { + result := creator.Run(runner, shell, creatorOpts, func(e progress.Event) { printCreateEvent(e) }) - if result.HasFailures() { - return fmt.Errorf("create completed with errors") + if err := createResultError(result); err != nil { + return err } fmt.Printf("\n%sWorktree created:%s %s\n", green, nc, result.WorktreePath) return nil } -func printCreateEvent(e pipeline.Event) { +func createResultError(result creator.Result) error { + if result.Err != nil { + return fmt.Errorf("create failed: %w", result.Err) + } + if result.HasFailures() { + return fmt.Errorf("create completed with errors") + } + return nil +} + +func printCreateEvent(e progress.Event) { switch e.Status { - case pipeline.StepRunning: + case progress.StepRunning: fmt.Printf("%s→%s %s: %s\n", blue, nc, e.Phase, e.Step) - case pipeline.StepDone: + case progress.StepDone: msg := "" if e.Message != "" { msg = " — " + e.Message } fmt.Printf("%s✓%s %s%s\n", green, nc, e.Step, msg) - case pipeline.StepFailed: + case progress.StepFailed: msg := "" if e.Error != nil { msg = " — " + e.Error.Error() } fmt.Printf("%s✗%s %s%s\n", yellow, nc, e.Step, msg) - case pipeline.StepSkipped: + case progress.StepSkipped: fmt.Printf(" %s%s (skipped)%s\n", dim, e.Step, nc) } } diff --git a/cmd/create_run_test.go b/cmd/create_run_test.go index 2ca032f..915486f 100644 --- a/cmd/create_run_test.go +++ b/cmd/create_run_test.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "os" "path/filepath" "reflect" @@ -8,9 +9,17 @@ import ( "testing" "github.com/abiswas97/sentei/internal/config" + "github.com/abiswas97/sentei/internal/creator" "github.com/abiswas97/sentei/internal/git" ) +func TestCreateResultErrorPropagatesContractError(t *testing.T) { + want := errors.New("progress delivery failed") + if got := createResultError(creator.Result{Err: want}); !errors.Is(got, want) { + t.Fatalf("createResultError() = %v, want wrapped %v", got, want) + } +} + func TestMatchEcosystems(t *testing.T) { available := []config.EcosystemConfig{ {Name: "pnpm"}, diff --git a/cmd/fixtures_test.go b/cmd/fixtures_test.go index 10c28e9..7213c4d 100644 --- a/cmd/fixtures_test.go +++ b/cmd/fixtures_test.go @@ -5,12 +5,16 @@ import ( "os/exec" "path/filepath" "testing" + + "github.com/abiswas97/sentei/internal/testtmp" ) // mustGit runs a git command in dir, failing the test on error. func mustGit(t *testing.T, dir string, args ...string) { t.Helper() - out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = testtmp.HermeticGitEnv() + out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v failed in %s: %v\n%s", args, dir, err, out) } diff --git a/cmd/migrate.go b/cmd/migrate.go index 4ce4b32..eb62007 100644 --- a/cmd/migrate.go +++ b/cmd/migrate.go @@ -6,7 +6,7 @@ import ( "path/filepath" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" ) @@ -41,12 +41,15 @@ func RunMigrate(args []string) error { } result := repo.Migrate(runner, shell, migrateOpts, printMigrateEvent) + if err := migrateResultError(result); err != nil { + return err + } fmt.Println() for _, phase := range result.Phases { if phase.HasFailures() { for _, step := range phase.Steps { - if step.Status == pipeline.StepFailed { + if step.Status == progress.StepFailed { fmt.Fprintf(os.Stderr, "%s✗%s %s: %v\n", yellow, nc, step.Name, step.Error) } } @@ -77,21 +80,28 @@ func RunMigrate(args []string) error { return nil } -func printMigrateEvent(e pipeline.Event) { +func migrateResultError(result repo.MigrateResult) error { + if result.Err != nil { + return fmt.Errorf("migration failed: %w", result.Err) + } + return nil +} + +func printMigrateEvent(e progress.Event) { switch e.Status { - case pipeline.StepRunning: + case progress.StepRunning: msg := "" if e.Message != "" { msg = fmt.Sprintf(" — %s", e.Message) } fmt.Printf("%s→%s [%s] %s%s\n", blue, nc, e.Phase, e.Step, msg) - case pipeline.StepDone: + case progress.StepDone: msg := "" if e.Message != "" { msg = fmt.Sprintf(" (%s)", e.Message) } fmt.Printf("%s✓%s [%s] %s%s\n", green, nc, e.Phase, e.Step, msg) - case pipeline.StepFailed: + case progress.StepFailed: fmt.Printf("%s✗%s [%s] %s: %v\n", yellow, nc, e.Phase, e.Step, e.Error) } } diff --git a/cmd/migrate_run_test.go b/cmd/migrate_run_test.go index 996f01a..f25405d 100644 --- a/cmd/migrate_run_test.go +++ b/cmd/migrate_run_test.go @@ -1,12 +1,22 @@ package cmd import ( + "errors" "os" "path/filepath" "strings" "testing" + + "github.com/abiswas97/sentei/internal/repo" ) +func TestMigrateResultErrorPropagatesContractError(t *testing.T) { + want := errors.New("delivery") + if got := migrateResultError(repo.MigrateResult{Err: want}); !errors.Is(got, want) { + t.Fatalf("error = %v", got) + } +} + func TestRunMigrate_ParseError(t *testing.T) { err := RunMigrate([]string{"--no-such-flag"}) if err == nil { diff --git a/cmd/output_format_test.go b/cmd/output_format_test.go index 4e3374d..20620b0 100644 --- a/cmd/output_format_test.go +++ b/cmd/output_format_test.go @@ -6,7 +6,7 @@ import ( "testing" "github.com/abiswas97/sentei/internal/cleanup" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" ) func TestPrintEvent(t *testing.T) { @@ -35,15 +35,15 @@ func TestPrintEvent(t *testing.T) { func TestPrintCloneEvent(t *testing.T) { tests := []struct { name string - event pipeline.Event + event progress.Event want []string }{ - {"running", pipeline.Event{Phase: "clone", Step: "fetch", Status: pipeline.StepRunning}, []string{"→", "[clone]", "fetch"}}, - {"done", pipeline.Event{Phase: "clone", Step: "fetch", Status: pipeline.StepDone}, []string{"✓", "[clone]", "fetch"}}, - {"done with message", pipeline.Event{Phase: "clone", Step: "fetch", Status: pipeline.StepDone, Message: "fast"}, []string{"✓", "(fast)"}}, - {"failed", pipeline.Event{Phase: "clone", Step: "fetch", Status: pipeline.StepFailed, Error: errors.New("boom")}, []string{"✗", "boom"}}, - {"skipped", pipeline.Event{Phase: "clone", Step: "fetch", Status: pipeline.StepSkipped}, []string{"⊘", "fetch"}}, - {"skipped with message", pipeline.Event{Phase: "clone", Step: "fetch", Status: pipeline.StepSkipped, Message: "no-op"}, []string{"⊘", "(no-op)"}}, + {"running", progress.Event{Phase: "clone", Step: "fetch", Status: progress.StepRunning}, []string{"→", "[clone]", "fetch"}}, + {"done", progress.Event{Phase: "clone", Step: "fetch", Status: progress.StepDone}, []string{"✓", "[clone]", "fetch"}}, + {"done with message", progress.Event{Phase: "clone", Step: "fetch", Status: progress.StepDone, Message: "fast"}, []string{"✓", "(fast)"}}, + {"failed", progress.Event{Phase: "clone", Step: "fetch", Status: progress.StepFailed, Error: errors.New("boom")}, []string{"✗", "boom"}}, + {"skipped", progress.Event{Phase: "clone", Step: "fetch", Status: progress.StepSkipped}, []string{"⊘", "fetch"}}, + {"skipped with message", progress.Event{Phase: "clone", Step: "fetch", Status: progress.StepSkipped, Message: "no-op"}, []string{"⊘", "(no-op)"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -60,15 +60,15 @@ func TestPrintCloneEvent(t *testing.T) { func TestPrintCreateEvent(t *testing.T) { tests := []struct { name string - event pipeline.Event + event progress.Event want []string }{ - {"running", pipeline.Event{Phase: "create", Step: "branch", Status: pipeline.StepRunning}, []string{"→", "create", "branch"}}, - {"done", pipeline.Event{Step: "branch", Status: pipeline.StepDone}, []string{"✓", "branch"}}, - {"done with message", pipeline.Event{Step: "branch", Status: pipeline.StepDone, Message: "created"}, []string{"✓", "— created"}}, - {"failed", pipeline.Event{Step: "branch", Status: pipeline.StepFailed, Error: errors.New("boom")}, []string{"✗", "— boom"}}, - {"failed without error", pipeline.Event{Step: "branch", Status: pipeline.StepFailed}, []string{"✗", "branch"}}, - {"skipped", pipeline.Event{Step: "branch", Status: pipeline.StepSkipped}, []string{"branch (skipped)"}}, + {"running", progress.Event{Phase: "create", Step: "branch", Status: progress.StepRunning}, []string{"→", "create", "branch"}}, + {"done", progress.Event{Step: "branch", Status: progress.StepDone}, []string{"✓", "branch"}}, + {"done with message", progress.Event{Step: "branch", Status: progress.StepDone, Message: "created"}, []string{"✓", "— created"}}, + {"failed", progress.Event{Step: "branch", Status: progress.StepFailed, Error: errors.New("boom")}, []string{"✗", "— boom"}}, + {"failed without error", progress.Event{Step: "branch", Status: progress.StepFailed}, []string{"✗", "branch"}}, + {"skipped", progress.Event{Step: "branch", Status: progress.StepSkipped}, []string{"branch (skipped)"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -85,14 +85,14 @@ func TestPrintCreateEvent(t *testing.T) { func TestPrintMigrateEvent(t *testing.T) { tests := []struct { name string - event pipeline.Event + event progress.Event want []string }{ - {"running", pipeline.Event{Phase: "backup", Step: "copy", Status: pipeline.StepRunning}, []string{"→", "[backup]", "copy"}}, - {"running with message", pipeline.Event{Phase: "backup", Step: "copy", Status: pipeline.StepRunning, Message: "1.2MB"}, []string{"→", "— 1.2MB"}}, - {"done", pipeline.Event{Phase: "backup", Step: "copy", Status: pipeline.StepDone}, []string{"✓", "[backup]", "copy"}}, - {"done with message", pipeline.Event{Phase: "backup", Step: "copy", Status: pipeline.StepDone, Message: "ok"}, []string{"✓", "(ok)"}}, - {"failed", pipeline.Event{Phase: "backup", Step: "copy", Status: pipeline.StepFailed, Error: errors.New("boom")}, []string{"✗", "boom"}}, + {"running", progress.Event{Phase: "backup", Step: "copy", Status: progress.StepRunning}, []string{"→", "[backup]", "copy"}}, + {"running with message", progress.Event{Phase: "backup", Step: "copy", Status: progress.StepRunning, Message: "1.2MB"}, []string{"→", "— 1.2MB"}}, + {"done", progress.Event{Phase: "backup", Step: "copy", Status: progress.StepDone}, []string{"✓", "[backup]", "copy"}}, + {"done with message", progress.Event{Phase: "backup", Step: "copy", Status: progress.StepDone, Message: "ok"}, []string{"✓", "(ok)"}}, + {"failed", progress.Event{Phase: "backup", Step: "copy", Status: progress.StepFailed, Error: errors.New("boom")}, []string{"✗", "boom"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/remove.go b/cmd/remove.go index 17eb3f3..9768256 100644 --- a/cmd/remove.go +++ b/cmd/remove.go @@ -8,6 +8,7 @@ import ( "time" "github.com/abiswas97/sentei/internal/git" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" "github.com/abiswas97/sentei/internal/worktree" ) @@ -127,8 +128,27 @@ func RunRemove(args []string) error { return err } - progress := make(chan worktree.DeletionEvent, 2*len(filtered)) - result := worktree.DeleteWorktrees(remover, filtered, 5, progress) + targets := make([]worktree.RemovalTarget, len(filtered)) + steps := make([]progress.PlannedStep, len(filtered)) + for i, wt := range filtered { + stepID := progress.StepID(fmt.Sprintf("remove-%d", i)) + targets[i] = worktree.RemovalTarget{Worktree: wt, StepID: stepID} + steps[i] = progress.PlannedStep{ID: stepID, Label: shortBranch(wt.Branch), Checkpoints: 2} + } + execution, err := progress.Start(progress.Plan{Phases: []progress.PlannedPhase{{ + ID: worktree.RemovalPhaseID, Label: worktree.RemovalPhaseName, Steps: steps, + }}}, nil) + if err != nil { + return fmt.Errorf("starting removal progress: %w", err) + } + result := worktree.DeleteWorktrees(execution, worktree.RemovalPhaseID, remover, targets, 5) + if err := execution.Finish("removal command complete"); err != nil { + return fmt.Errorf("finishing removal progress: %w", err) + } + result.Phases = execution.Phases() + if result.Err != nil { + return fmt.Errorf("reporting removal progress: %w", result.Err) + } if err := worktree.PruneWorktrees(runner, repoPath); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to prune worktrees: %v\n", err) diff --git a/cmd/remove_protection_test.go b/cmd/remove_protection_test.go index 13f65a5..f2d06d1 100644 --- a/cmd/remove_protection_test.go +++ b/cmd/remove_protection_test.go @@ -8,6 +8,7 @@ import ( "github.com/abiswas97/sentei/internal/git" "github.com/abiswas97/sentei/internal/repo" + "github.com/abiswas97/sentei/internal/testtmp" ) // TestRemove_DefaultBranchProtectedFromWorktree builds a real sentei bare repo @@ -21,7 +22,9 @@ func TestRemove_DefaultBranchProtectedFromWorktree(t *testing.T) { mustGit := func(dir string, args ...string) { t.Helper() - out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = testtmp.HermeticGitEnv() + out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v: %s", args, out) } diff --git a/docs/superpowers/plans/2026-07-14-progress-arc-correctness-and-live-region.md b/docs/superpowers/plans/2026-07-14-progress-arc-correctness-and-live-region.md new file mode 100644 index 0000000..f358730 --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-progress-arc-correctness-and-live-region.md @@ -0,0 +1,782 @@ +# Progress Arc Correctness and Live Region Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every Sentei progress flow monotonic and terminally honest, render it in a pinned responsive live region, and provide deterministic VHS GIFs of the corrected UX. + +**Architecture:** `internal/progress` gains stable step identity and a plan-owned, concurrency-safe execution contract. Producers prepare exact plans before running and emit through that contract; every TUI progress view folds the resulting event stream through `progress.Snapshot`. `ProgressLayout` projects snapshots into bounded history, focus, and queue regions while the existing Charm primitives supply the bar, help, portal, and purposeful motion. + +**Tech Stack:** Go 1.25, Charm Bubble Tea/Bubbles/Lip Gloss v2, `teatest`, shell hook tests, Charmbracelet VHS 0.11, ffmpeg/ffprobe. + +--- + +## File Map + +| File | Responsibility | +|------|----------------| +| `internal/progress/plan.go` | Stable planned IDs/labels and plan validation | +| `internal/progress/execution.go` | Concurrency-safe declaration, transitions, checkpoints, and terminalization | +| `internal/progress/snapshot.go` | Single fold retaining IDs, labels, messages, and errors | +| `internal/progress/validate.go` | Stream invariant validation | +| `internal/integration/apply.go` | Read-only preparation plus frozen integration execution | +| `internal/creator/plan.go` | Exact creator plan and stable IDs | +| `internal/creator/*.go` | Execute creator work only through the prepared plan | +| `internal/repo/plan.go` | Static create/clone/migrate plans | +| `internal/repo/{create,clone,migrate}.go` | Execute repository plans and skip blocked suffixes | +| `internal/worktree/deleter.go` | Removal checkpoint events through shared execution | +| `internal/tui/removal_progress.go` | Removal event history and Snapshot projection | +| `internal/tui/progress_viewport.go` | Pure bounded history/focus/queue projection | +| `internal/tui/progress_layout.go` | Pinned responsive rendering using the projection | +| `internal/tui/progress_details.go` | Full progress trace/error portal content | +| `internal/tui/motion_preference.go` | Static-motion environment policy | +| `scripts/pre-commit` | Effective author/committer identity guard | +| `scripts/pre-commit_test.sh` | Hook identity regression tests | +| `.demos/progress-arc/*.tape` | Deterministic VHS recordings | + +## Task 1: Make Plans Own Progress Truth + +**Files:** +- Modify: `internal/progress/plan.go` +- Create: `internal/progress/execution.go` +- Modify: `internal/progress/progress.go` +- Modify: `internal/progress/snapshot.go` +- Modify: `internal/progress/validate.go` +- Test: `internal/progress/execution_test.go` +- Test: `internal/progress/snapshot_test.go` +- Test: `internal/progress/plan_test.go` + +- [ ] **Step 1.1: Write failing stable-identity and execution-contract tests** + +Add tests that express the final API and invariants: + +```go +func TestExecution_FinishSettlesDistinctStepsWithEqualLabels(t *testing.T) { + plan := Plan{Phases: []PlannedPhase{{ID: "integrations", Label: "Integrations", Steps: []PlannedStep{ + {ID: "ccc.copy-index", Label: "Copy index from main"}, + {ID: "crg.copy-index", Label: "Copy index from main"}, + }}}} + var events []Event + x, err := Start(plan, func(ev Event) { events = append(events, ev) }) + if err != nil { t.Fatal(err) } + if _, err := x.Done("integrations", "ccc.copy-index", ""); err != nil { t.Fatal(err) } + if err := x.Finish("blocked by earlier failure"); err != nil { t.Fatal(err) } + + states := Snapshot(events) + if len(states) != 1 || len(states[0].Steps) != 2 { t.Fatalf("states = %#v", states) } + if !states[0].Settled() { t.Fatalf("phase did not settle: %#v", states[0]) } + if states[0].Steps[1].Status != StepSkipped { t.Fatalf("second step = %#v", states[0].Steps[1]) } +} + +func TestExecution_RejectsUndeclaredAndTerminalMutation(t *testing.T) { + x, err := Start(Plan{Phases: []PlannedPhase{{ID: "p", Label: "Phase", Steps: []PlannedStep{{ID: "s", Label: "Step"}}}}}, func(Event) {}) + if err != nil { t.Fatal(err) } + if _, err := x.Done("p", "missing", ""); err == nil { t.Fatal("undeclared step accepted") } + if _, err := x.Done("p", "s", ""); err != nil { t.Fatal(err) } + if _, err := x.Fail("p", "s", errors.New("late")); err == nil { t.Fatal("terminal mutation accepted") } +} + +func TestExecution_CheckpointsAreMonotonicUnderConcurrency(t *testing.T) { + // Start one two-checkpoint step, race duplicate checkpoint reports, finish it, + // then assert Snapshot reports exactly 2/2 and ValidateStream succeeds. +} +``` + +- [ ] **Step 1.2: Run the focused tests and confirm RED** + +Run: + +```bash +go test ./internal/progress -run 'TestExecution|TestSnapshot_PreservesErrorAndLabel' -count=1 -v +``` + +Expected: compile failure because `Start`, stable phase/step IDs, labels, and `StepState.Error` do not exist. + +- [ ] **Step 1.3: Implement the stable plan and execution API** + +Use these public shapes: + +```go +type PhaseID string +type StepID string + +type PlannedPhase struct { + ID PhaseID + Label string + Steps []PlannedStep +} + +type PlannedStep struct { + ID StepID + Label string + Checkpoints int +} + +type Event struct { + Phase PhaseID + PhaseLabel string + Step StepID + StepLabel string + Status StepStatus + Checkpoint int + Of int + Close bool + Message string + Error error +} + +type StepResult struct { + ID StepID + Name string + Status StepStatus + Message string + Error error +} + +type Execution struct { + mu sync.Mutex + emit func(Event) + phases map[PhaseID]*executionPhase + order []PhaseID +} + +func Start(plan Plan, emit func(Event)) (*Execution, error) +func (x *Execution) Running(phase PhaseID, step StepID, checkpoint int, message string) error +func (x *Execution) Done(phase PhaseID, step StepID, message string) (StepResult, error) +func (x *Execution) Fail(phase PhaseID, step StepID, err error) (StepResult, error) +func (x *Execution) Skip(phase PhaseID, step StepID, reason string) (StepResult, error) +func (x *Execution) Run(phase PhaseID, step StepID, fn StepFunc) (StepResult, error) +func (x *Execution) SkipPending(phase PhaseID, reason string) error +func (x *Execution) Finish(reason string) error +``` + +`Start` validates nonempty unique IDs, normalizes checkpoint counts to one, +emits the complete Pending declaration prefix, then emits a close marker for +every phase. Transition methods reject unknown IDs, terminal mutations, +checkpoint regression, and checkpoint overflow. `Run` marks Running, executes +the function without holding the mutex, then resolves Done or Failed. `Finish` +resolves every nonterminal step as skipped. + +Update `Snapshot` to fold by IDs while preserving declaration labels and errors: + +```go +type StepState struct { + ID StepID + Name string + Status StepStatus + Message string + Error error + Reached int + Declared int +} + +type PhaseState struct { + ID PhaseID + Name string + Steps []StepState + Total int + Done int + Failed int + Closed bool +} +``` + +Strengthen `ValidateStream` to require declarations as one complete prefix, +reject undeclared work and terminal mutation, and retain checkpoint checks. + +- [ ] **Step 1.4: Run RED tests to GREEN, then the package suite** + +```bash +go test ./internal/progress -count=1 +go test -race ./internal/progress -count=1 +``` + +Expected: both commands exit 0. + +- [ ] **Step 1.5: Convert existing progress helpers mechanically and commit** + +Update `RunStep` and `PhaseRecorder` to accept stable IDs plus labels, preserving +their current behavior until producer-specific tasks replace them. Keep a +temporary `Declare`/`ClosePhase` adapter for unconverted producers; remove it in +Task 5 after the last producer moves to `Execution`. Run: + +```bash +go test ./internal/progress ./internal/creator ./internal/integration ./internal/repo ./internal/tui +``` + +Commit: + +```bash +git add internal/progress +git commit -m "feat(progress): make plans own terminal state" +``` + +## Task 2: Prepare and Freeze Integration Applies + +**Files:** +- Create: `internal/integration/apply.go` +- Modify: `internal/integration/manager.go` +- Delete: `internal/integration/plan.go` +- Modify: `internal/tui/integration_list.go` +- Modify: `internal/tui/migrate_integrations.go` +- Modify: `internal/tui/integration_progress.go` +- Test: `internal/integration/apply_test.go` +- Modify: `internal/tui/integration_plan_test.go` + +- [ ] **Step 2.1: Write failing preparation and failure-terminalization tests** + +Create table tests for these exact cases: + +```go +func TestPrepareApply_MissingToolPlansPrerequisiteOnce(t *testing.T) { + // Two worktrees, one missing global tool, all dependencies present. + // Assert plan has one Prerequisites install step and one setup step per worktree. +} + +func TestPreparedApply_InstallFailureSkipsEverySetup(t *testing.T) { + // Run the frozen plan with an install error. + // Assert install failed, all setup steps are skipped with "blocked by Install ...", + // ValidateStream succeeds, and every Snapshot phase is settled. +} + +func TestPreparedApply_DeclarationTotalIsFixedAcrossPrefixes(t *testing.T) { + // Two integrations x two worktrees with mixed detected/missing tools. + // After the declaration prefix, assert CheckpointProgress total never changes. +} +``` + +- [ ] **Step 2.2: Run the tests and confirm RED** + +```bash +go test ./internal/integration -run 'TestPrepareApply|TestPreparedApply' -count=1 -v +``` + +Expected: compile failure because `PrepareApply` and `PreparedApply.Run` do not exist. + +- [ ] **Step 2.3: Implement one read-only preparation pass** + +Use this contract: + +```go +type PreparedApply struct { + Plan progress.Plan + operations []applyOperation +} + +func PrepareApply(shell git.ShellRunner, repoPath, mainWT string, + toEnable, toDisable []Integration, wtPaths []string) (PreparedApply, error) + +func (p PreparedApply) Run(shell git.ShellRunner, emit func(progress.Event)) []progress.Phase +``` + +Probe each enabled tool and dependency once using the first worktree as the +command directory. Put missing dependency installs and tool installs in one +`Prerequisites` phase. Put setup/teardown/removal operations in worktree phases. +Store commands, working directories, IDs, labels, and dependency edges in +`operations`; `Run` executes exactly those decisions and never calls detection. +When a prerequisite fails, skip its dependent install/setup operations. Continue +independent teardown removals after teardown-command failure. + +In the TUI goroutine, prepare before setting determinate events. Represent the +probe interval using the existing indeterminate motion vocabulary and the copy +`Preparing plan...`; only call `progress.Start` after preparation succeeds. +Replace `buildIntegrationPhases` with: + +```go +func (m Model) buildIntegrationPhases() []progress.PhaseState { + states := progress.Snapshot(m.integ.events) + for i := range states { states[i].Name = filepath.Base(states[i].Name) } + return states +} +``` + +Use the preserved `StepState.Error` only while rendering; never recompute counts. + +- [ ] **Step 2.4: Verify focused and TUI integration tests** + +```bash +go test ./internal/integration ./internal/tui -run 'Integration|PreparedApply|PrepareApply' -count=1 +go test -race ./internal/integration ./internal/tui -count=1 +``` + +Expected: exit 0 with no phase reopening or pending-on-failure assertions. + +- [ ] **Step 2.5: Commit** + +```bash +git add internal/integration internal/tui/integration_list.go internal/tui/migrate_integrations.go internal/tui/integration_progress.go internal/tui/integration_plan_test.go +git commit -m "fix(integration): freeze exact apply plans" +``` + +## Task 3: Convert Creator to One Exact Plan + +**Files:** +- Create: `internal/creator/plan.go` +- Modify: `internal/creator/creator.go` +- Modify: `internal/creator/setup.go` +- Modify: `internal/creator/deps.go` +- Modify: `internal/creator/integrations.go` +- Test: `internal/creator/plan_test.go` +- Modify: `internal/creator/setup_test.go` +- Modify: `internal/creator/integrations_test.go` + +- [ ] **Step 3.1: Write failing creator contract tests** + +Add fixtures asserting: + +```go +func TestRun_CreateWorktreeFailureSettlesWholePlan(t *testing.T) { + // Enable merge, env copy, dependencies, and integrations; fail worktree add. + // Assert all later steps are skipped with a blocking reason and every phase settles. +} + +func TestRun_DuplicateIntegrationLabelsRemainDistinct(t *testing.T) { + // Two integrations both copy an index. Assert two stable IDs and two rendered rows. +} + +func TestRun_NoSetupCommandEmitsSkipped(t *testing.T) { + // Empty setup command must produce a StepSkipped event, not only a result entry. +} +``` + +- [ ] **Step 3.2: Confirm RED** + +```bash +go test ./internal/creator -run 'SettlesWholePlan|DuplicateIntegrationLabels|NoSetupCommand' -count=1 -v +``` + +Expected: assertions fail because early returns leave declared work unresolved and duplicate labels fold together. + +- [ ] **Step 3.3: Build and execute the creator plan** + +Create stable IDs with phase prefixes, for example: + +```go +const ( + phaseSetup progress.PhaseID = "setup" + phaseDependencies progress.PhaseID = "dependencies" + phaseIntegrations progress.PhaseID = "integrations" +) + +func buildPlan(opts Options) progress.Plan +func integrationStepID(integrationName, operation string) progress.StepID +``` + +Declare setup, dependency, and integration phases once in `Run`, pass the one +`Execution` through helpers, and call `Finish` on every return. A failed +worktree creation skips merge, env copy, dependencies, and integrations. A +dependency/install failure skips only dependent integration work. Record a +skipped index copy in both the event stream and `Result.Phases`. + +- [ ] **Step 3.4: Verify and commit** + +```bash +go test ./internal/creator -count=1 +go test -race ./internal/creator -count=1 +git add internal/creator +git commit -m "fix(creator): settle the declared creation plan" +``` + +## Task 4: Predeclare Repository Create, Clone, and Migrate + +**Files:** +- Create: `internal/repo/plan.go` +- Modify: `internal/repo/create.go` +- Modify: `internal/repo/clone.go` +- Modify: `internal/repo/migrate.go` +- Test: `internal/repo/plan_test.go` +- Modify: `internal/repo/create_test.go` +- Modify: `internal/repo/clone_test.go` +- Modify: `internal/repo/migrate_test.go` + +- [ ] **Step 4.1: Write failing table-driven stream-contract tests** + +For each existing failure injection point, collect events and call one helper: + +```go +func assertFinishedPlan(t *testing.T, events []progress.Event) { + t.Helper() + if err := progress.ValidateStream(events); err != nil { t.Fatal(err) } + for _, phase := range progress.Snapshot(events) { + if !phase.Settled() { t.Fatalf("phase not settled: %#v", phase) } + } +} +``` + +Cover invalid clone target, clone/structure/worktree failures, create setup and +GitHub failures, migrate validation/backup/destructive-stage failures, and the +optional upstream/origin skips. + +- [ ] **Step 4.2: Confirm RED** + +```bash +go test ./internal/repo -run 'FinishedPlan|DeclaresBeforeRunning' -count=1 -v +``` + +Expected: failures because repository phases are still discovered step by step. + +- [ ] **Step 4.3: Add static repository plan builders** + +Implement: + +```go +func createPlan(opts CreateOptions) progress.Plan +func clonePlan(opts CloneOptions) progress.Plan +func migratePlan(hasOrigin bool) progress.Plan +``` + +Create declares Setup plus conditional GitHub phases. Clone declares Validate, +Clone, Structure, and Worktree. Migrate performs only the safe origin-presence +probe before declaration, then declares Validate, Backup, Migrate, and Copy. +Each top-level function starts one execution and defers `Finish("blocked by an earlier phase")`. +Each early-return branch explicitly skips its downstream phases with the most +specific reason available. + +- [ ] **Step 4.4: Verify and commit** + +```bash +go test ./internal/repo -count=1 +go test -race ./internal/repo -count=1 +git add internal/repo +git commit -m "fix(repo): seed repository operation plans" +``` + +## Task 5: Move Removal onto the Shared Event Fold + +**Files:** +- Modify: `internal/worktree/deleter.go` +- Modify: `internal/worktree/deleter_test.go` +- Modify: `internal/tui/model.go` +- Modify: `internal/tui/progress.go` +- Create: `internal/tui/removal_progress.go` +- Modify: `internal/tui/removal_plan_test.go` +- Modify: `internal/tui/removal_e2e_test.go` + +- [ ] **Step 5.1: Write failing checkpoint and Snapshot parity tests** + +```go +func TestDeleteWorktrees_EmitsStartAndTerminalCheckpoints(t *testing.T) { + // Two removals with concurrency two. Assert declarations precede work, + // Running reaches checkpoint 1/2, terminals reach 2/2, and phases settle. +} + +func TestRemovalProgress_UsesSnapshotAsSourceOfTruth(t *testing.T) { + // Feed teardown/removal/prune/cleanup events to the model and assert the + // layout phases equal progress.Snapshot(eventHistory). +} +``` + +- [ ] **Step 5.2: Confirm RED** + +```bash +go test ./internal/worktree ./internal/tui -run 'StartAndTerminalCheckpoints|SnapshotAsSourceOfTruth' -count=1 -v +``` + +Expected: current deleter has no declaration/checkpoint events and TUI rebuilds phase state manually. + +- [ ] **Step 5.3: Emit the complete removal plan and retain event history** + +At confirmation time build phases for teardown, removal (two checkpoints per +worktree), prune, and cleanup. Store events on `removalRun`. Pass the shared +`Execution` to `DeleteWorktrees`; report checkpoint one before `remover`, then +Done/Failed after it returns. Teardown failure remains independent of removal; +prune failure remains independent of cleanup where current safety permits. + +Replace `buildRemovalPhases` with `progress.Snapshot(run.events)`. Preserve +display labels via declaration labels, not a second worktree-status fold. +Now that integration, creator, repository, and removal producers use +`Execution`, delete `PlannedPhase.Open`, the legacy `Declare`/`ClosePhase` +adapter, and tests that permit discovery after declaration. + +- [ ] **Step 5.4: Verify concurrency and commit** + +```bash +go test ./internal/worktree ./internal/tui -run 'Removal|DeleteWorktrees' -count=1 +go test -race ./internal/worktree ./internal/tui -run 'Removal|DeleteWorktrees' -count=1 +git add internal/worktree internal/tui/model.go internal/tui/progress.go internal/tui/removal_progress.go internal/tui/removal_plan_test.go internal/tui/removal_e2e_test.go +git commit -m "refactor(progress): fold removal from shared events" +``` + +## Task 6: Build the Pinned Responsive Live Region + +**Files:** +- Create: `internal/tui/progress_viewport.go` +- Create: `internal/tui/progress_viewport_test.go` +- Modify: `internal/tui/progress_layout.go` +- Modify: `internal/tui/window.go` +- Modify: `internal/tui/window_test.go` +- Modify: `internal/tui/model.go` +- Create: `internal/tui/progress_details.go` +- Modify: `internal/tui/help.go` +- Modify: `internal/tui/keys.go` +- Modify: `internal/tui/progress_layout_test.go` + +- [ ] **Step 6.1: Write failing viewport and dimension properties** + +```go +func TestProgressLayout_NeverExceedsTerminal(t *testing.T) { + for height := 8; height <= 40; height++ { + for _, width := range []int{40, 50, 80, 120} { + out := denseProgressFixture(width, height).View() + if got := lipgloss.Height(out); got > height { t.Fatalf("%dx%d rendered %d rows", width, height, got) } + for _, line := range strings.Split(out, "\n") { + if got := lipgloss.Width(line); got > width { t.Fatalf("%dx%d rendered %d cols", width, height, got) } + } + } + } +} + +func TestBuildProgressViewport_ChoosesOneFocus(t *testing.T) { + // Settled history + two pending phases + one running phase. + // Assert one focus, static queued summary, and bounded detail rows. +} + +func TestWindowSteps_HardBoundsRunningAndFailures(t *testing.T) { + // More active/failure steps than available lines must still return <= budget. +} +``` + +- [ ] **Step 6.2: Confirm RED** + +```bash +go test ./internal/tui -run 'NeverExceedsTerminal|BuildProgressViewport|HardBounds' -count=1 -v +``` + +Expected: 80x24 height assertion fails and pending phases are classified as active. + +- [ ] **Step 6.3: Implement the pure viewport projection** + +```go +type ProgressViewport struct { + History []progress.PhaseState + HistoryOmitted int + Focus *progress.PhaseState + Queued int + DetailRows int + Tier progressViewportTier +} + +func BuildProgressViewport(phases []progress.PhaseState, rows int) ProgressViewport +``` + +A phase is active only when one step is `StepRunning`. Focus order is latest +failed phase, running phase, then the earliest unresolved phase. Normal (18+), +compact (12-17), and minimal (<12) tiers reserve fixed rows. History keeps the +newest settled/failed rows; omitted rows become one summary. Queue becomes one +static `N phases waiting` row. + +- [ ] **Step 6.4: Render fixed-height regions and add details** + +Pass raw `WindowSizeMsg.Height` to progress layouts rather than the globally +chrome-budgeted body height. Render title, separator, history, a padded focus +region, queue, separator, bar, and footer within the tier's exact budget. +Build skip text before truncation so suffixes cannot overflow. Clamp the bar to +remaining width; omit elapsed first, then use percentage-only below the useful +bar floor. + +Add progress views to `detailContent` and add `keys.Info` to their footer only +when event history contains failures or omitted detail. Render full messages and +errors in the existing portal viewport. + +- [ ] **Step 6.5: Verify resize and rendering tests, then commit** + +```bash +go test ./internal/tui -run 'Progress|Window|Resize|Detail' -count=1 +go test -race ./internal/tui -count=1 +git add internal/tui +git commit -m "feat(tui): pin the progress live region" +``` + +## Task 7: Scope Motion and Repair the Identity Guard + +**Files:** +- Create: `internal/tui/motion_preference.go` +- Create: `internal/tui/motion_preference_test.go` +- Modify: `internal/tui/model.go` +- Modify: `internal/tui/progress_layout.go` +- Modify: `internal/tui/motion_test.go` +- Modify: `scripts/pre-commit` +- Create: `scripts/pre-commit_test.sh` +- Modify: `.impeccable.md` + +- [ ] **Step 7.1: Write failing motion and hook tests** + +```go +func TestMotionPreference_OffDisablesProgressTicks(t *testing.T) { + t.Setenv("SENTEI_MOTION", "off") + m := newProgressModel() + if m.motionActive() { t.Fatal("motion remains active") } + if cmd := m.motionCmd(); cmd != nil { t.Fatal("motion tick scheduled") } +} + +func TestProgressMotion_OnlyRunningPhaseShimmers(t *testing.T) { + // Render several declared pending phases and one running phase. + // Assert the injected star frame appears exactly once. +} +``` + +The shell test creates an isolated temporary repository and runs the hook with: + +```bash +GIT_AUTHOR_NAME=sentei-test GIT_AUTHOR_EMAIL=test@sentei.invalid \ +GIT_COMMITTER_NAME='Valid User' GIT_COMMITTER_EMAIL=valid@example.com \ +scripts/pre-commit +``` + +Expected: nonzero. Reverse author/committer and expect nonzero. Use two valid +identities and expect zero when no Go files are staged. + +- [ ] **Step 7.2: Confirm RED** + +```bash +go test ./internal/tui -run 'MotionPreference|OnlyRunningPhase' -count=1 -v +bash scripts/pre-commit_test.sh +``` + +Expected: Go compile failure for the preference API and hook test failure because configured email masks environment identity. + +- [ ] **Step 7.3: Implement static motion and effective identity checks** + +```go +type MotionPreference int +const ( + MotionFull MotionPreference = iota + MotionOff +) + +func motionPreference(getenv func(string) string) MotionPreference { + if strings.EqualFold(getenv("SENTEI_MOTION"), "off") || getenv("TERM") == "dumb" { return MotionOff } + return MotionFull +} +``` + +Store the preference on `Model`. In static mode do not schedule `motionTickMsg` +or progress spring frames, render the exact target bar, keep stopwatch ticks, +and let settle observation treat displayed progress as target progress. In full +mode, inject Motion only into the one running focus phase; pending/history rows +remain static. Keep the existing completion crystallization and success-green +bar, without extra transitions. + +In the hook, obtain both identities with: + +```bash +author_ident="$(git var GIT_AUTHOR_IDENT)" +committer_ident="$(git var GIT_COMMITTER_IDENT)" +case "$author_ident" in *sentei-test*|*"@sentei.invalid"*) reject="author" ;; esac +case "$committer_ident" in *sentei-test*|*"@sentei.invalid"*) reject="committer" ;; esac +``` + +Report only which role is invalid and how to inspect it; allow every other +contributor or automation identity. Record the env-first reduced-motion decision +in `.impeccable.md`. + +- [ ] **Step 7.4: Verify and commit** + +```bash +go test ./internal/tui -run 'Motion|Settle|Progress' -count=1 +bash scripts/pre-commit_test.sh +git add internal/tui scripts/pre-commit scripts/pre-commit_test.sh .impeccable.md +git commit -m "fix(tui): scope motion and guard effective git identity" +``` + +## Task 8: Add End-to-End Contracts and Deterministic VHS Demos + +**Files:** +- Modify: `internal/tui/integration_plan_test.go` +- Modify: `internal/tui/removal_e2e_test.go` +- Create: `internal/tui/progress_teatest_test.go` +- Create: `.demos/progress-arc/setup-fixture.sh` +- Create: `.demos/progress-arc/before-after.tape` +- Create: `.demos/progress-arc/removal-success.tape` +- Create: `.demos/progress-arc/integration-failure.tape` +- Create: `.demos/progress-arc/README.md` + +- [ ] **Step 8.1: Write last-frame and monotonic integration tests** + +Use `teatest` to drive success and failure models at 80x24. Capture output until +summary, then assert the last progress frame contains the bar/footer, resolved +blocked steps, and no pending active glyphs. For every stream prefix assert the +checkpoint total is unchanged after the declaration prefix and reached never +decreases. + +- [ ] **Step 8.2: Confirm the new tests catch the old branch behavior** + +Build the current commit's tests, then temporarily run the same assertions +against the parent implementation or use the retained before binary in the VHS +fixture. Record the expected old failure: missing footer/overflow, denominator +growth, or unresolved setup. Restore the working tree before continuing. + +- [ ] **Step 8.3: Make isolated deterministic VHS fixtures** + +`setup-fixture.sh` must create everything under `/tmp/sentei-vhs-progress-arc`: + +- isolated HOME and Git config with `sentei-demo `; +- a bare repository and fixed worktrees/branches; +- a `git` PATH shim that sleeps only for `worktree remove` and delegates; +- `code-review-graph` and `ccc` shims with deterministic detection/setup; +- a failing `ccc index` that prints `error: deterministic demo failure` and exits 17. + +Each tape must use `Hide`, reset the fixture, run `stty cols 80 rows 24`, set the +isolated HOME/PATH, then `Show` before launching Sentei. Prefer `Wait+Screen` for +semantic states and short sleeps only to expose the completion beat. + +- [ ] **Step 8.4: Render and inspect GIFs plus checkpoint frames** + +```bash +vhs .demos/progress-arc/before-after.tape +vhs .demos/progress-arc/removal-success.tape +vhs .demos/progress-arc/integration-failure.tape +ffprobe -v error -show_entries stream=width,height,r_frame_rate -of default=nw=1 .demos/progress-arc/removal-success.gif +ffmpeg -y -i .demos/progress-arc/removal-success.gif -vf "select=eq(n\,0)+eq(n\,25)+eq(n\,50)" -vsync 0 /tmp/sentei-vhs-progress-arc/frame-%02d.png +``` + +Inspect the GIFs and extracted frames for pinned footer/bar rows, monotonic fill, +one active animation, readable failure/skip copy, and coherent final frames. + +- [ ] **Step 8.5: Run the full gauntlet, refresh semantic index, and commit** + +```bash +go test -race ./... +go vet ./... +golangci-lint run +go build ./... +git diff --check +ccc index +git add internal/tui .demos/progress-arc +git commit -m "test: prove truthful progress end to end" +``` + +Expected: every command exits 0; GIF and frame inspection finds no clipping or +regression. + +## Task 9: Final Parallel Review and PR Readiness + +**Files:** +- Modify only files required by validated review findings. + +- [ ] **Step 9.1: Dispatch independent correctness, architecture, and UX reviews** + +Give each reviewer the design spec, this plan, base SHA `8a2e202`, and current +HEAD. Require file/line evidence and reproduction commands. Correctness focuses +on stream invariants and failure paths; architecture checks domain dependency +direction and duplicate sources of truth; UX checks 80x24, resize tiers, motion, +portal detail, and the GIFs. + +- [ ] **Step 9.2: Fix every Critical or Important finding test-first** + +For each accepted finding, write a regression test, observe RED, implement the +smallest fix, observe GREEN, and return it to the reporting reviewer for +re-review. Do not merge unrelated pre-existing CLI/docs issues into this branch. + +- [ ] **Step 9.3: Run fresh final verification** + +```bash +go test -race ./... +go vet ./... +golangci-lint run +go build ./... +git diff --check origin/main...HEAD +git status --short --branch +``` + +Verify the worktree is clean and only then summarize PR readiness, remaining +non-blocking debt, and links to the GIFs. diff --git a/docs/superpowers/specs/2026-07-14-progress-arc-correctness-and-live-region-design.md b/docs/superpowers/specs/2026-07-14-progress-arc-correctness-and-live-region-design.md new file mode 100644 index 0000000..46a532f --- /dev/null +++ b/docs/superpowers/specs/2026-07-14-progress-arc-correctness-and-live-region-design.md @@ -0,0 +1,275 @@ +# Progress Arc Correctness and Live Region Design + +**Date:** 2026-07-14 +**Status:** Approved + +## Problem + +The progress arc has a sound event vocabulary, but several producers and the +terminal renderer do not yet uphold it end to end: + +1. Conditional integration work is discovered after execution starts, so the + denominator grows and visible progress can move backward. +2. Early failures can close a phase while declared downstream steps remain + pending forever. +3. Repository create, clone, and migrate still discover their known steps as + they execute instead of declaring their plans first. +4. Removal and integration rebuild display state in the TUI rather than using + the shared `progress.Snapshot` fold, creating multiple sources of truth. +5. The expanding phase tree exceeds an 80x24 terminal and animates pending + phases as if they were running. +6. The pre-commit identity guard checks repository configuration rather than + Git's effective author and committer identities. + +The result is a progress display that can be numerically dishonest, visually +unstable, and contradictory after failure even though the underlying package +boundaries are otherwise clean. + +## Goals + +- Freeze an accurate denominator before destructive or long-running work. +- Make every declared step resolve to done, failed, or skipped. +- Keep one event stream and one fold as the progress source of truth. +- Fit all live progress views at 80x24 without clipping the bar or footer. +- Use motion only for work that is actively executing. +- Preserve detailed, auditable failure and skip information. +- Demonstrate the finished behavior with deterministic VHS recordings. + +## Non-goals + +- Adding cancellation or joining background commands when `q` is pressed. +- Fixing the positional repository argument documented in the README. +- Changing the removal summary's pre-existing `q` behavior. +- Introducing a new TUI or animation dependency. + +## Considered Approaches + +### Declare the maximum possible plan + +Declare every possible conditional step and skip work that is not needed. +This guarantees a stable denominator but counts hypothetical work and creates +noisy skip traces. + +### Preflight and freeze the exact plan + +Perform read-only discovery first, compile the exact work into the event +stream, then execute that immutable plan. This keeps totals accurate and +retains step-level detail. This is the selected approach. + +### Count only coarse phases + +Treat each integration, worktree, or repository phase as one progress unit and +show sub-operations as status text. This is robust but discards useful detail +and weakens the existing progress architecture. + +## Design + +### 1. Exact plans precede execution + +Each flow separates planning from execution: + +1. Preflight performs read-only discovery and returns an immutable operation + plan. +2. `progress.Declare` compiles that plan into Pending events and close markers. +3. Execution consumes the same plan without adding step names. +4. Every execution path resolves every declared step. + +The existing `progress.Plan`, `PlannedPhase`, and `PlannedStep` remain the +domain contract. Plans continue to ride the event stream so replay, tests, and +the TUI do not need a second artifact to join with events. + +The plan owns execution, not only declaration. A concurrency-safe execution +object validates the plan, emits the complete declaration prefix, runs or +resolves named steps, and terminalizes any untouched steps during `Finish`. +This makes undeclared work and terminal-state mutation contract violations +instead of states the renderer must repair. + +Step identity is separate from presentation. Each planned step has a stable ID +that is unique within its phase and a display label. Events fold by ID and show +the label. This prevents repeated labels such as `Copy index from main` from +collapsing into one step. + +Integration preflight determines, per worktree and integration: + +- whether the tool is already available; +- which dependencies require installation; +- whether the tool installation step will run; +- the setup, teardown, and artifact-removal steps that will run. + +Detection results are carried in the plan and reused by execution. Execution +must not repeat detection and produce a different plan. + +Because the supported integration installers are global tools, their dependency +and install work appears once in a `Prerequisites` phase. Per-worktree phases +contain setup, teardown, and artifact-removal work. This also prevents the first +worktree's installation from invalidating identical plans for later worktrees. +The TUI shows an indeterminate `Preparing plan...` state while the read-only +probes run; percentages begin only after the exact plan exists. + +Repository create, clone, and migrate declare the steps already determined by +their options. A step whose applicability depends on an earlier result may be +declared conservatively and resolved as skipped with a reason, but no new step +may be introduced after its phase closes. + +### 2. Terminal-state guarantee + +A closed phase must be settled when its driver completes. If a step fails, +later dependent steps are emitted as `StepSkipped` with a concise reason such +as `blocked by installation` or `blocked by worktree creation`. + +Independent cleanup work may continue after a failure when that is already the +flow's safety behavior. Only dependency relationships cause skips. + +The progress package gains a small helper for resolving the untouched suffix +of a declared plan as skipped, plus a final execution safety net that resolves +any remaining pending step. Producer tests validate the stream with +`ValidateStream` and assert that every phase is settled after completion. + +### 3. One fold for every progress view + +`progress.Snapshot` remains the only event-to-display fold. `StepState` retains +the stable ID, display label, and structured error needed by the TUI so +integration progress no longer reimplements folding to decorate errors. + +Removal emits declarations, checkpoints, terminal events, and close markers +for teardown, worktree removal, prune, and cleanup. Its TUI-specific state may +still control concurrency and summaries, but `buildRemovalPhases` is replaced +by folding the emitted event history. + +Integration progress similarly folds its event history directly, then applies +only presentation-safe phase labels such as worktree basenames. Presentation +must not recompute status, counts, or closure. + +### 4. Pinned live region + +`ProgressLayout` receives the raw terminal height and divides it into fixed +vertical regions: + +1. title and optional subtitle; +2. separator; +3. compact completed history; +4. one active or failed phase detail region; +5. a static waiting summary; +6. separator, progress bar, and footer. + +The pure viewport projection returns bounded history, one focus phase, and a +queued summary. The bar and footer remain at stable rows during a run by padding +unused live-region rows. Completed phases collapse to one line. The active +region receives the remaining line budget and windows its steps, prioritizing +the current running step, the latest failure, recent completions, and the next +pending work in that order. If even those exceed the budget, one stat line +reports the omitted counts. Pending phases render as a static summary and never +receive an active glyph or shimmer. + +At 80x24, history and the waiting summary compact before the active region, +bar, or footer can be displaced. At smaller heights, the layout preserves the +title, active/failure line, bar, and footer, and replaces omitted detail with a +single stat line. + +The projection has three resize tiers: normal at 18 or more rows, compact at +12-17 rows, and minimal below 12 rows. Width pressure removes elapsed time +before shrinking the useful bar, then falls back to percentage-only at truly +small widths. Full failed-step output and omitted history remain available in +the existing `bubbles/viewport` details portal. + +### 5. Motion and Charm primitives + +The existing Charm stack remains sufficient: + +- `bubbles/progress` continues to spring between monotonic targets; +- Lip Gloss measures and truncates cells and composes the fixed regions; +- the existing deterministic motion clock drives the active star and shimmer; +- `bubbles/help` continues to render footer bindings. + +Motion follows one rule: anything moving is being worked on; anything still is +waiting or settled. Only the currently executing phase and step twinkle and +shimmer. Phase collapse is an instant state change because terminal row motion +would compete with the pinned layout. The completion beat remains the single +hero moment: the bar settles, changes to the success palette, and the active +star crystallizes. + +No new decorative spinner or transition is added. Tests use static motion +fixtures, and non-interactive or reduced-motion contexts retain meaningful +static glyphs and an immediately correct final frame. `SENTEI_MOTION=off` and +`TERM=dumb` disable star, shimmer, and spring ticks without disabling elapsed +time or delaying settle; a settings UI is deferred. + +### 6. Effective Git identity guard + +The pre-commit hook reads `git var GIT_AUTHOR_IDENT` and +`git var GIT_COMMITTER_IDENT`, which include environment overrides and therefore +match the identities Git will actually write. It rejects known test markers +such as the `.invalid` domain and `sentei-test`, while allowing legitimate +contributors and automation. It never prints or requires a hard-coded personal +email. + +Hook tests cover configured identities, environment overrides, author/committer +differences, and test-identity rejection. + +## Verification + +### Domain and producer contracts + +- Phase IDs and step IDs are nonempty and unique within their scope. +- The declaration burst is the complete stream prefix before work starts. +- Denominator and checkpoint totals are fully established before the first + Running event. +- Totals never increase during execution and reached checkpoints never regress. +- No producer introduces an undeclared step or mutates a terminal step. +- All closed phases are settled at driver completion. +- Failure resolves dependent suffixes as skipped with a reason. +- Empty and already-installed integration plans remain valid and truthful. +- Concurrent event interleavings preserve totals and terminal states. +- Equal display labels with different stable IDs remain distinct. + +### TUI behavior + +- Rendered output never exceeds the supplied height at 80x24 and narrower + fixtures. +- The bar and footer stay present at fixed rows throughout a run. +- Exactly one active phase animates; pending phases are static. +- Failures and their blocked steps remain visible. +- Long skip reasons and error previews stay within the supplied width. +- Resizing between 80x24, 120x40, and 50x16 preserves state and controls. +- Static-motion mode schedules no motion or spring frames and settles normally. +- Success and failure final frames are asserted with `teatest`. +- Existing golden views and the race-enabled full test suite remain clean. + +### Demonstrations + +VHS tapes run only against playground data or isolated temporary repositories. +Recordings cover: + +1. multi-worktree progress at 80x24; +2. successful worktree removal; +3. integration installation failure with the dependent setup step skipped; +4. a concise before/after comparison of the expanding tree and pinned region. + +Each tape fixes terminal dimensions, typing delays, and operation timing. +`stty cols 80 rows 24` fixes terminal cells independently of VHS pixel size. +PATH shims under an isolated temporary home provide deterministic command +latency and integration failure without touching a user repository or network. +GIFs and selected PNG frames are inspected after rendering for clipping, +unreadable transient frames, bar regression, and a coherent final frame. + +## Risks and Mitigations + +- **Preflight result becomes stale before execution.** The interval is short; + execution treats an already-satisfied install as success or skip rather than + mutating the plan. Tests cover this idempotent boundary. +- **Plans duplicate producer control flow.** Plan builders own step selection; + execution iterates plan operations rather than reconstructing conditions. +- **Preflight feels like stalled progress.** A non-percent preparing state gives + immediate feedback; the determinate bar begins only when its denominator is + truthful. +- **Pinned history hides useful detail.** Failures stay expanded, while full + output remains available through the existing details portal and summaries. +- **Small terminals cannot show every row.** The layout preserves active work, + failures, progress, and controls, then reports omitted counts explicitly. + +## Rollout + +Land the work as small commits: progress helpers and contracts, integration +planning, creator/repository planning, removal event unification, pinned layout +and motion, identity guard, then demonstrations. Run focused tests after each +commit and the full race/lint/build gauntlet before the final review. diff --git a/internal/creator/contract_test.go b/internal/creator/contract_test.go new file mode 100644 index 0000000..e177166 --- /dev/null +++ b/internal/creator/contract_test.go @@ -0,0 +1,313 @@ +package creator + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/abiswas97/sentei/internal/config" + "github.com/abiswas97/sentei/internal/integration" + "github.com/abiswas97/sentei/internal/progress" + "github.com/abiswas97/sentei/internal/testutil/mock" +) + +func TestRun_ProgressCallbackPanicIsReturned(t *testing.T) { + emitErr := errors.New("delivery failed") + result := Run(&mock.Runner{}, &mock.Runner{}, Options{ + BranchName: "feature/callback", BaseBranch: "main", RepoPath: "/repo", + }, func(progress.Event) { panic(emitErr) }) + + if !errors.Is(result.Err, emitErr) { + t.Fatalf("Err = %v, want wrapped callback error %v", result.Err, emitErr) + } + if len(result.Phases) != 0 { + t.Fatalf("Phases = %#v, want no projection when Start cannot deliver", result.Phases) + } +} + +func TestRun_RejectsDuplicateEcosystemIdentityBeforeExecution(t *testing.T) { + runner := &mock.Runner{} + result := Run(runner, runner, Options{ + BranchName: "feature/duplicate", BaseBranch: "main", RepoPath: "/repo", + Ecosystems: []config.EcosystemConfig{ + {Name: "node", Install: config.InstallConfig{Command: "npm install"}}, + {Name: "node", Install: config.InstallConfig{Command: "pnpm install"}}, + }, + }, func(progress.Event) {}) + + if result.Err == nil || !strings.Contains(result.Err.Error(), "duplicate ecosystem identity") { + t.Fatalf("Err = %v", result.Err) + } + if len(runner.Calls) != 0 { + t.Fatalf("calls = %v, want none before rejecting identity conflict", runner.Calls) + } +} + +func TestPrepareCreation_EqualLabelsHaveDistinctSemanticIDs(t *testing.T) { + runner := &mock.Runner{Responses: map[string]mock.Response{ + "/repo:[ls-tree -r --name-only main]": {Output: "package.json\npackages/api/package.json"}, + "/repo:[show main:package.json]": {Output: `{"workspaces":["packages/*"]}`}, + }} + prepared, err := prepareCreation(runner, runner, Options{ + BranchName: "feature/labels", BaseBranch: "main", RepoPath: "/repo", + Ecosystems: []config.EcosystemConfig{ + {Name: "node (packages/api)", Install: config.InstallConfig{Command: "root install"}}, + {Name: "node", Install: config.InstallConfig{ + Command: "fallback", WorkspaceDetect: "package.json", WorkspaceInstall: "workspace {dir}", + }}, + }, + }) + if err != nil { + t.Fatal(err) + } + steps := prepared.plan.Phases[1].Steps + if len(steps) != 2 || steps[0].Label != steps[1].Label || steps[0].ID == steps[1].ID { + t.Fatalf("steps = %#v, want equal labels with distinct IDs", steps) + } +} + +func TestRun_ResultPhasesMatchCompletedEventStream(t *testing.T) { + runner := &mock.Runner{Responses: map[string]mock.Response{ + "/repo:[show-ref --verify refs/heads/feature/parity]": {Err: errors.New("missing")}, + "/repo:[worktree add /repo/feature-parity -b feature/parity main]": {}, + "/repo/feature-parity:shell[go mod download]": {}, + }} + var events []progress.Event + result := Run(runner, runner, Options{ + BranchName: "feature/parity", BaseBranch: "main", RepoPath: "/repo", + Ecosystems: []config.EcosystemConfig{{Name: "go", Install: config.InstallConfig{Command: "go mod download"}}}, + }, func(event progress.Event) { events = append(events, event) }) + if result.Err != nil { + t.Fatal(result.Err) + } + if err := progress.ValidateStream(events); err != nil { + t.Fatalf("invalid stream: %v", err) + } + for _, phase := range result.Phases { + for _, step := range phase.Steps { + found := false + for i := len(events) - 1; i >= 0; i-- { + if events[i].Phase == phase.ID && events[i].Step == step.ID && !events[i].Close { + found = true + if events[i].Status != step.Status { + t.Fatalf("%s/%s result status %v != stream status %v", phase.ID, step.ID, step.Status, events[i].Status) + } + break + } + } + if !found { + t.Fatalf("result step %s/%s missing from stream", phase.ID, step.ID) + } + } + } +} + +func TestRun_MergeFailureDoesNotBlockEnvCopy(t *testing.T) { + source := t.TempDir() + if err := os.WriteFile(filepath.Join(source, ".env"), []byte("SAFE=1"), 0o600); err != nil { + t.Fatal(err) + } + repo := t.TempDir() + worktree := filepath.Join(repo, "feature-independent") + if err := os.MkdirAll(worktree, 0o755); err != nil { + t.Fatal(err) + } + runner := &mock.Runner{Responses: map[string]mock.Response{ + fmt.Sprintf("%s:[show-ref --verify refs/heads/feature/independent]", repo): {Err: errors.New("missing")}, + fmt.Sprintf("%s:[worktree add %s -b feature/independent main]", repo, worktree): {}, + fmt.Sprintf("%s:[merge main --no-edit]", worktree): {Err: errors.New("conflict")}, + }} + result := Run(runner, runner, Options{ + BranchName: "feature/independent", BaseBranch: "main", RepoPath: repo, + SourceWorktree: source, MergeBase: true, CopyEnvFiles: true, + Ecosystems: []config.EcosystemConfig{{Name: "node", EnvFiles: []string{".env"}}}, + }, func(progress.Event) {}) + if _, err := os.Stat(filepath.Join(worktree, ".env")); err != nil { + t.Fatalf("env copy was blocked by merge failure: %v", err) + } + if !result.HasFailures() { + t.Fatal("merge failure was not recorded") + } +} + +func TestRun_IndependentSetupFailureDoesNotBlockDependenciesOrIntegrations(t *testing.T) { + tests := []struct { + name string + mergeErr error + makeSourceEnv func(*testing.T, string) + failedStepName string + }{ + { + name: "merge failure", mergeErr: errors.New("conflict"), failedStepName: "Merge base branch", + makeSourceEnv: func(t *testing.T, source string) { + t.Helper() + if err := os.WriteFile(filepath.Join(source, ".env"), []byte("SAFE=1"), 0o600); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "env copy failure", failedStepName: "Copy env files", + makeSourceEnv: func(t *testing.T, source string) { + t.Helper() + if err := os.Mkdir(filepath.Join(source, ".env"), 0o755); err != nil { + t.Fatal(err) + } + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + source := t.TempDir() + tc.makeSourceEnv(t, source) + repo := t.TempDir() + worktree := filepath.Join(repo, "feature-fallbacks") + if err := os.MkdirAll(worktree, 0o755); err != nil { + t.Fatal(err) + } + responses := map[string]mock.Response{ + fmt.Sprintf("%s:shell[tool detect]", source): {}, + fmt.Sprintf("%s:[show-ref --verify refs/heads/feature/fallbacks]", repo): {Err: errors.New("missing")}, + fmt.Sprintf("%s:[worktree add %s -b feature/fallbacks main]", repo, worktree): {}, + fmt.Sprintf("%s:[merge main --no-edit]", worktree): {Err: tc.mergeErr}, + fmt.Sprintf("%s:shell[go mod download]", worktree): {}, + fmt.Sprintf("%s:shell[tool setup]", worktree): {}, + } + runner := &mock.Runner{Responses: responses} + result := Run(runner, runner, Options{ + BranchName: "feature/fallbacks", BaseBranch: "main", RepoPath: repo, + SourceWorktree: source, MergeBase: true, CopyEnvFiles: true, + Ecosystems: []config.EcosystemConfig{{ + Name: "go", EnvFiles: []string{".env"}, Install: config.InstallConfig{Command: "go mod download"}, + }}, + Integrations: []integration.Integration{{ + Name: "tool", Detect: integration.DetectSpec{Command: "tool detect"}, + Setup: integration.SetupSpec{Command: "tool setup", WorkingDir: "worktree"}, + }}, + }, func(progress.Event) {}) + + if result.Err != nil { + t.Fatalf("Err = %v", result.Err) + } + if step := creatorResultStep(t, result, tc.failedStepName); step.Status != progress.StepFailed { + t.Fatalf("failed setup result = %#v", step) + } + for _, name := range []string{"go", "Setup tool"} { + if step := creatorResultStep(t, result, name); step.Status != progress.StepDone { + t.Fatalf("independent step %q = %#v", name, step) + } + } + }) + } +} + +func creatorResultStep(t *testing.T, result Result, name string) progress.StepResult { + t.Helper() + for _, phase := range result.Phases { + for _, step := range phase.Steps { + if step.Name == name { + return step + } + } + } + t.Fatalf("step %q not found in %#v", name, result.Phases) + return progress.StepResult{} +} + +func TestRun_WorktreeFailureBlocksAllPreparedExecution(t *testing.T) { + runner := &mock.Runner{Responses: map[string]mock.Response{ + "/repo:shell[tool detect]": {}, + "/repo:[show-ref --verify refs/heads/feature/blocked]": {Err: errors.New("missing")}, + "/repo:[worktree add /repo/feature-blocked -b feature/blocked main]": {Err: errors.New("cannot create")}, + }} + result := Run(runner, runner, Options{ + BranchName: "feature/blocked", BaseBranch: "main", RepoPath: "/repo", MergeBase: true, + Ecosystems: []config.EcosystemConfig{{Name: "go", Install: config.InstallConfig{Command: "go mod download"}}}, + Integrations: []integration.Integration{{ + Name: "tool", Detect: integration.DetectSpec{Command: "tool detect"}, + Setup: integration.SetupSpec{Command: "tool setup", WorkingDir: "worktree"}, + }}, + }, func(progress.Event) {}) + for _, call := range runner.Calls { + if strings.Contains(call, "merge main") || strings.Contains(call, "go mod download") || strings.Contains(call, "tool setup") { + t.Fatalf("blocked execution call occurred: %v", runner.Calls) + } + } + for _, phase := range result.Phases { + for _, step := range phase.Steps { + if step.Name != "Create worktree" && step.Status != progress.StepSkipped { + t.Fatalf("blocked step did not settle skipped: %#v", step) + } + } + } +} + +type blockingDependencyShell struct { + started chan string + release chan struct{} + active atomic.Int32 + maximum atomic.Int32 +} + +func (s *blockingDependencyShell) RunShell(_ string, command string) (string, error) { + active := s.active.Add(1) + for { + maximum := s.maximum.Load() + if active <= maximum || s.maximum.CompareAndSwap(maximum, active) { + break + } + } + s.started <- command + <-s.release + s.active.Add(-1) + return "", nil +} + +func TestRun_ParallelDependenciesRespectLimitAndJoinBeforeFinish(t *testing.T) { + parallel := true + var tree strings.Builder + tree.WriteString("package.json\n") + for i := 0; i < 6; i++ { + fmt.Fprintf(&tree, "packages/p%d/package.json\n", i) + } + runner := &mock.Runner{Responses: map[string]mock.Response{ + "/repo:[ls-tree -r --name-only main]": {Output: tree.String()}, + "/repo:[show main:package.json]": {Output: `{"workspaces":["packages/*"]}`}, + "/repo:[show-ref --verify refs/heads/feature/concurrency]": {Err: errors.New("missing")}, + "/repo:[worktree add /repo/feature-concurrency -b feature/concurrency main]": {}, + }} + shell := &blockingDependencyShell{started: make(chan string, 6), release: make(chan struct{}, 6)} + resultCh := make(chan Result, 1) + go func() { + resultCh <- Run(runner, shell, Options{ + BranchName: "feature/concurrency", BaseBranch: "main", RepoPath: "/repo", + Ecosystems: []config.EcosystemConfig{{Name: "node", Install: config.InstallConfig{ + Command: "root", WorkspaceDetect: "package.json", WorkspaceInstall: "install {dir}", Parallel: ¶llel, + }}}, + }, func(progress.Event) {}) + }() + for i := 0; i < maxDepsConcurrency; i++ { + <-shell.started + } + if got := shell.maximum.Load(); got != maxDepsConcurrency { + t.Fatalf("maximum concurrency = %d, want %d", got, maxDepsConcurrency) + } + for i := 0; i < maxDepsConcurrency; i++ { + shell.release <- struct{}{} + } + <-shell.started + select { + case result := <-resultCh: + t.Fatalf("Run returned before final dependency joined: %#v", result) + default: + } + shell.release <- struct{}{} + result := <-resultCh + if result.Err != nil || result.HasFailures() { + t.Fatalf("result = %#v", result) + } +} diff --git a/internal/creator/creator.go b/internal/creator/creator.go index 8066b16..49b85dc 100644 --- a/internal/creator/creator.go +++ b/internal/creator/creator.go @@ -1,10 +1,13 @@ package creator import ( + "errors" + "fmt" + "github.com/abiswas97/sentei/internal/config" "github.com/abiswas97/sentei/internal/git" "github.com/abiswas97/sentei/internal/integration" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" ) type Options struct { @@ -20,29 +23,29 @@ type Options struct { type Result struct { WorktreePath string - Phases []pipeline.Phase + Phases []progress.Phase + Err error } func (r *Result) HasFailures() bool { - return pipeline.PhasesHaveFailures(r.Phases) + return r.Err != nil || progress.PhasesHaveFailures(r.Phases) } -func Run(runner git.CommandRunner, shell git.ShellRunner, opts Options, emit func(pipeline.Event)) Result { +func Run(runner git.CommandRunner, shell git.ShellRunner, opts Options, emit func(progress.Event)) Result { result := Result{} - - setupPhase := runSetup(runner, opts, emit) - result.Phases = append(result.Phases, setupPhase) - - if setupPhase.Steps[0].Status == pipeline.StepFailed { + prepared, err := prepareCreation(runner, shell, opts) + if err != nil { + result.Err = err return result } - result.WorktreePath = git.WorktreePath(opts.RepoPath, opts.BranchName) - - depsPhase := runDeps(shell, result.WorktreePath, opts, emit) - result.Phases = append(result.Phases, depsPhase) - - intPhase := runIntegrations(shell, result.WorktreePath, opts, emit) - result.Phases = append(result.Phases, intPhase) - + execution, err := progress.Start(prepared.plan, emit) + if err != nil { + result.Err = fmt.Errorf("starting worktree creation: %w", err) + return result + } + runErr := prepared.run(execution, runner, shell, &result) + finishErr := execution.Finish("worktree creation finished") + result.Phases = execution.Phases() + result.Err = errors.Join(runErr, finishErr) return result } diff --git a/internal/creator/creator_test.go b/internal/creator/creator_test.go index f068a1a..8363606 100644 --- a/internal/creator/creator_test.go +++ b/internal/creator/creator_test.go @@ -8,7 +8,7 @@ import ( "github.com/abiswas97/sentei/internal/config" "github.com/abiswas97/sentei/internal/integration" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/testutil/mock" ) @@ -18,7 +18,7 @@ func TestRun_FullPipeline(t *testing.T) { "/repo:[worktree add /repo/feature-auth -b feature/auth main]": {Output: ""}, "/repo/feature-auth:[merge main --no-edit]": {Output: ""}, "/repo/feature-auth:shell[go mod download]": {Output: ""}, - "/repo/feature-auth:shell[code-review-graph --version]": {Output: "1.0"}, + "/repo/main:shell[code-review-graph --version]": {Output: "1.0"}, "/repo:shell[code-review-graph build --repo '/repo/feature-auth']": {Output: ""}, }} @@ -49,7 +49,7 @@ func TestRun_FullPipeline(t *testing.T) { }, } - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} result := Run(runner, runner, opts, ec.Emit) if result.WorktreePath != "/repo/feature-auth" { @@ -95,11 +95,11 @@ func TestRun_CreateWorktreeFails_AbortsEarly(t *testing.T) { }, } - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} result := Run(runner, runner, opts, ec.Emit) - if len(result.Phases) != 1 { - t.Fatalf("phase count = %d, want 1 (abort after setup)", len(result.Phases)) + if len(result.Phases) != 2 { + t.Fatalf("phase count = %d, want full prepared projection", len(result.Phases)) } if result.WorktreePath != "" { t.Errorf("WorktreePath = %q, want empty on failure", result.WorktreePath) @@ -122,11 +122,11 @@ func TestRun_MergeFailsContinues(t *testing.T) { CopyEnvFiles: false, } - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} result := Run(runner, runner, opts, ec.Emit) - if len(result.Phases) != 3 { - t.Fatalf("phase count = %d, want 3 (continues despite merge failure)", len(result.Phases)) + if len(result.Phases) != 1 { + t.Fatalf("phase count = %d, want only the non-empty setup phase", len(result.Phases)) } if result.WorktreePath == "" { t.Error("WorktreePath should be set even with merge failure") @@ -167,7 +167,7 @@ func TestRun_CopyEnvFiles(t *testing.T) { }, } - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} result := Run(runner, runner, opts, ec.Emit) // Verify env file was copied @@ -189,11 +189,16 @@ func TestResult_HasFailures(t *testing.T) { result Result want bool }{ + { + name: "contract error", + result: Result{Err: fmt.Errorf("progress delivery failed")}, + want: true, + }, { name: "no failures", result: Result{ - Phases: []pipeline.Phase{ - {Steps: []pipeline.StepResult{{Status: pipeline.StepDone}, {Status: pipeline.StepSkipped}}}, + Phases: []progress.Phase{ + {Steps: []progress.StepResult{{Status: progress.StepDone}, {Status: progress.StepSkipped}}}, }, }, want: false, @@ -201,8 +206,8 @@ func TestResult_HasFailures(t *testing.T) { { name: "has failure", result: Result{ - Phases: []pipeline.Phase{ - {Steps: []pipeline.StepResult{{Status: pipeline.StepDone}, {Status: pipeline.StepFailed}}}, + Phases: []progress.Phase{ + {Steps: []progress.StepResult{{Status: progress.StepDone}, {Status: progress.StepFailed}}}, }, }, want: true, diff --git a/internal/creator/deps.go b/internal/creator/deps.go deleted file mode 100644 index f2b8e9a..0000000 --- a/internal/creator/deps.go +++ /dev/null @@ -1,98 +0,0 @@ -package creator - -import ( - "fmt" - "strings" - "sync" - - "github.com/abiswas97/sentei/internal/config" - "github.com/abiswas97/sentei/internal/ecosystem" - "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" -) - -const maxDepsConcurrency = 5 - -func runDeps(shell git.ShellRunner, wtPath string, opts Options, emit func(pipeline.Event)) pipeline.Phase { - phase := pipeline.Phase{Name: "Dependencies"} - - if len(opts.Ecosystems) == 0 { - return phase - } - - for _, eco := range opts.Ecosystems { - steps := installEcosystem(shell, wtPath, eco, emit) - phase.Steps = append(phase.Steps, steps...) - } - - return phase -} - -func installEcosystem(shell git.ShellRunner, wtPath string, eco config.EcosystemConfig, emit func(pipeline.Event)) []pipeline.StepResult { - if eco.Install.WorkspaceDetect == "" || eco.Install.WorkspaceInstall == "" { - rootStep := runInstallCommand(shell, wtPath, eco.Name, eco.Install.Command, emit) - return []pipeline.StepResult{rootStep} - } - - workspaces, err := ecosystem.DetectWorkspaces(wtPath, eco.Install.WorkspaceDetect) - if err != nil || len(workspaces) == 0 { - rootStep := runInstallCommand(shell, wtPath, eco.Name, eco.Install.Command, emit) - return []pipeline.StepResult{rootStep} - } - - var steps []pipeline.StepResult - - if eco.Install.IsParallel() { - wsSteps := installWorkspacesParallel(shell, wtPath, eco, workspaces, emit) - steps = append(steps, wsSteps...) - } else { - for _, ws := range workspaces { - cmd := strings.ReplaceAll(eco.Install.WorkspaceInstall, "{dir}", ws) - step := runInstallCommand(shell, wtPath, fmt.Sprintf("%s (%s)", eco.Name, ws), cmd, emit) - steps = append(steps, step) - } - } - - return steps -} - -func installWorkspacesParallel(shell git.ShellRunner, wtPath string, eco config.EcosystemConfig, workspaces []string, emit func(pipeline.Event)) []pipeline.StepResult { - results := make([]pipeline.StepResult, len(workspaces)) - sem := make(chan struct{}, maxDepsConcurrency) - var wg sync.WaitGroup - var mu sync.Mutex - safeEmit := func(e pipeline.Event) { - mu.Lock() - defer mu.Unlock() - emit(e) - } - - for i, ws := range workspaces { - wg.Add(1) - sem <- struct{}{} - - go func(idx int, workspace string) { - defer wg.Done() - defer func() { <-sem }() - - cmd := strings.ReplaceAll(eco.Install.WorkspaceInstall, "{dir}", workspace) - stepName := fmt.Sprintf("%s (%s)", eco.Name, workspace) - results[idx] = runInstallCommand(shell, wtPath, stepName, cmd, safeEmit) - }(i, ws) - } - - wg.Wait() - return results -} - -func runInstallCommand(shell git.ShellRunner, wtPath, stepName, command string, emit func(pipeline.Event)) pipeline.StepResult { - return pipeline.RunStep("Dependencies", stepName, emit, func() (string, error) { - if command == "" { - return "", fmt.Errorf("empty install command for %s", stepName) - } - if _, err := shell.RunShell(wtPath, command); err != nil { - return "", fmt.Errorf("installing %s: %w", stepName, err) - } - return "", nil - }) -} diff --git a/internal/creator/deps_test.go b/internal/creator/deps_test.go deleted file mode 100644 index a2a4409..0000000 --- a/internal/creator/deps_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package creator - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/abiswas97/sentei/internal/config" - "github.com/abiswas97/sentei/internal/pipeline" - "github.com/abiswas97/sentei/internal/testutil/mock" -) - -func boolPtr(b bool) *bool { - return &b -} - -func TestRunDeps_SingleEcosystem(t *testing.T) { - runner := &mock.Runner{Responses: map[string]mock.Response{ - "/repo/feature-auth:shell[pnpm install]": {Output: ""}, - }} - - opts := Options{ - Ecosystems: []config.EcosystemConfig{ - { - Name: "pnpm", - Install: config.InstallConfig{Command: "pnpm install"}, - }, - }, - } - - ec := &mock.EventCollector[pipeline.Event]{} - phase := runDeps(runner, "/repo/feature-auth", opts, ec.Emit) - - if phase.Name != "Dependencies" { - t.Errorf("phase name = %q, want %q", phase.Name, "Dependencies") - } - if len(phase.Steps) != 1 { - t.Fatalf("step count = %d, want 1", len(phase.Steps)) - } - if phase.Steps[0].Status != pipeline.StepDone { - t.Errorf("step status = %v, want pipeline.StepDone", phase.Steps[0].Status) - } -} - -func TestRunDeps_NoEcosystems(t *testing.T) { - runner := &mock.Runner{Responses: map[string]mock.Response{}} - - opts := Options{Ecosystems: nil} - ec := &mock.EventCollector[pipeline.Event]{} - phase := runDeps(runner, "/repo/feature-auth", opts, ec.Emit) - - if len(phase.Steps) != 0 { - t.Errorf("step count = %d, want 0", len(phase.Steps)) - } -} - -func TestRunDeps_InstallFailure(t *testing.T) { - runner := &mock.Runner{Responses: map[string]mock.Response{ - "/repo/feature-auth:shell[pnpm install]": {Err: fmt.Errorf("ENOENT")}, - }} - - opts := Options{ - Ecosystems: []config.EcosystemConfig{ - { - Name: "pnpm", - Install: config.InstallConfig{Command: "pnpm install"}, - }, - }, - } - - ec := &mock.EventCollector[pipeline.Event]{} - phase := runDeps(runner, "/repo/feature-auth", opts, ec.Emit) - - if phase.Steps[0].Status != pipeline.StepFailed { - t.Errorf("step status = %v, want pipeline.StepFailed", phase.Steps[0].Status) - } -} - -func TestRunDeps_ParallelWorkspaces(t *testing.T) { - tmpDir := t.TempDir() - wtPath := filepath.Join(tmpDir, "feature-auth") - os.MkdirAll(wtPath, 0755) - - pkgsUI := filepath.Join(wtPath, "packages", "ui") - pkgsCore := filepath.Join(wtPath, "packages", "core") - os.MkdirAll(pkgsUI, 0755) - os.MkdirAll(pkgsCore, 0755) - - wsYaml := "packages:\n - packages/*\n" - os.WriteFile(filepath.Join(wtPath, "pnpm-workspace.yaml"), []byte(wsYaml), 0644) - - runner := &mock.Runner{Responses: map[string]mock.Response{ - fmt.Sprintf("%s:shell[pnpm install --filter packages/ui]", wtPath): {Output: ""}, - fmt.Sprintf("%s:shell[pnpm install --filter packages/core]", wtPath): {Output: ""}, - }} - - opts := Options{ - Ecosystems: []config.EcosystemConfig{ - { - Name: "pnpm", - Install: config.InstallConfig{ - Command: "pnpm install", - WorkspaceDetect: "pnpm-workspace.yaml", - WorkspaceInstall: "pnpm install --filter {dir}", - Parallel: boolPtr(true), - }, - }, - }, - } - - ec := &mock.EventCollector[pipeline.Event]{} - phase := runDeps(runner, wtPath, opts, ec.Emit) - - // 2 workspace installs (root install skipped when workspaces detected) - if len(phase.Steps) != 2 { - t.Fatalf("step count = %d, want 2", len(phase.Steps)) - } - - // Verify all steps completed - for i, step := range phase.Steps { - if step.Status != pipeline.StepDone { - t.Errorf("step[%d] %q status = %v, want pipeline.StepDone", i, step.Name, step.Status) - } - } - - // Verify events contain "running" and "done" for each - runningCount := 0 - for _, e := range ec.Events { - if e.Status == pipeline.StepRunning { - runningCount++ - } - } - if runningCount < 2 { - t.Errorf("expected at least 2 running events, got %d", runningCount) - } -} - -func TestRunDeps_CommandParsing(t *testing.T) { - runner := &mock.Runner{Responses: map[string]mock.Response{ - "/wt:shell[go mod download]": {Output: ""}, - }} - - opts := Options{ - Ecosystems: []config.EcosystemConfig{ - { - Name: "go", - Install: config.InstallConfig{Command: "go mod download"}, - }, - }, - } - - ec := &mock.EventCollector[pipeline.Event]{} - runDeps(runner, "/wt", opts, ec.Emit) - - if len(runner.Calls) != 1 { - t.Fatalf("expected 1 call, got %d", len(runner.Calls)) - } - if !strings.Contains(runner.Calls[0], "shell[go mod download]") { - t.Errorf("call = %q, expected to contain 'shell[go mod download]'", runner.Calls[0]) - } -} diff --git a/internal/creator/e2e_test.go b/internal/creator/e2e_test.go index 500f8b8..08145d0 100644 --- a/internal/creator/e2e_test.go +++ b/internal/creator/e2e_test.go @@ -10,8 +10,7 @@ import ( "github.com/abiswas97/sentei/internal/config" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/integration" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" ) func TestE2E_CreateWorktree(t *testing.T) { @@ -74,8 +73,8 @@ func TestE2E_CreateWorktree(t *testing.T) { } shell := &git.DefaultShellRunner{} - var events []pipeline.Event - result := Run(runner, shell, opts, func(e pipeline.Event) { + var events []progress.Event + result := Run(runner, shell, opts, func(e progress.Event) { events = append(events, e) }) @@ -118,59 +117,10 @@ func TestE2E_CreateWorktree(t *testing.T) { for _, phase := range result.Phases { if phase.Name == "Setup" { for _, step := range phase.Steps { - if step.Status == pipeline.StepFailed && step.Name == "Create worktree" { + if step.Status == progress.StepFailed && step.Name == "Create worktree" { t.Errorf("create worktree step failed: %v", step.Error) } } } } } - -func TestE2E_Teardown(t *testing.T) { - if testing.Short() { - t.Skip("skipping E2E test in short mode") - } - - tmpDir := t.TempDir() - - // Create fake integration artifacts - crgDir := filepath.Join(tmpDir, ".code-review-graph") - os.MkdirAll(crgDir, 0755) - os.WriteFile(filepath.Join(crgDir, "graph.json"), []byte("{}"), 0644) - - cocDir := filepath.Join(tmpDir, ".cocoindex_code") - os.MkdirAll(cocDir, 0755) - os.WriteFile(filepath.Join(cocDir, "index.db"), []byte("data"), 0644) - - shell := &git.DefaultShellRunner{} - integrations := []integration.Integration{ - { - Name: "code-review-graph", - Teardown: integration.TeardownSpec{Dirs: []string{".code-review-graph/"}}, - }, - { - Name: "cocoindex-code", - Teardown: integration.TeardownSpec{Dirs: []string{".cocoindex_code/"}}, - }, - } - - var events []pipeline.Event - results := Teardown(shell, tmpDir, integrations, func(e pipeline.Event) { - events = append(events, e) - }) - - // Both should succeed - for _, r := range results { - if r.Status != pipeline.StepDone { - t.Errorf("teardown %q: status = %v, want pipeline.StepDone", r.Name, r.Status) - } - } - - // Verify directories removed - if _, err := os.Stat(crgDir); !os.IsNotExist(err) { - t.Error(".code-review-graph/ should be deleted") - } - if _, err := os.Stat(cocDir); !os.IsNotExist(err) { - t.Error(".cocoindex_code/ should be deleted") - } -} diff --git a/internal/creator/integrations.go b/internal/creator/integrations.go deleted file mode 100644 index 4204f7c..0000000 --- a/internal/creator/integrations.go +++ /dev/null @@ -1,212 +0,0 @@ -package creator - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/abiswas97/sentei/internal/fileutil" - "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/integration" - "github.com/abiswas97/sentei/internal/pipeline" -) - -func runIntegrations(shell git.ShellRunner, wtPath string, opts Options, emit func(pipeline.Event)) pipeline.Phase { - phase := pipeline.Phase{Name: "Integrations"} - - if len(opts.Integrations) == 0 { - return phase - } - - for _, integ := range opts.Integrations { - steps := setupIntegration(shell, wtPath, opts.RepoPath, opts.SourceWorktree, integ, emit) - phase.Steps = append(phase.Steps, steps...) - } - - return phase -} - -func setupIntegration(shell git.ShellRunner, wtPath, repoPath, sourceWorktree string, integ integration.Integration, emit func(pipeline.Event)) []pipeline.StepResult { - var steps []pipeline.StepResult - - if integ.IndexCopyDir != "" && sourceWorktree != "" { - stepName := "Copy index from main" - emit(pipeline.Event{Phase: "Integrations", Step: stepName, Status: pipeline.StepRunning}) - if err := copyIntegrationIndex(sourceWorktree, wtPath, integ.IndexCopyDir); err != nil { - emit(pipeline.Event{Phase: "Integrations", Step: stepName, Status: pipeline.StepSkipped, Message: err.Error()}) - } else { - emit(pipeline.Event{Phase: "Integrations", Step: stepName, Status: pipeline.StepDone}) - steps = append(steps, pipeline.StepResult{Name: stepName, Status: pipeline.StepDone}) - } - } - - installed := detectIntegration(shell, wtPath, integ) - - if !installed { - depSteps := checkAndInstallDeps(shell, wtPath, integ, emit) - steps = append(steps, depSteps...) - - for _, s := range depSteps { - if s.Status == pipeline.StepFailed { - return steps - } - } - - installStep := installIntegration(shell, wtPath, integ, emit) - steps = append(steps, installStep) - if installStep.Status == pipeline.StepFailed { - return steps - } - } - - setupStep := runSetupCommand(shell, wtPath, repoPath, integ, emit) - steps = append(steps, setupStep) - - if setupStep.Status != pipeline.StepFailed && len(integ.GitignoreEntries) > 0 { - if err := appendGitignore(wtPath, integ.GitignoreEntries); err != nil { - gitignoreStep := fmt.Sprintf("Gitignore %s", integ.Name) - emit(pipeline.Event{Phase: "Integrations", Step: gitignoreStep, Status: pipeline.StepFailed, Error: err}) - // Record the failure so HasFailures() and the summary reflect it; an - // emitted event alone is invisible to the result. - steps = append(steps, pipeline.StepResult{Name: gitignoreStep, Status: pipeline.StepFailed, Error: err}) - } - } - - return steps -} - -func detectIntegration(shell git.ShellRunner, wtPath string, integ integration.Integration) bool { - if integ.Detect.Command != "" { - _, err := shell.RunShell(wtPath, integ.Detect.Command) - return err == nil - } - if integ.Detect.BinaryName != "" { - if _, err := exec.LookPath(integ.Detect.BinaryName); err == nil { - return true - } - } - return false -} - -func checkAndInstallDeps(shell git.ShellRunner, wtPath string, integ integration.Integration, emit func(pipeline.Event)) []pipeline.StepResult { - var steps []pipeline.StepResult - - for _, dep := range integ.Dependencies { - stepName := fmt.Sprintf("Check %s", dep.Name) - emit(pipeline.Event{Phase: "Integrations", Step: stepName, Status: pipeline.StepRunning}) - - _, err := shell.RunShell(wtPath, dep.Detect) - if err == nil { - emit(pipeline.Event{Phase: "Integrations", Step: stepName, Status: pipeline.StepDone}) - steps = append(steps, pipeline.StepResult{Name: stepName, Status: pipeline.StepDone}) - continue - } - - if dep.Install == "" { - emit(pipeline.Event{Phase: "Integrations", Step: stepName, Status: pipeline.StepFailed, Error: fmt.Errorf("%s not found and no install command available", dep.Name)}) - steps = append(steps, pipeline.StepResult{ - Name: stepName, - Status: pipeline.StepFailed, - Error: fmt.Errorf("%s not found and no install command available", dep.Name), - }) - return steps - } - - installName := fmt.Sprintf("Install %s", dep.Name) - emit(pipeline.Event{Phase: "Integrations", Step: installName, Status: pipeline.StepRunning}) - _, installErr := shell.RunShell(wtPath, dep.Install) - if installErr != nil { - emit(pipeline.Event{Phase: "Integrations", Step: installName, Status: pipeline.StepFailed, Error: installErr}) - steps = append(steps, pipeline.StepResult{ - Name: installName, - Status: pipeline.StepFailed, - Error: fmt.Errorf("installing dependency %s: %w", dep.Name, installErr), - }) - return steps - } - - emit(pipeline.Event{Phase: "Integrations", Step: installName, Status: pipeline.StepDone}) - steps = append(steps, pipeline.StepResult{Name: installName, Status: pipeline.StepDone}) - } - - return steps -} - -func installIntegration(shell git.ShellRunner, wtPath string, integ integration.Integration, emit func(pipeline.Event)) pipeline.StepResult { - return pipeline.RunStep("Integrations", fmt.Sprintf("Install %s", integ.Name), emit, func() (string, error) { - if _, err := shell.RunShell(wtPath, integ.Install.Command); err != nil { - return "", fmt.Errorf("installing %s: %w", integ.Name, err) - } - return "", nil - }) -} - -func runSetupCommand(shell git.ShellRunner, wtPath, repoPath string, integ integration.Integration, emit func(pipeline.Event)) pipeline.StepResult { - stepName := fmt.Sprintf("Setup %s", integ.Name) - - if integ.Setup.Command == "" { - return pipeline.StepResult{Name: stepName, Status: pipeline.StepSkipped} - } - - return pipeline.RunStep("Integrations", stepName, emit, func() (string, error) { - // The worktree path embeds the branch name and is interpolated into a command - // run via `sh -c`; quote it so a branch like "a&&rm -rf x" cannot inject. - command := strings.ReplaceAll(integ.Setup.Command, "{path}", git.ShellQuote(wtPath)) - - runDir := wtPath - if integ.Setup.WorkingDir == "repo" { - runDir = repoPath - } - - if _, err := shell.RunShell(runDir, command); err != nil { - return "", fmt.Errorf("setting up %s: %w", integ.Name, err) - } - return "", nil - }) -} - -// copyIntegrationIndex copies the IndexCopyDir from source to target worktree. -func copyIntegrationIndex(sourceWT, targetWT, indexDir string) error { - srcDir := filepath.Join(sourceWT, indexDir) - if _, err := os.Stat(srcDir); os.IsNotExist(err) { - return fmt.Errorf("no index at %s", srcDir) - } - - dstDir := filepath.Join(targetWT, indexDir) - _ = os.RemoveAll(dstDir) - - return fileutil.CopyDir(srcDir, dstDir) -} - -func appendGitignore(dir string, entries []string) error { - gitignorePath := filepath.Join(dir, ".gitignore") - - existing, _ := os.ReadFile(gitignorePath) - content := string(existing) - - var toAdd []string - for _, entry := range entries { - if !strings.Contains(content, entry) { - toAdd = append(toAdd, entry) - } - } - - if len(toAdd) == 0 { - return nil - } - - f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return fmt.Errorf("opening .gitignore: %w", err) - } - defer func() { _ = f.Close() }() - - for _, entry := range toAdd { - if _, err := fmt.Fprintln(f, entry); err != nil { - return fmt.Errorf("writing to .gitignore: %w", err) - } - } - return nil -} diff --git a/internal/creator/integrations_test.go b/internal/creator/integrations_test.go deleted file mode 100644 index 79417ad..0000000 --- a/internal/creator/integrations_test.go +++ /dev/null @@ -1,283 +0,0 @@ -package creator - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/integration" - "github.com/abiswas97/sentei/internal/pipeline" - "github.com/abiswas97/sentei/internal/testutil/mock" -) - -func TestRunIntegrations_NoIntegrations(t *testing.T) { - runner := &mock.Runner{Responses: map[string]mock.Response{}} - opts := Options{Integrations: nil} - ec := &mock.EventCollector[pipeline.Event]{} - - phase := runIntegrations(runner, "/wt", opts, ec.Emit) - - if len(phase.Steps) != 0 { - t.Errorf("step count = %d, want 0", len(phase.Steps)) - } -} - -func TestRunIntegrations_AlreadyInstalled(t *testing.T) { - runner := &mock.Runner{Responses: map[string]mock.Response{ - "/wt:shell[code-review-graph --version]": {Output: "1.0.0"}, - "/repo:shell[code-review-graph build --repo '/wt']": {Output: "built"}, - }} - - opts := Options{ - RepoPath: "/repo", - Integrations: []integration.Integration{ - { - Name: "code-review-graph", - Detect: integration.DetectSpec{ - Command: "code-review-graph --version", - }, - Setup: integration.SetupSpec{ - Command: "code-review-graph build --repo {path}", - WorkingDir: "repo", - }, - GitignoreEntries: []string{".code-review-graph/"}, - }, - }, - } - - ec := &mock.EventCollector[pipeline.Event]{} - - phase := runIntegrations(runner, "/wt", opts, ec.Emit) - - // Should have steps: detect + setup - hasSetup := false - for _, s := range phase.Steps { - if strings.Contains(s.Name, "setup") || strings.Contains(s.Name, "Setup") { - hasSetup = true - } - } - if !hasSetup { - t.Error("expected setup step to be present") - } -} - -func TestRunIntegrations_InstallRequired(t *testing.T) { - runner := &mock.Runner{Responses: map[string]mock.Response{ - // Detect fails first time - "/wt:shell[code-review-graph --version]": {Err: fmt.Errorf("not found")}, - // Dependency checks - `/wt:shell[python3 -c "import sys; assert sys.version_info >= (3,10)"]`: {Output: ""}, - "/wt:shell[pipx --version]": {Output: "1.0"}, - // Install - "/wt:shell[pipx install code-review-graph]": {Output: "installed"}, - // Setup (working dir = repo, so runs from opts.RepoPath) - "/repo:shell[code-review-graph build --repo '/wt']": {Output: "built"}, - }} - - opts := Options{ - RepoPath: "/repo", - Integrations: []integration.Integration{ - { - Name: "code-review-graph", - Dependencies: []integration.Dependency{ - { - Name: "python3.10+", - Detect: `python3 -c "import sys; assert sys.version_info >= (3,10)"`, - }, - { - Name: "pipx", - Detect: "pipx --version", - Install: "brew install pipx", - }, - }, - Detect: integration.DetectSpec{ - Command: "code-review-graph --version", - }, - Install: integration.InstallSpec{ - Command: "pipx install code-review-graph", - }, - Setup: integration.SetupSpec{ - Command: "code-review-graph build --repo {path}", - WorkingDir: "repo", - }, - }, - }, - } - - ec := &mock.EventCollector[pipeline.Event]{} - - phase := runIntegrations(runner, "/wt", opts, ec.Emit) - - hasFailed := false - for _, s := range phase.Steps { - if s.Status == pipeline.StepFailed { - hasFailed = true - } - } - if hasFailed { - t.Error("expected no failures when install + setup succeed") - } -} - -func TestRunIntegrations_SetupFailure(t *testing.T) { - runner := &mock.Runner{Responses: map[string]mock.Response{ - "/wt:shell[ccc --version]": {Output: "1.0"}, - "/wt:shell[ccc init]": {Err: fmt.Errorf("init failed")}, - }} - - opts := Options{ - RepoPath: "/repo", - Integrations: []integration.Integration{ - { - Name: "cocoindex-code", - Detect: integration.DetectSpec{ - Command: "ccc --version", - }, - Setup: integration.SetupSpec{ - Command: "ccc init", - WorkingDir: "worktree", - }, - }, - }, - } - - ec := &mock.EventCollector[pipeline.Event]{} - phase := runIntegrations(runner, "/wt", opts, ec.Emit) - - hasFailed := false - for _, s := range phase.Steps { - if s.Status == pipeline.StepFailed { - hasFailed = true - } - } - if !hasFailed { - t.Error("expected a failure when setup command fails") - } -} - -func TestCopyIntegrationIndex_CopiesFromSource(t *testing.T) { - sourceDir := t.TempDir() - targetDir := t.TempDir() - - srcIndex := filepath.Join(sourceDir, ".cocoindex_code") - os.MkdirAll(srcIndex, 0755) - os.WriteFile(filepath.Join(srcIndex, "settings.yml"), []byte("test"), 0644) - - err := copyIntegrationIndex(sourceDir, targetDir, ".cocoindex_code") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - targetIndex := filepath.Join(targetDir, ".cocoindex_code", "settings.yml") - if _, err := os.Stat(targetIndex); os.IsNotExist(err) { - t.Error("expected settings.yml to be copied") - } -} - -func TestCopyIntegrationIndex_NoSourceIndex_ReturnsError(t *testing.T) { - sourceDir := t.TempDir() - targetDir := t.TempDir() - - err := copyIntegrationIndex(sourceDir, targetDir, ".cocoindex_code") - if err == nil { - t.Error("expected error when source index doesn't exist") - } -} - -func TestAppendGitignore(t *testing.T) { - tests := []struct { - name string - existing string - entries []string - want string - }{ - { - name: "adds new entries", - existing: "node_modules/\n", - entries: []string{".code-review-graph/"}, - want: "node_modules/\n.code-review-graph/\n", - }, - { - name: "skips existing entries", - existing: ".code-review-graph/\n", - entries: []string{".code-review-graph/"}, - want: ".code-review-graph/\n", - }, - { - name: "creates file if absent", - existing: "", - entries: []string{".code-review-graph/", ".cocoindex_code/"}, - want: ".code-review-graph/\n.cocoindex_code/\n", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - dir := t.TempDir() - gitignorePath := filepath.Join(dir, ".gitignore") - - if tt.existing != "" { - os.WriteFile(gitignorePath, []byte(tt.existing), 0644) - } - - appendGitignore(dir, tt.entries) - - got, _ := os.ReadFile(gitignorePath) - if string(got) != tt.want { - t.Errorf("gitignore content:\ngot: %q\nwant: %q", string(got), tt.want) - } - }) - } -} - -func TestRunSetupCommand_QuotesInjectingPath(t *testing.T) { - // A branch with shell metacharacters reaches the worktree path; the setup - // command must receive it single-quoted so it cannot inject. - wtPath := "/repo/a&&touch PWNED" - runner := &mock.Runner{Responses: map[string]mock.Response{ - fmt.Sprintf("/repo:shell[crg build %s]", git.ShellQuote(wtPath)): {Output: "ok"}, - }} - integ := integration.Integration{ - Name: "crg", - Setup: integration.SetupSpec{Command: "crg build {path}", WorkingDir: "repo"}, - } - ec := &mock.EventCollector[pipeline.Event]{} - step := runSetupCommand(runner, wtPath, "/repo", integ, ec.Emit) - if step.Status == pipeline.StepFailed { - t.Errorf("quoted setup command should have matched the mock, got failure: %v", step.Error) - } -} - -func TestRunIntegrations_GitignoreFailure_IsRecorded(t *testing.T) { - // wtPath does not exist, so appendGitignore fails. The failure must be - // recorded as a pipeline.StepResult (not just emitted), so HasFailures sees it. - wtPath := "/nonexistent/wt" - runner := &mock.Runner{Responses: map[string]mock.Response{ - "/nonexistent/wt:shell[crg --version]": {Output: "1.0"}, - fmt.Sprintf("/repo:shell[crg build %s]", git.ShellQuote(wtPath)): {Output: "ok"}, - }} - opts := Options{ - RepoPath: "/repo", - Integrations: []integration.Integration{{ - Name: "crg", - Detect: integration.DetectSpec{Command: "crg --version"}, - Setup: integration.SetupSpec{Command: "crg build {path}", WorkingDir: "repo"}, - GitignoreEntries: []string{".crg/"}, - }}, - } - ec := &mock.EventCollector[pipeline.Event]{} - phase := runIntegrations(runner, wtPath, opts, ec.Emit) - - gitignoreFailed := false - for _, s := range phase.Steps { - if strings.Contains(s.Name, "Gitignore") && s.Status == pipeline.StepFailed { - gitignoreFailed = true - } - } - if !gitignoreFailed { - t.Error("a gitignore append failure must be recorded as a pipeline.StepFailed result") - } -} diff --git a/internal/creator/preflight.go b/internal/creator/preflight.go new file mode 100644 index 0000000..c849040 --- /dev/null +++ b/internal/creator/preflight.go @@ -0,0 +1,172 @@ +package creator + +import ( + "encoding/json" + "fmt" + "path" + "sort" + "strings" + + "github.com/abiswas97/sentei/internal/config" + "github.com/abiswas97/sentei/internal/git" + "gopkg.in/yaml.v3" +) + +type dependencyTarget struct { + ecosystem config.EcosystemConfig + workspace string +} + +// prepareDependencyTargets reads workspace declarations from the base commit. +// It never examines a future worktree, so all dependency work is known before +// progress starts and before any mutating command can run. +func prepareDependencyTargets(runner git.CommandRunner, opts Options) ([]dependencyTarget, error) { + needsTree := false + for _, ecosystem := range opts.Ecosystems { + if strings.TrimSpace(ecosystem.Install.Command) == "" && strings.TrimSpace(ecosystem.Install.WorkspaceInstall) == "" { + continue + } + if ecosystem.Install.WorkspaceDetect != "" && ecosystem.Install.WorkspaceInstall != "" { + needsTree = true + } + } + if !needsTree { + return rootDependencyTargets(opts.Ecosystems), nil + } + + treeOutput, err := runner.Run(opts.RepoPath, "ls-tree", "-r", "--name-only", opts.BaseBranch) + if err != nil { + return nil, fmt.Errorf("reading base branch %q tree: %w", opts.BaseBranch, err) + } + treeFiles, treeDirs := indexTree(treeOutput) + var targets []dependencyTarget + for _, ecosystem := range opts.Ecosystems { + install := ecosystem.Install + if strings.TrimSpace(install.Command) == "" && strings.TrimSpace(install.WorkspaceInstall) == "" { + continue + } + manifest := path.Clean(install.WorkspaceDetect) + if install.WorkspaceDetect == "" || install.WorkspaceInstall == "" || !treeFiles[manifest] { + if strings.TrimSpace(install.Command) != "" { + targets = append(targets, dependencyTarget{ecosystem: ecosystem}) + } + continue + } + data, err := runner.Run(opts.RepoPath, "show", opts.BaseBranch+":"+manifest) + if err != nil { + return nil, fmt.Errorf("reading %s from base branch %q: %w", manifest, opts.BaseBranch, err) + } + patterns, err := workspacePatterns(manifest, []byte(data)) + if err != nil { + if strings.TrimSpace(install.Command) != "" { + targets = append(targets, dependencyTarget{ecosystem: ecosystem}) + } + continue + } + members, err := matchWorkspaceDirs(patterns, treeDirs) + if err != nil { + if strings.TrimSpace(install.Command) != "" { + targets = append(targets, dependencyTarget{ecosystem: ecosystem}) + } + continue + } + if len(members) == 0 { + if strings.TrimSpace(install.Command) != "" { + targets = append(targets, dependencyTarget{ecosystem: ecosystem}) + } + continue + } + for _, member := range members { + targets = append(targets, dependencyTarget{ecosystem: ecosystem, workspace: member}) + } + } + return targets, nil +} + +func rootDependencyTargets(ecosystems []config.EcosystemConfig) []dependencyTarget { + var targets []dependencyTarget + for _, ecosystem := range ecosystems { + if strings.TrimSpace(ecosystem.Install.Command) != "" { + targets = append(targets, dependencyTarget{ecosystem: ecosystem}) + } + } + return targets +} + +func indexTree(output string) (map[string]bool, map[string]bool) { + files := make(map[string]bool) + dirs := make(map[string]bool) + for _, line := range strings.Split(output, "\n") { + file := strings.TrimSpace(line) + if file == "" { + continue + } + file = path.Clean(file) + files[file] = true + for dir := path.Dir(file); dir != "." && dir != "/"; dir = path.Dir(dir) { + dirs[dir] = true + } + } + return files, dirs +} + +func workspacePatterns(manifest string, data []byte) ([]string, error) { + switch path.Base(manifest) { + case "pnpm-workspace.yaml": + var document struct { + Packages []string `yaml:"packages"` + } + if err := yaml.Unmarshal(data, &document); err != nil { + return nil, err + } + return document.Packages, nil + case "package.json": + var document struct { + Workspaces json.RawMessage `json:"workspaces"` + } + if err := json.Unmarshal(data, &document); err != nil { + return nil, err + } + if len(document.Workspaces) == 0 || string(document.Workspaces) == "null" { + return nil, nil + } + var patterns []string + if err := json.Unmarshal(document.Workspaces, &patterns); err == nil { + return patterns, nil + } + var object struct { + Packages []string `json:"packages"` + } + if err := json.Unmarshal(document.Workspaces, &object); err != nil { + return nil, fmt.Errorf("workspaces must be an array or packages object: %w", err) + } + return object.Packages, nil + default: + return nil, fmt.Errorf("unsupported workspace manifest %q", manifest) + } +} + +func matchWorkspaceDirs(patterns []string, dirs map[string]bool) ([]string, error) { + seen := make(map[string]bool) + for _, pattern := range patterns { + pattern = strings.TrimPrefix(path.Clean(pattern), "./") + if pattern == "." || strings.HasPrefix(pattern, "../") || path.IsAbs(pattern) { + return nil, fmt.Errorf("workspace pattern %q escapes repository root", pattern) + } + for dir := range dirs { + matched, err := path.Match(pattern, dir) + if err != nil { + return nil, fmt.Errorf("invalid workspace pattern %q: %w", pattern, err) + } + if matched { + seen[dir] = true + } + } + } + members := make([]string, 0, len(seen)) + for member := range seen { + members = append(members, member) + } + sort.Strings(members) + return members, nil +} diff --git a/internal/creator/preflight_test.go b/internal/creator/preflight_test.go new file mode 100644 index 0000000..455de9e --- /dev/null +++ b/internal/creator/preflight_test.go @@ -0,0 +1,168 @@ +package creator + +import ( + "errors" + "slices" + "strings" + "testing" + + "github.com/abiswas97/sentei/internal/config" + "github.com/abiswas97/sentei/internal/progress" + "github.com/abiswas97/sentei/internal/testutil/mock" +) + +func TestRun_MalformedBaseBranchManifestFallsBackToRootInstall(t *testing.T) { + runner := &mock.Runner{Responses: map[string]mock.Response{ + "/repo:[ls-tree -r --name-only main]": {Output: "package.json\npackages/api/package.json"}, + "/repo:[show main:package.json]": {Output: `{"workspaces":[`}, + "/repo:[show-ref --verify refs/heads/feature/manifest]": {Err: errors.New("not found")}, + "/repo:[worktree add /repo/feature-manifest -b feature/manifest main]": {}, + "/repo/feature-manifest:shell[npm install]": {}, + }} + + result := Run(runner, runner, workspaceOptions(), func(progress.Event) {}) + + if result.Err != nil || result.HasFailures() { + t.Fatalf("result = %#v, want successful root fallback", result) + } + if !slices.Contains(runner.Calls, "/repo/feature-manifest:shell[npm install]") { + t.Fatalf("calls = %v, want root install fallback", runner.Calls) + } +} + +func TestRun_UnreadableBaseBranchManifestReturnsErrorBeforeExecution(t *testing.T) { + readErr := errors.New("object unavailable") + runner := &mock.Runner{Responses: map[string]mock.Response{ + "/repo:[ls-tree -r --name-only main]": {Output: "package.json\npackages/api/package.json"}, + "/repo:[show main:package.json]": {Err: readErr}, + }} + + result := Run(runner, runner, workspaceOptions(), func(progress.Event) {}) + + if !errors.Is(result.Err, readErr) { + t.Fatalf("Err = %v, want wrapped %v", result.Err, readErr) + } + assertNoExecutionCalls(t, runner.Calls) +} + +func TestRun_MalformedPnpmBaseBranchManifestFallsBackToRootInstall(t *testing.T) { + runner := &mock.Runner{Responses: map[string]mock.Response{ + "/repo:[ls-tree -r --name-only main]": {Output: "pnpm-workspace.yaml\npackages/api/package.json"}, + "/repo:[show main:pnpm-workspace.yaml]": {Output: "packages: [unterminated"}, + "/repo:[show-ref --verify refs/heads/feature/manifest]": {Err: errors.New("not found")}, + "/repo:[worktree add /repo/feature-manifest -b feature/manifest main]": {}, + "/repo/feature-manifest:shell[npm install]": {}, + }} + opts := workspaceOptions() + opts.Ecosystems[0].Install.WorkspaceDetect = "pnpm-workspace.yaml" + + result := Run(runner, runner, opts, func(progress.Event) {}) + + if result.Err != nil || result.HasFailures() { + t.Fatalf("result = %#v, want successful root fallback", result) + } +} + +func TestRun_UnresolvableWorkspacePatternFallsBackToRootInstall(t *testing.T) { + runner := &mock.Runner{Responses: map[string]mock.Response{ + "/repo:[ls-tree -r --name-only main]": {Output: "package.json\npackages/api/package.json"}, + "/repo:[show main:package.json]": {Output: `{"workspaces":["packages/["]}`}, + "/repo:[show-ref --verify refs/heads/feature/manifest]": {Err: errors.New("not found")}, + "/repo:[worktree add /repo/feature-manifest -b feature/manifest main]": {}, + "/repo/feature-manifest:shell[npm install]": {}, + }} + + result := Run(runner, runner, workspaceOptions(), func(progress.Event) {}) + + if result.Err != nil || result.HasFailures() { + t.Fatalf("result = %#v, want successful root fallback", result) + } + if !slices.Contains(runner.Calls, "/repo/feature-manifest:shell[npm install]") { + t.Fatalf("calls = %v, want root install fallback", runner.Calls) + } +} + +func TestWorkspacePatterns_ParsesPnpmManifestStrictly(t *testing.T) { + got, err := workspacePatterns("pnpm-workspace.yaml", []byte("packages:\n - packages/*\n")) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(got, []string{"packages/*"}) { + t.Fatalf("patterns = %v", got) + } +} + +func TestRun_FreezesWorkspaceInstallsFromBaseBranchBeforeStart(t *testing.T) { + parallel := true + opts := workspaceOptions() + opts.Ecosystems[0].Install.Parallel = ¶llel + runner := &mock.Runner{Responses: map[string]mock.Response{ + "/repo:[ls-tree -r --name-only main]": {Output: "package.json\npackages/api/package.json\npackages/web/package.json"}, + "/repo:[show main:package.json]": {Output: `{"workspaces":["packages/*"]}`}, + "/repo:[show-ref --verify refs/heads/feature/manifest]": {Err: errors.New("not found")}, + "/repo:[worktree add /repo/feature-manifest -b feature/manifest main]": {}, + "/repo/feature-manifest:shell[npm --prefix packages/api install]": {}, + "/repo/feature-manifest:shell[npm --prefix packages/web install]": {}, + }} + var events []progress.Event + result := Run(runner, runner, opts, func(event progress.Event) { events = append(events, event) }) + + if result.Err != nil { + t.Fatalf("Err = %v", result.Err) + } + if slices.Contains(runner.Calls, "/repo/feature-manifest:shell[npm install]") { + t.Fatalf("root install ran despite frozen workspace members: %v", runner.Calls) + } + wantLabels := []string{"node (packages/api)", "node (packages/web)"} + var dependencyLabels []string + for _, event := range events { + if event.PhaseLabel == "Dependencies" && event.Status == progress.StepPending && !event.Close { + dependencyLabels = append(dependencyLabels, event.StepLabel) + } + } + if !slices.Equal(dependencyLabels, wantLabels) { + t.Fatalf("dependency declaration labels = %v, want %v", dependencyLabels, wantLabels) + } + firstRunning := slices.IndexFunc(events, func(event progress.Event) bool { return event.Status == progress.StepRunning }) + lastClose := slices.IndexFunc(events, func(event progress.Event) bool { return event.Close && event.PhaseLabel == "Dependencies" }) + if firstRunning < 0 || lastClose < 0 || lastClose > firstRunning { + t.Fatalf("declaration prefix was not closed before execution: %#v", events) + } +} + +func TestRun_UsesRootInstallWhenBaseBranchHasNoWorkspaceManifest(t *testing.T) { + runner := &mock.Runner{Responses: map[string]mock.Response{ + "/repo:[ls-tree -r --name-only main]": {Output: "go.mod"}, + "/repo:[show-ref --verify refs/heads/feature/manifest]": {Err: errors.New("not found")}, + "/repo:[worktree add /repo/feature-manifest -b feature/manifest main]": {}, + "/repo/feature-manifest:shell[npm install]": {}, + }} + + result := Run(runner, runner, workspaceOptions(), func(progress.Event) {}) + + if result.Err != nil || result.HasFailures() { + t.Fatalf("result = %#v, want successful root install", result) + } +} + +func workspaceOptions() Options { + return Options{ + BranchName: "feature/manifest", BaseBranch: "main", RepoPath: "/repo", + Ecosystems: []config.EcosystemConfig{{ + Name: "node", + Install: config.InstallConfig{ + Command: "npm install", WorkspaceDetect: "package.json", + WorkspaceInstall: "npm --prefix {dir} install", + }, + }}, + } +} + +func assertNoExecutionCalls(t *testing.T, calls []string) { + t.Helper() + for _, call := range calls { + if strings.Contains(call, "worktree add") || strings.Contains(call, ":shell[") { + t.Fatalf("execution call occurred during failed preflight: %v", calls) + } + } +} diff --git a/internal/creator/prepared.go b/internal/creator/prepared.go new file mode 100644 index 0000000..03c4ee3 --- /dev/null +++ b/internal/creator/prepared.go @@ -0,0 +1,298 @@ +package creator + +import ( + "crypto/sha256" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/abiswas97/sentei/internal/fileutil" + "github.com/abiswas97/sentei/internal/git" + "github.com/abiswas97/sentei/internal/integration" + "github.com/abiswas97/sentei/internal/progress" +) + +const ( + setupPhaseID progress.PhaseID = "setup" + dependenciesPhaseID progress.PhaseID = "dependencies" + integrationsPhaseID progress.PhaseID = "integrations" + maxDepsConcurrency = 5 +) + +type preparedDependency struct { + stepID progress.StepID + label string + command string + parallel bool + ecosystem string +} + +type preparedCreation struct { + opts Options + plan progress.Plan + worktreePath string + createStepID progress.StepID + mergeStepID progress.StepID + envStepID progress.StepID + envFiles []string + dependencies []preparedDependency + integrations integration.PreparedApply + hasDependencies bool + hasIntegrations bool +} + +func prepareCreation(runner git.CommandRunner, shell git.ShellRunner, opts Options) (preparedCreation, error) { + if strings.TrimSpace(opts.BranchName) == "" || strings.TrimSpace(opts.BaseBranch) == "" || strings.TrimSpace(opts.RepoPath) == "" { + return preparedCreation{}, errors.New("preparing worktree creation: branch, base branch, and repository path are required") + } + if err := validateEcosystemIdentities(opts); err != nil { + return preparedCreation{}, fmt.Errorf("preparing worktree creation: %w", err) + } + targets, err := prepareDependencyTargets(runner, opts) + if err != nil { + return preparedCreation{}, err + } + + prepared := preparedCreation{ + opts: opts, worktreePath: git.WorktreePath(opts.RepoPath, opts.BranchName), + createStepID: semanticStepID("create-worktree", opts.BranchName), + } + setup := progress.PlannedPhase{ID: setupPhaseID, Label: "Setup"} + setup.Steps = append(setup.Steps, progress.PlannedStep{ID: prepared.createStepID, Label: "Create worktree"}) + if opts.MergeBase { + prepared.mergeStepID = semanticStepID("merge-base", opts.BaseBranch) + setup.Steps = append(setup.Steps, progress.PlannedStep{ID: prepared.mergeStepID, Label: "Merge base branch"}) + } + if opts.CopyEnvFiles { + prepared.envFiles = uniqueEnvFiles(opts) + if len(prepared.envFiles) > 0 { + prepared.envStepID = semanticStepID("copy-env", strings.Join(prepared.envFiles, "\x00")) + setup.Steps = append(setup.Steps, progress.PlannedStep{ID: prepared.envStepID, Label: "Copy env files"}) + } + } + prepared.plan.Phases = append(prepared.plan.Phases, setup) + + seenDependencies := map[string]bool{} + dependencyPhase := progress.PlannedPhase{ID: dependenciesPhaseID, Label: "Dependencies"} + for _, target := range targets { + command := strings.TrimSpace(target.ecosystem.Install.Command) + label := target.ecosystem.Name + if target.workspace != "" { + command = strings.TrimSpace(strings.ReplaceAll(target.ecosystem.Install.WorkspaceInstall, "{dir}", target.workspace)) + label = fmt.Sprintf("%s (%s)", target.ecosystem.Name, target.workspace) + } + if command == "" { + continue + } + identity := target.ecosystem.Name + "\x00" + target.workspace + if seenDependencies[identity] { + return preparedCreation{}, fmt.Errorf("preparing worktree creation: duplicate dependency operation identity %q", label) + } + seenDependencies[identity] = true + operation := preparedDependency{ + stepID: semanticStepID("install-dependencies", identity), label: label, command: command, + parallel: target.ecosystem.Install.IsParallel(), ecosystem: target.ecosystem.Name, + } + prepared.dependencies = append(prepared.dependencies, operation) + dependencyPhase.Steps = append(dependencyPhase.Steps, progress.PlannedStep{ID: operation.stepID, Label: operation.label}) + } + if len(dependencyPhase.Steps) > 0 { + prepared.hasDependencies = true + prepared.plan.Phases = append(prepared.plan.Phases, dependencyPhase) + } + + if len(opts.Integrations) > 0 { + probeDir := opts.SourceWorktree + if probeDir == "" { + probeDir = opts.RepoPath + } + apply, err := integration.PrepareApplyForTarget(shell, opts.RepoPath, opts.SourceWorktree, probeDir, opts.Integrations, nil, []string{prepared.worktreePath}) + if err != nil { + return preparedCreation{}, err + } + apply, err = apply.BindPhase(integrationsPhaseID, "Integrations") + if err != nil { + return preparedCreation{}, err + } + if !apply.Empty() { + prepared.hasIntegrations = true + prepared.integrations = apply + prepared.plan.Phases = append(prepared.plan.Phases, apply.Plan().Phases...) + } + } + return prepared, nil +} + +func validateEcosystemIdentities(opts Options) error { + seen := make(map[string]bool, len(opts.Ecosystems)) + for _, ecosystem := range opts.Ecosystems { + name := strings.TrimSpace(ecosystem.Name) + if name == "" { + return errors.New("ecosystem has empty name") + } + if seen[name] { + return fmt.Errorf("duplicate ecosystem identity %q", name) + } + seen[name] = true + } + return nil +} + +func uniqueEnvFiles(opts Options) []string { + seen := map[string]bool{} + var files []string + for _, ecosystem := range opts.Ecosystems { + for _, name := range ecosystem.EnvFiles { + name = filepath.Clean(strings.TrimSpace(name)) + if name == "." || filepath.IsAbs(name) || strings.HasPrefix(name, ".."+string(filepath.Separator)) || seen[name] { + continue + } + seen[name] = true + files = append(files, name) + } + } + sort.Strings(files) + return files +} + +func (p preparedCreation) run(execution *progress.Execution, runner git.CommandRunner, shell git.ShellRunner, result *Result) error { + createResult, err := execution.Run(setupPhaseID, p.createStepID, func() (string, error) { + args := []string{"worktree", "add", p.worktreePath} + if git.BranchExists(runner, p.opts.RepoPath, p.opts.BranchName) { + args = append(args, p.opts.BranchName) + } else { + args = append(args, "-b", p.opts.BranchName, p.opts.BaseBranch) + } + if _, err := runner.Run(p.opts.RepoPath, args...); err != nil { + return "", fmt.Errorf("creating worktree: %w", err) + } + return p.worktreePath, nil + }) + if err != nil { + return fmt.Errorf("executing worktree creation: %w", err) + } + if createResult.Status == progress.StepFailed { + return p.skipBlocked(execution, "blocked by Create worktree") + } + result.WorktreePath = p.worktreePath + + if p.mergeStepID != "" { + _, err := execution.Run(setupPhaseID, p.mergeStepID, func() (string, error) { + _, err := runner.Run(p.worktreePath, "merge", p.opts.BaseBranch, "--no-edit") + return "", err + }) + if err != nil { + return fmt.Errorf("executing merge: %w", err) + } + } + if p.envStepID != "" { + _, err := execution.Run(setupPhaseID, p.envStepID, func() (string, error) { + return copyPreparedEnvFiles(p.opts.SourceWorktree, p.worktreePath, p.envFiles) + }) + if err != nil { + return fmt.Errorf("executing env copy: %w", err) + } + } + var runErr error + if p.hasDependencies { + runErr = errors.Join(runErr, p.runDependencies(execution, shell)) + } + if p.hasIntegrations { + runErr = errors.Join(runErr, p.integrations.RunIn(execution, shell)) + } + return runErr +} + +func (p preparedCreation) skipBlocked(execution *progress.Execution, reason string) error { + var err error + err = errors.Join(err, execution.SkipPending(setupPhaseID, reason)) + if p.hasDependencies { + err = errors.Join(err, execution.SkipPending(dependenciesPhaseID, reason)) + } + if p.hasIntegrations { + err = errors.Join(err, execution.SkipPending(integrationsPhaseID, reason)) + } + return err +} + +func (p preparedCreation) runDependencies(execution *progress.Execution, shell git.ShellRunner) error { + groups := make([][]preparedDependency, 0) + for _, dependency := range p.dependencies { + if len(groups) == 0 || groups[len(groups)-1][0].ecosystem != dependency.ecosystem { + groups = append(groups, []preparedDependency{dependency}) + } else { + groups[len(groups)-1] = append(groups[len(groups)-1], dependency) + } + } + var runErr error + for _, group := range groups { + if len(group) == 1 || !group[0].parallel { + for _, dependency := range group { + runErr = errors.Join(runErr, runPreparedDependency(execution, shell, p.worktreePath, dependency)) + } + continue + } + sem := make(chan struct{}, maxDepsConcurrency) + var wg sync.WaitGroup + var mu sync.Mutex + for _, dependency := range group { + dependency := dependency + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + err := runPreparedDependency(execution, shell, p.worktreePath, dependency) + if err != nil { + mu.Lock() + runErr = errors.Join(runErr, err) + mu.Unlock() + } + }() + } + wg.Wait() + } + return runErr +} + +func runPreparedDependency(execution *progress.Execution, shell git.ShellRunner, worktreePath string, dependency preparedDependency) error { + _, err := execution.Run(dependenciesPhaseID, dependency.stepID, func() (string, error) { + _, err := shell.RunShell(worktreePath, dependency.command) + if err != nil { + return "", fmt.Errorf("installing %s: %w", dependency.label, err) + } + return "", nil + }) + if err != nil { + return fmt.Errorf("executing dependency %s: %w", dependency.label, err) + } + return nil +} + +func copyPreparedEnvFiles(source, destination string, files []string) (string, error) { + var copied []string + for _, name := range files { + if _, err := os.Stat(filepath.Join(source, name)); errors.Is(err, os.ErrNotExist) { + continue + } else if err != nil { + return "", fmt.Errorf("inspecting %s: %w", name, err) + } + if err := fileutil.CopyFile(filepath.Join(source, name), filepath.Join(destination, name)); err != nil { + return "", fmt.Errorf("copying %s: %w", name, err) + } + copied = append(copied, name) + } + if len(copied) == 0 { + return "no source files found", nil + } + return strings.Join(copied, ", "), nil +} + +func semanticStepID(kind, identity string) progress.StepID { + sum := sha256.Sum256([]byte(identity)) + return progress.StepID(fmt.Sprintf("%s:%x", kind, sum[:8])) +} diff --git a/internal/creator/setup.go b/internal/creator/setup.go deleted file mode 100644 index 0a8668a..0000000 --- a/internal/creator/setup.go +++ /dev/null @@ -1,115 +0,0 @@ -package creator - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/abiswas97/sentei/internal/fileutil" - "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" -) - -func runSetup(runner git.CommandRunner, opts Options, emit func(pipeline.Event)) pipeline.Phase { - phase := pipeline.Phase{Name: "Setup"} - - wtResult, wtPath := createWorktreeStep(runner, opts.RepoPath, opts.BranchName, opts.BaseBranch, emit) - phase.Steps = append(phase.Steps, wtResult) - - if wtResult.Status == pipeline.StepFailed { - return phase - } - - mergeResult := mergeBaseStep(runner, wtPath, opts.BaseBranch, opts.MergeBase, emit) - phase.Steps = append(phase.Steps, mergeResult) - - var envFiles []string - for _, eco := range opts.Ecosystems { - envFiles = append(envFiles, eco.EnvFiles...) - } - if opts.CopyEnvFiles { - envResult := copyEnvFilesStep(opts.SourceWorktree, wtPath, envFiles, emit) - phase.Steps = append(phase.Steps, envResult) - } else { - phase.Steps = append(phase.Steps, pipeline.StepResult{ - Name: "Copy env files", - Status: pipeline.StepSkipped, - }) - } - - return phase -} - -func createWorktreeStep(runner git.CommandRunner, repoPath, branch, baseBranch string, emit func(pipeline.Event)) (pipeline.StepResult, string) { - wtPath := git.WorktreePath(repoPath, branch) - - result := pipeline.RunStep("Setup", "Create worktree", emit, func() (string, error) { - var err error - if git.BranchExists(runner, repoPath, branch) { - _, err = runner.Run(repoPath, "worktree", "add", wtPath, branch) - } else { - _, err = runner.Run(repoPath, "worktree", "add", wtPath, "-b", branch, baseBranch) - } - if err != nil { - return "", fmt.Errorf("creating worktree: %w", err) - } - return wtPath, nil - }) - if result.Status == pipeline.StepFailed { - return result, "" - } - return result, wtPath -} - -func mergeBaseStep(runner git.CommandRunner, wtPath, baseBranch string, enabled bool, emit func(pipeline.Event)) pipeline.StepResult { - stepName := "Merge base branch" - - if !enabled { - return pipeline.StepResult{Name: stepName, Status: pipeline.StepSkipped} - } - - emit(pipeline.Event{Phase: "Setup", Step: stepName, Status: pipeline.StepRunning}) - - _, err := runner.Run(wtPath, "merge", baseBranch, "--no-edit") - if err != nil { - emit(pipeline.Event{Phase: "Setup", Step: stepName, Status: pipeline.StepFailed, Error: err, Message: "merge conflict — resolve manually"}) - return pipeline.StepResult{ - Name: stepName, - Status: pipeline.StepFailed, - Message: "merge conflict — resolve manually", - Error: err, - } - } - - emit(pipeline.Event{Phase: "Setup", Step: stepName, Status: pipeline.StepDone}) - return pipeline.StepResult{Name: stepName, Status: pipeline.StepDone} -} - -func copyEnvFilesStep(srcDir, dstDir string, envFiles []string, emit func(pipeline.Event)) pipeline.StepResult { - stepName := "Copy env files" - - if len(envFiles) == 0 { - return pipeline.StepResult{Name: stepName, Status: pipeline.StepSkipped} - } - - return pipeline.RunStep("Setup", stepName, emit, func() (string, error) { - var copied []string - for _, name := range envFiles { - src := filepath.Join(srcDir, name) - if _, err := os.Stat(src); os.IsNotExist(err) { - continue - } - - if err := fileutil.CopyFile(src, filepath.Join(dstDir, name)); err != nil { - return "", fmt.Errorf("copying %s: %w", name, err) - } - copied = append(copied, name) - } - - if len(copied) == 0 { - return "no source files found", nil - } - return strings.Join(copied, ", "), nil - }) -} diff --git a/internal/creator/setup_test.go b/internal/creator/setup_test.go deleted file mode 100644 index e1c23eb..0000000 --- a/internal/creator/setup_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package creator - -import ( - "fmt" - "os" - "path/filepath" - "testing" - - "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" - "github.com/abiswas97/sentei/internal/testutil/mock" -) - -func TestCreateWorktreeStep(t *testing.T) { - tests := []struct { - name string - branch string - baseBranch string - repoPath string - branchExists bool - runnerErr error - wantStatus pipeline.StepStatus - wantPath string - }{ - { - name: "successful creation with new branch", - branch: "feature/auth", - baseBranch: "main", - repoPath: "/repo", - wantStatus: pipeline.StepDone, - wantPath: "/repo/feature-auth", - }, - { - name: "checks out existing branch into new worktree", - branch: "feature/dup", - baseBranch: "main", - repoPath: "/repo", - branchExists: true, - wantStatus: pipeline.StepDone, - wantPath: "/repo/feature-dup", - }, - { - name: "worktree add failure is surfaced", - branch: "feature/broken", - baseBranch: "main", - repoPath: "/repo", - runnerErr: fmt.Errorf("fatal: something went wrong"), - wantStatus: pipeline.StepFailed, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - wtPath := git.WorktreePath(tt.repoPath, tt.branch) - - responses := map[string]mock.Response{ - fmt.Sprintf("%s:[show-ref --verify refs/heads/%s]", tt.repoPath, tt.branch): { - Err: func() error { - if tt.branchExists { - return nil - } - return fmt.Errorf("not found") - }(), - }, - } - if tt.branchExists { - responses[fmt.Sprintf("%s:[worktree add %s %s]", tt.repoPath, wtPath, tt.branch)] = mock.Response{Err: tt.runnerErr} - } else { - responses[fmt.Sprintf("%s:[worktree add %s -b %s %s]", tt.repoPath, wtPath, tt.branch, tt.baseBranch)] = mock.Response{Err: tt.runnerErr} - } - runner := &mock.Runner{Responses: responses} - - ec := &mock.EventCollector[pipeline.Event]{} - result, path := createWorktreeStep(runner, tt.repoPath, tt.branch, tt.baseBranch, ec.Emit) - - if result.Status != tt.wantStatus { - t.Errorf("status = %v, want %v", result.Status, tt.wantStatus) - } - if tt.wantStatus == pipeline.StepDone && path != tt.wantPath { - t.Errorf("path = %q, want %q", path, tt.wantPath) - } - if len(ec.Events) == 0 { - t.Error("expected at least one event emitted") - } - }) - } -} - -func TestMergeBaseStep(t *testing.T) { - tests := []struct { - name string - mergeBase bool - baseBranch string - runnerErr error - wantStatus pipeline.StepStatus - }{ - { - name: "successful merge", - mergeBase: true, - baseBranch: "main", - wantStatus: pipeline.StepDone, - }, - { - name: "merge conflict continues", - mergeBase: true, - baseBranch: "main", - runnerErr: fmt.Errorf("merge conflict"), - wantStatus: pipeline.StepFailed, - }, - { - name: "merge disabled", - mergeBase: false, - baseBranch: "main", - wantStatus: pipeline.StepSkipped, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - runner := &mock.Runner{Responses: map[string]mock.Response{ - "/repo/feature-auth:[merge main --no-edit]": { - Output: "", - Err: tt.runnerErr, - }, - }} - - ec := &mock.EventCollector[pipeline.Event]{} - result := mergeBaseStep(runner, "/repo/feature-auth", tt.baseBranch, tt.mergeBase, ec.Emit) - - if result.Status != tt.wantStatus { - t.Errorf("status = %v, want %v", result.Status, tt.wantStatus) - } - }) - } -} - -func TestCopyEnvFilesStep(t *testing.T) { - tests := []struct { - name string - envFiles []string - srcFiles []string - wantStatus pipeline.StepStatus - }{ - { - name: "copies existing files", - envFiles: []string{".env", ".env.local"}, - srcFiles: []string{".env"}, - wantStatus: pipeline.StepDone, - }, - { - name: "no env files configured", - envFiles: nil, - wantStatus: pipeline.StepSkipped, - }, - { - name: "no source files exist", - envFiles: []string{".env"}, - srcFiles: nil, - wantStatus: pipeline.StepDone, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - srcDir := t.TempDir() - dstDir := t.TempDir() - - for _, f := range tt.srcFiles { - os.WriteFile(filepath.Join(srcDir, f), []byte("SECRET=val"), 0644) - } - - ec := &mock.EventCollector[pipeline.Event]{} - result := copyEnvFilesStep(srcDir, dstDir, tt.envFiles, ec.Emit) - - if result.Status != tt.wantStatus { - t.Errorf("status = %v, want %v", result.Status, tt.wantStatus) - } - - for _, f := range tt.srcFiles { - dstPath := filepath.Join(dstDir, f) - if _, err := os.Stat(dstPath); os.IsNotExist(err) { - t.Errorf("expected %s to be copied to dest", f) - } - } - }) - } -} diff --git a/internal/creator/teardown.go b/internal/creator/teardown.go index e4ebb62..3dce7c8 100644 --- a/internal/creator/teardown.go +++ b/internal/creator/teardown.go @@ -1,14 +1,11 @@ package creator import ( - "fmt" "os" "path/filepath" "strings" - "github.com/abiswas97/sentei/internal/git" "github.com/abiswas97/sentei/internal/integration" - "github.com/abiswas97/sentei/internal/pipeline" ) // ArtifactInfo describes the artifact directories found for an integration. @@ -40,57 +37,3 @@ func ScanArtifacts(wtPath string, integrations []integration.Integration) []Arti return found } - -// Teardown removes integration artifacts from wtPath. For each integration that has -// artifacts present, it runs the teardown command if configured; if the command fails -// or is absent, it falls back to deleting the artifact directories directly. -func Teardown(shell git.ShellRunner, wtPath string, integrations []integration.Integration, emit func(pipeline.Event)) []pipeline.StepResult { - artifacts := ScanArtifacts(wtPath, integrations) - if len(artifacts) == 0 { - return nil - } - - var results []pipeline.StepResult - - for _, artifact := range artifacts { - integ := findIntegration(integrations, artifact.IntegrationName) - if integ == nil { - continue - } - - stepName := fmt.Sprintf("Teardown %s", integ.Name) - result := pipeline.RunStep("Teardown", stepName, emit, func() (string, error) { - if integ.Teardown.Command != "" { - if _, err := shell.RunShell(wtPath, integ.Teardown.Command); err == nil { - return "", nil - } - } - - // The command failed or is absent: fall back to deleting the - // artifact directories directly. - allRemoved := true - for _, dir := range artifact.Dirs { - cleanDir := strings.TrimSuffix(dir, "/") - if err := os.RemoveAll(filepath.Join(wtPath, cleanDir)); err != nil { - allRemoved = false - } - } - if !allRemoved { - return "", fmt.Errorf("failed to remove some artifact dirs for %s", integ.Name) - } - return "removed artifact dirs", nil - }) - results = append(results, result) - } - - return results -} - -func findIntegration(integrations []integration.Integration, name string) *integration.Integration { - for i := range integrations { - if integrations[i].Name == name { - return &integrations[i] - } - } - return nil -} diff --git a/internal/creator/teardown_test.go b/internal/creator/teardown_test.go index 15b58d0..f99f434 100644 --- a/internal/creator/teardown_test.go +++ b/internal/creator/teardown_test.go @@ -1,14 +1,11 @@ package creator import ( - "fmt" "os" "path/filepath" "testing" "github.com/abiswas97/sentei/internal/integration" - "github.com/abiswas97/sentei/internal/pipeline" - "github.com/abiswas97/sentei/internal/testutil/mock" ) func TestScanArtifacts(t *testing.T) { @@ -18,175 +15,25 @@ func TestScanArtifacts(t *testing.T) { integs []integration.Integration wantCount int }{ - { - name: "finds code-review-graph artifacts", - dirs: []string{".code-review-graph"}, - integs: []integration.Integration{ - { - Name: "code-review-graph", - Teardown: integration.TeardownSpec{ - Dirs: []string{".code-review-graph/"}, - }, - }, - }, - wantCount: 1, - }, - { - name: "no artifacts present", - dirs: nil, - integs: []integration.Integration{ - { - Name: "code-review-graph", - Teardown: integration.TeardownSpec{ - Dirs: []string{".code-review-graph/"}, - }, - }, - }, - wantCount: 0, - }, - { - name: "multiple integration artifacts", - dirs: []string{".code-review-graph", ".cocoindex_code"}, - integs: []integration.Integration{ - { - Name: "code-review-graph", - Teardown: integration.TeardownSpec{Dirs: []string{".code-review-graph/"}}, - }, - { - Name: "cocoindex-code", - Teardown: integration.TeardownSpec{Dirs: []string{".cocoindex_code/"}}, - }, - }, - wantCount: 2, - }, + {name: "finds artifacts", dirs: []string{".code-review-graph"}, integs: []integration.Integration{{Name: "code-review-graph", Teardown: integration.TeardownSpec{Dirs: []string{".code-review-graph/"}}}}, wantCount: 1}, + {name: "no artifacts", integs: []integration.Integration{{Name: "code-review-graph", Teardown: integration.TeardownSpec{Dirs: []string{".code-review-graph/"}}}}}, + {name: "multiple artifacts", dirs: []string{".code-review-graph", ".cocoindex_code"}, integs: []integration.Integration{ + {Name: "code-review-graph", Teardown: integration.TeardownSpec{Dirs: []string{".code-review-graph/"}}}, + {Name: "cocoindex-code", Teardown: integration.TeardownSpec{Dirs: []string{".cocoindex_code/"}}}, + }, wantCount: 2}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { wtDir := t.TempDir() - for _, d := range tt.dirs { - os.MkdirAll(filepath.Join(wtDir, d), 0755) + for _, dir := range tt.dirs { + if err := os.MkdirAll(filepath.Join(wtDir, dir), 0o755); err != nil { + t.Fatal(err) + } } - - artifacts := ScanArtifacts(wtDir, tt.integs) - if len(artifacts) != tt.wantCount { + if artifacts := ScanArtifacts(wtDir, tt.integs); len(artifacts) != tt.wantCount { t.Errorf("artifact count = %d, want %d", len(artifacts), tt.wantCount) } }) } } - -func TestTeardown_WithCommand(t *testing.T) { - wtDir := t.TempDir() - os.MkdirAll(filepath.Join(wtDir, ".cocoindex_code"), 0755) - - runner := &mock.Runner{Responses: map[string]mock.Response{ - wtDir + ":shell[ccc reset --all --force]": {Output: "reset"}, - }} - - integs := []integration.Integration{ - { - Name: "cocoindex-code", - Teardown: integration.TeardownSpec{ - Command: "ccc reset --all --force", - Dirs: []string{".cocoindex_code/"}, - }, - }, - } - - ec := &mock.EventCollector[pipeline.Event]{} - results := Teardown(runner, wtDir, integs, ec.Emit) - - if len(results) != 1 { - t.Fatalf("result count = %d, want 1", len(results)) - } - if results[0].Status != pipeline.StepDone { - t.Errorf("status = %v, want pipeline.StepDone", results[0].Status) - } -} - -func TestTeardown_CommandFailsFallsBackToDirDelete(t *testing.T) { - wtDir := t.TempDir() - artifactDir := filepath.Join(wtDir, ".cocoindex_code") - os.MkdirAll(artifactDir, 0755) - os.WriteFile(filepath.Join(artifactDir, "index.db"), []byte("data"), 0644) - - runner := &mock.Runner{Responses: map[string]mock.Response{ - wtDir + ":shell[ccc reset --all --force]": {Err: fmt.Errorf("command not found")}, - }} - - integs := []integration.Integration{ - { - Name: "cocoindex-code", - Teardown: integration.TeardownSpec{ - Command: "ccc reset --all --force", - Dirs: []string{".cocoindex_code/"}, - }, - }, - } - - ec := &mock.EventCollector[pipeline.Event]{} - results := Teardown(runner, wtDir, integs, ec.Emit) - - if len(results) != 1 { - t.Fatalf("result count = %d, want 1", len(results)) - } - if results[0].Status != pipeline.StepDone { - t.Errorf("status = %v, want pipeline.StepDone (fallback should succeed)", results[0].Status) - } - - if _, err := os.Stat(artifactDir); !os.IsNotExist(err) { - t.Error("expected artifact directory to be deleted") - } -} - -func TestTeardown_NoArtifacts(t *testing.T) { - wtDir := t.TempDir() - runner := &mock.Runner{Responses: map[string]mock.Response{}} - - integs := []integration.Integration{ - { - Name: "code-review-graph", - Teardown: integration.TeardownSpec{Dirs: []string{".code-review-graph/"}}, - }, - } - - ec := &mock.EventCollector[pipeline.Event]{} - results := Teardown(runner, wtDir, integs, ec.Emit) - - if len(results) != 0 { - t.Errorf("result count = %d, want 0 (no artifacts)", len(results)) - } - if len(ec.Events) != 0 { - t.Errorf("event count = %d, want 0", len(ec.Events)) - } -} - -func TestTeardown_DirOnlyNoCommand(t *testing.T) { - wtDir := t.TempDir() - artifactDir := filepath.Join(wtDir, ".code-review-graph") - os.MkdirAll(artifactDir, 0755) - - runner := &mock.Runner{Responses: map[string]mock.Response{}} - - integs := []integration.Integration{ - { - Name: "code-review-graph", - Teardown: integration.TeardownSpec{Dirs: []string{".code-review-graph/"}}, - }, - } - - ec := &mock.EventCollector[pipeline.Event]{} - results := Teardown(runner, wtDir, integs, ec.Emit) - - if len(results) != 1 { - t.Fatalf("result count = %d, want 1", len(results)) - } - if results[0].Status != pipeline.StepDone { - t.Errorf("status = %v, want pipeline.StepDone", results[0].Status) - } - - if _, err := os.Stat(artifactDir); !os.IsNotExist(err) { - t.Error("expected artifact directory to be deleted") - } -} diff --git a/internal/integration/apply.go b/internal/integration/apply.go new file mode 100644 index 0000000..475c2e4 --- /dev/null +++ b/internal/integration/apply.go @@ -0,0 +1,620 @@ +package integration + +import ( + "crypto/sha256" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/abiswas97/sentei/internal/fileutil" + "github.com/abiswas97/sentei/internal/git" + "github.com/abiswas97/sentei/internal/progress" +) + +const prerequisitesPhase progress.PhaseID = "prerequisites" + +func SetupStepName(integ Integration) string { return "Setup " + integ.Name } +func InstallStepName(integ Integration) string { return "Install " + integ.Name } +func InstallDependencyStepName(dep Dependency) string { return "Install dependency " + dep.Name } +func TeardownStepName(integ Integration) string { return "Teardown " + integ.Name } +func RemoveDirStepName(dir, wtPath string) string { + return fmt.Sprintf("Remove %s in %s", strings.TrimSuffix(dir, "/"), filepath.Base(wtPath)) +} + +type applyOperationKind uint8 + +const ( + applyShellCommand applyOperationKind = iota + applyFailure + applyRemove + applyCopy + applyGitignore +) + +type applyOperation struct { + phaseID progress.PhaseID + phaseName string + stepID progress.StepID + label string + kind applyOperationKind + dir string + command string + failure error + dependsOn []string + + seedSource string + seedDest string + seedSourceRoot string + seedSourcePath string + seedDestRoot string + seedDestPath string + managedRoot string + managedPath string + gitignore []string + gitignoreDir string +} + +func (op applyOperation) key() string { return op.phaseID + "\x00" + op.stepID } + +// PreparedApply is an exact progress declaration plus the frozen operations +// that implement it. Run performs no detection. +type PreparedApply struct { + plan progress.Plan + operations []applyOperation + files applyFileOperations +} + +type applyFileOperations interface { + removeAll(path string) error + copyDir(source, destination string) error + appendGitignore(dir string, entries []string) error +} + +type realApplyFileOperations struct{} + +func (realApplyFileOperations) removeAll(path string) error { return os.RemoveAll(path) } +func (realApplyFileOperations) copyDir(source, destination string) error { + return fileutil.CopyDir(source, destination) +} +func (realApplyFileOperations) appendGitignore(dir string, entries []string) error { + return appendGitignoreEntries(dir, entries) +} + +// Plan returns a defensive copy of the frozen progress declaration. +func (p PreparedApply) Plan() progress.Plan { return p.plan.Clone() } + +// Empty reports whether preparation froze no work to execute. +func (p PreparedApply) Empty() bool { return len(p.operations) == 0 } + +// PrepareApply performs the read-only detection pass once in the first target +// worktree, then freezes both the declaration and every execution decision. +func PrepareApply(shell git.ShellRunner, repoPath, mainWT string, toEnable, toDisable []Integration, wtPaths []string) (PreparedApply, error) { + probeDir := "" + if len(wtPaths) > 0 { + probeDir = wtPaths[0] + } + return PrepareApplyForTarget(shell, repoPath, mainWT, probeDir, toEnable, toDisable, wtPaths) +} + +// PrepareApplyForTarget separates the existing worktree used for read-only +// availability probes from future target worktrees. This lets callers freeze +// an apply plan before any target is created. +func PrepareApplyForTarget(shell git.ShellRunner, repoPath, mainWT, probeDir string, toEnable, toDisable []Integration, wtPaths []string) (PreparedApply, error) { + if (len(toEnable) > 0 || len(toDisable) > 0) && len(wtPaths) == 0 { + return PreparedApply{}, errors.New("preparing integrations: no target worktree") + } + if err := validateApplyInputs(toEnable, toDisable); err != nil { + return PreparedApply{}, fmt.Errorf("preparing integrations: %w", err) + } + if err := validateManagedPaths(mainWT, toEnable, toDisable, wtPaths); err != nil { + return PreparedApply{}, fmt.Errorf("preparing integrations: %w", err) + } + type enabledDecision struct { + integration Integration + key string + installed bool + toolOpKey string + } + decisions := make([]enabledDecision, 0, len(toEnable)) + dependencyPresent := map[string]bool{} + dependencySpecs := map[string]Dependency{} + dependencyOp := map[string]applyOperation{} + var prerequisites []applyOperation + + for _, integ := range toEnable { + decision := enabledDecision{integration: integ, key: integ.Name} + decision.installed = detectForApply(shell, probeDir, integ.Detect) + for _, dep := range integ.Dependencies { + if _, checked := dependencyPresent[dep.Name]; checked { + continue + } + _, err := shell.RunShell(probeDir, dep.Detect) + dependencyPresent[dep.Name] = err == nil + dependencySpecs[dep.Name] = dep + } + decisions = append(decisions, decision) + } + + for _, decision := range decisions { + if decision.installed { + continue + } + for _, dependency := range decision.integration.Dependencies { + if dependencyPresent[dependency.Name] { + continue + } + if _, planned := dependencyOp[dependency.Name]; planned { + continue + } + dep := dependencySpecs[dependency.Name] + op := applyOperation{ + phaseID: prerequisitesPhase, phaseName: "Prerequisites", + stepID: stableStepID("dependency", dep.Name), label: InstallDependencyStepName(dep), + dir: probeDir, + } + if dep.Install == "" { + op.kind = applyFailure + op.failure = fmt.Errorf("dependency %s is missing and has no install command", dep.Name) + } else { + op.kind = applyShellCommand + op.command = dep.Install + } + dependencyOp[dep.Name] = op + prerequisites = append(prerequisites, op) + } + } + + for i := range decisions { + decision := &decisions[i] + if decision.installed { + continue + } + op := applyOperation{ + phaseID: prerequisitesPhase, phaseName: "Prerequisites", + stepID: stableStepID("install", decision.key), label: InstallStepName(decision.integration), + kind: applyShellCommand, dir: probeDir, command: decision.integration.Install.Command, + } + if op.command == "" { + op.kind = applyFailure + op.failure = fmt.Errorf("%s is missing and has no install command", decision.integration.Name) + } + for _, dep := range decision.integration.Dependencies { + if depOp, missing := dependencyOp[dep.Name]; missing { + op.dependsOn = append(op.dependsOn, depOp.key()) + } + } + decision.toolOpKey = op.key() + prerequisites = append(prerequisites, op) + } + + operations := append([]applyOperation(nil), prerequisites...) + for _, wtPath := range wtPaths { + phaseID := progress.PhaseID("worktree:" + stableToken(normalizeWorkspaceIdentity(wtPath))) + for _, decision := range decisions { + integ := decision.integration + dependencies := []string(nil) + if decision.toolOpKey != "" { + dependencies = append(dependencies, decision.toolOpKey) + } + if integ.IndexCopyDir != "" && mainWT != "" && normalizeWorkspaceIdentity(wtPath) != normalizeWorkspaceIdentity(mainWT) { + source, err := ResolveManagedPath(mainWT, integ.IndexCopyDir) + if err != nil { + return PreparedApply{}, fmt.Errorf("preparing integrations: index source: %w", err) + } + destination, err := ResolveManagedPath(wtPath, integ.IndexCopyDir) + if err != nil { + return PreparedApply{}, fmt.Errorf("preparing integrations: index destination: %w", err) + } + info, err := os.Stat(source) + switch { + case err == nil && !info.IsDir(): + return PreparedApply{}, fmt.Errorf("preparing integrations: index source %q is not a directory", source) + case err == nil: + copyOp := applyOperation{ + phaseID: phaseID, phaseName: wtPath, + stepID: stableStepID("copy-index", integ.Name), label: "Copy index for " + integ.Name, + kind: applyCopy, dependsOn: append([]string(nil), dependencies...), + seedSource: source, seedDest: destination, + seedSourceRoot: mainWT, seedSourcePath: integ.IndexCopyDir, + seedDestRoot: wtPath, seedDestPath: integ.IndexCopyDir, + } + operations = append(operations, copyOp) + dependencies = append(dependencies, copyOp.key()) + case os.IsNotExist(err): + case err != nil: + return PreparedApply{}, fmt.Errorf("preparing integrations: inspecting index source %q: %w", source, err) + } + } + if strings.TrimSpace(integ.Setup.Command) != "" { + workDir := wtPath + if integ.Setup.WorkingDir == "repo" { + workDir = repoPath + } + setupOp := applyOperation{ + phaseID: phaseID, phaseName: wtPath, + stepID: stableStepID("setup", decision.key), label: SetupStepName(integ), + kind: applyShellCommand, dir: workDir, + command: strings.ReplaceAll(integ.Setup.Command, "{path}", git.ShellQuote(wtPath)), + dependsOn: append([]string(nil), dependencies...), + } + operations = append(operations, setupOp) + dependencies = append(dependencies, setupOp.key()) + } + if len(integ.GitignoreEntries) > 0 { + operations = append(operations, applyOperation{ + phaseID: phaseID, phaseName: wtPath, + stepID: stableStepID("gitignore", integ.Name), label: "Update .gitignore for " + integ.Name, + kind: applyGitignore, dependsOn: append([]string(nil), dependencies...), + gitignore: append([]string(nil), integ.GitignoreEntries...), gitignoreDir: wtPath, + }) + } + } + for _, integ := range toDisable { + key := integ.Name + if integ.Teardown.Command != "" { + operations = append(operations, applyOperation{ + phaseID: phaseID, phaseName: wtPath, + stepID: stableStepID("teardown", key), label: TeardownStepName(integ), + kind: applyShellCommand, dir: wtPath, command: integ.Teardown.Command, + }) + } + for _, dir := range integ.Teardown.Dirs { + managedDir, err := ResolveManagedPath(wtPath, dir) + if err != nil { + return PreparedApply{}, fmt.Errorf("preparing integrations: teardown directory: %w", err) + } + operations = append(operations, applyOperation{ + phaseID: phaseID, phaseName: wtPath, + stepID: stableStepID("remove", key+":"+normalizeWorkspaceIdentity(dir)), label: RemoveDirStepName(dir, wtPath), + kind: applyRemove, dir: managedDir, managedRoot: wtPath, managedPath: dir, + }) + } + } + } + + if err := validateOperationGraph(operations); err != nil { + return PreparedApply{}, fmt.Errorf("preparing integrations: %w", err) + } + return PreparedApply{plan: planForOperations(operations), operations: operations, files: realApplyFileOperations{}}, nil +} + +func validateManagedPaths(mainWT string, toEnable, toDisable []Integration, wtPaths []string) error { + for _, integ := range toEnable { + if integ.IndexCopyDir == "" { + continue + } + if mainWT != "" { + if _, err := ResolveManagedPath(mainWT, integ.IndexCopyDir); err != nil { + return fmt.Errorf("integration %q index copy directory: %w", integ.Name, err) + } + } + for _, wtPath := range wtPaths { + if _, err := ResolveManagedPath(wtPath, integ.IndexCopyDir); err != nil { + return fmt.Errorf("integration %q index copy directory: %w", integ.Name, err) + } + } + } + for _, integ := range toDisable { + for _, dir := range integ.Teardown.Dirs { + for _, wtPath := range wtPaths { + if _, err := ResolveManagedPath(wtPath, dir); err != nil { + return fmt.Errorf("integration %q teardown directory: %w", integ.Name, err) + } + } + } + } + return nil +} + +// ResolveManagedPath validates a declared integration path and resolves it +// beneath root. A single trailing separator is accepted for directory specs. +func ResolveManagedPath(root, managedPath string) (string, error) { + if managedPath == "" || strings.TrimSpace(managedPath) != managedPath { + return "", fmt.Errorf("managed path %q must be nonempty and have no surrounding whitespace", managedPath) + } + if filepath.IsAbs(managedPath) { + return "", fmt.Errorf("managed path %q must be relative", managedPath) + } + path := strings.TrimSuffix(managedPath, string(filepath.Separator)) + if path == "" || filepath.Clean(path) != path || path == "." || path == ".." || strings.HasPrefix(path, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("managed path %q must be clean and below its root", managedPath) + } + rootPath, err := filepath.Abs(root) + if err != nil { + return "", fmt.Errorf("resolving managed path root %q: %w", root, err) + } + rootPath, err = resolveExistingPath(rootPath) + if err != nil { + return "", fmt.Errorf("resolving managed path root %q: %w", root, err) + } + resolved := rootPath + parts := strings.Split(path, string(filepath.Separator)) +components: + for i, part := range parts { + candidate := filepath.Join(resolved, part) + info, err := os.Lstat(candidate) + switch { + case err == nil && info.Mode()&os.ModeSymlink != 0: + return "", fmt.Errorf("managed path %q contains symlink component %q", managedPath, strings.Join(parts[:i+1], string(filepath.Separator))) + case err == nil: + resolved = candidate + case os.IsNotExist(err): + resolved = filepath.Join(append([]string{resolved}, parts[i:]...)...) + break components + case err != nil: + return "", fmt.Errorf("inspecting managed path %q: %w", managedPath, err) + } + } + relative, err := filepath.Rel(rootPath, resolved) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("managed path %q resolves outside root %q", managedPath, root) + } + return resolved, nil +} + +func resolveExistingPath(path string) (string, error) { + current := path + var missing []string + for { + _, err := os.Lstat(current) + switch { + case err == nil: + resolved, err := filepath.EvalSymlinks(current) + if err != nil { + return "", err + } + for i := len(missing) - 1; i >= 0; i-- { + resolved = filepath.Join(resolved, missing[i]) + } + return resolved, nil + case !os.IsNotExist(err): + return "", err + } + parent := filepath.Dir(current) + if parent == current { + return "", err + } + missing = append(missing, filepath.Base(current)) + current = parent + } +} + +// BindPhase returns a copy whose operations share one caller-owned phase. +// Operation identities and dependency edges remain semantic and unchanged +// apart from their phase-qualified keys. +func (p PreparedApply) BindPhase(phaseID progress.PhaseID, phaseLabel string) (PreparedApply, error) { + if phaseID == "" { + return PreparedApply{}, errors.New("binding integration apply: empty phase ID") + } + operations := append([]applyOperation(nil), p.operations...) + keys := make(map[string]string, len(operations)) + for i := range operations { + operations[i].dependsOn = append([]string(nil), operations[i].dependsOn...) + oldKey := operations[i].key() + operations[i].phaseID = phaseID + operations[i].phaseName = phaseLabel + keys[oldKey] = operations[i].key() + } + for i := range operations { + for j, dependency := range operations[i].dependsOn { + operations[i].dependsOn[j] = keys[dependency] + } + } + if err := validateOperationGraph(operations); err != nil { + return PreparedApply{}, fmt.Errorf("binding integration apply: %w", err) + } + return PreparedApply{plan: planForOperations(operations), operations: operations, files: p.files}, nil +} + +func validateApplyInputs(toEnable, toDisable []Integration) error { + identities := make(map[string]string, len(toEnable)+len(toDisable)) + dependencies := make(map[string]Dependency) + for _, group := range []struct { + name string + integrations []Integration + }{{"enable", toEnable}, {"disable", toDisable}} { + for _, integ := range group.integrations { + name := strings.TrimSpace(integ.Name) + if name == "" { + return errors.New("integration has empty name") + } + if prior, exists := identities[name]; exists { + return fmt.Errorf("duplicate integration identity %q in %s and %s", name, prior, group.name) + } + identities[name] = group.name + if group.name == "enable" { + hasCommand := strings.TrimSpace(integ.Detect.Command) != "" + hasBinary := strings.TrimSpace(integ.Detect.BinaryName) != "" + if !hasCommand && !hasBinary { + return fmt.Errorf("integration %q must declare a detection command or binary", name) + } + } + for _, dep := range integ.Dependencies { + depName := strings.TrimSpace(dep.Name) + if depName == "" { + return fmt.Errorf("integration %q has dependency with empty name", name) + } + if strings.TrimSpace(dep.Detect) == "" { + return fmt.Errorf("dependency %q has empty detection command", depName) + } + if prior, exists := dependencies[depName]; exists && (prior.Detect != dep.Detect || prior.Install != dep.Install) { + return fmt.Errorf("dependency %q has conflicting specifications", depName) + } + dependencies[depName] = dep + } + } + } + return nil +} + +func normalizeWorkspaceIdentity(path string) string { + return filepath.ToSlash(filepath.Clean(path)) +} + +func validateOperationGraph(operations []applyOperation) error { + all := make(map[string]bool, len(operations)) + for _, op := range operations { + key := op.key() + if all[key] { + return fmt.Errorf("duplicate operation %q", key) + } + all[key] = true + } + completed := make(map[string]bool, len(operations)) + for _, op := range operations { + for _, dependency := range op.dependsOn { + if !all[dependency] { + return fmt.Errorf("operation %q references missing dependency %q", op.key(), dependency) + } + if !completed[dependency] { + return fmt.Errorf("operation %q depends on out-of-order or cyclic operation %q", op.key(), dependency) + } + } + completed[op.key()] = true + } + return nil +} + +func (p PreparedApply) Run(shell git.ShellRunner, emit func(progress.Event)) ([]progress.Phase, error) { + if err := validateOperationGraph(p.operations); err != nil { + return nil, fmt.Errorf("validating integration apply: %w", err) + } + execution, err := progress.Start(p.plan, emit) + if err != nil { + return nil, fmt.Errorf("starting integration apply: %w", err) + } + runErr := p.RunIn(execution, shell) + finishErr := execution.Finish("integration apply finished") + if finishErr != nil { + finishErr = fmt.Errorf("finishing integration apply: %w", finishErr) + } + return execution.Phases(), errors.Join(runErr, finishErr) +} + +// RunIn executes the frozen operations through an already-started shared +// execution. It does not start or finish that execution. +func (p PreparedApply) RunIn(execution *progress.Execution, shell git.ShellRunner) error { + if execution == nil { + return errors.New("executing integration apply: nil execution") + } + if err := validateOperationGraph(p.operations); err != nil { + return fmt.Errorf("validating integration apply: %w", err) + } + results := make(map[string]progress.StepResult, len(p.operations)) + files := p.files + if files == nil { + files = realApplyFileOperations{} + } + for _, op := range p.operations { + blockedBy := "" + for _, dependency := range op.dependsOn { + result := results[dependency] + if result.Status == progress.StepFailed || result.Status == progress.StepSkipped { + blockedBy = result.Name + break + } + } + if blockedBy != "" { + result, err := execution.Skip(op.phaseID, op.stepID, "blocked by "+blockedBy) + if err != nil { + return fmt.Errorf("skipping %s: %w", op.label, err) + } + results[op.key()] = result + continue + } + var result progress.StepResult + var transitionErr error + switch op.kind { + case applyFailure: + result, transitionErr = execution.Fail(op.phaseID, op.stepID, op.failure) + case applyRemove: + result, transitionErr = execution.Run(op.phaseID, op.stepID, func() (string, error) { + path := op.dir + if op.managedPath != "" { + resolved, err := ResolveManagedPath(op.managedRoot, op.managedPath) + if err != nil { + return "", fmt.Errorf("revalidating removal path: %w", err) + } + path = resolved + } + return "", files.removeAll(path) + }) + case applyCopy: + result, transitionErr = execution.Run(op.phaseID, op.stepID, func() (string, error) { + source, destination := op.seedSource, op.seedDest + if op.seedSourcePath != "" { + resolved, err := ResolveManagedPath(op.seedSourceRoot, op.seedSourcePath) + if err != nil { + return "", fmt.Errorf("revalidating index source: %w", err) + } + source = resolved + } + if op.seedDestPath != "" { + resolved, err := ResolveManagedPath(op.seedDestRoot, op.seedDestPath) + if err != nil { + return "", fmt.Errorf("revalidating index destination: %w", err) + } + destination = resolved + } + if err := files.removeAll(destination); err != nil { + return "", fmt.Errorf("removing existing index: %w", err) + } + if err := files.copyDir(source, destination); err != nil { + return "", fmt.Errorf("copying index: %w", err) + } + return "", nil + }) + case applyGitignore: + result, transitionErr = execution.Run(op.phaseID, op.stepID, func() (string, error) { + return "", files.appendGitignore(op.gitignoreDir, op.gitignore) + }) + default: + result, transitionErr = execution.Run(op.phaseID, op.stepID, func() (string, error) { return shell.RunShell(op.dir, op.command) }) + } + if transitionErr != nil { + return fmt.Errorf("executing %s: %w", op.label, transitionErr) + } + results[op.key()] = result + } + return nil +} + +func detectForApply(shell git.ShellRunner, dir string, detect DetectSpec) bool { + if strings.TrimSpace(detect.Command) != "" { + if _, err := shell.RunShell(dir, detect.Command); err == nil { + return true + } + } + if strings.TrimSpace(detect.BinaryName) != "" { + if _, err := shell.RunShell(dir, "command -v "+detect.BinaryName); err == nil { + return true + } + } + return false +} + +func stableToken(value string) string { + sum := sha256.Sum256([]byte(value)) + return fmt.Sprintf("%x", sum[:8]) +} + +func stableStepID(kind, value string) progress.StepID { + return progress.StepID(kind + ":" + stableToken(value)) +} + +func planForOperations(operations []applyOperation) progress.Plan { + indices := map[progress.PhaseID]int{} + var plan progress.Plan + for _, op := range operations { + index, exists := indices[op.phaseID] + if !exists { + index = len(plan.Phases) + indices[op.phaseID] = index + plan.Phases = append(plan.Phases, progress.PlannedPhase{ID: op.phaseID, Label: op.phaseName}) + } + plan.Phases[index].Steps = append(plan.Phases[index].Steps, progress.PlannedStep{ID: op.stepID, Label: op.label}) + } + return plan +} diff --git a/internal/integration/apply_test.go b/internal/integration/apply_test.go new file mode 100644 index 0000000..a2fe901 --- /dev/null +++ b/internal/integration/apply_test.go @@ -0,0 +1,929 @@ +package integration + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + + "github.com/abiswas97/sentei/internal/progress" +) + +type applyShell struct { + mu sync.Mutex + responses map[string]mockShellResponse + calls []string +} + +type mockShellResponse struct { + output string + err error +} + +type applyShellFunc func(dir, command string) (string, error) + +func (fn applyShellFunc) RunShell(dir, command string) (string, error) { return fn(dir, command) } + +type failingApplyFiles struct { + removeErr error + copyErr error + gitignoreErr error +} + +func (f failingApplyFiles) removeAll(string) error { return f.removeErr } +func (f failingApplyFiles) copyDir(string, string) error { return f.copyErr } +func (f failingApplyFiles) appendGitignore(string, []string) error { return f.gitignoreErr } + +func findStep(t *testing.T, events []progress.Event, label string) progress.StepState { + t.Helper() + for _, phase := range progress.Snapshot(events) { + for _, step := range phase.Steps { + if step.Name == label { + return step + } + } + } + t.Fatalf("step %q not found", label) + return progress.StepState{} +} + +func (s *applyShell) RunShell(dir, command string) (string, error) { + key := fmt.Sprintf("%s:shell[%s]", dir, command) + s.mu.Lock() + s.calls = append(s.calls, key) + response, ok := s.responses[key] + s.mu.Unlock() + if !ok { + return "", fmt.Errorf("unexpected shell call: %s", key) + } + return response.output, response.err +} + +func testIntegration(name string, dependencies ...Dependency) Integration { + return Integration{ + Name: name, + Dependencies: dependencies, + Detect: DetectSpec{Command: name + " detect"}, + Install: InstallSpec{Command: name + " install"}, + Setup: SetupSpec{Command: name + " setup {path}", WorkingDir: "worktree"}, + } +} + +func plannedLabels(plan progress.Plan) []string { + var labels []string + for _, phase := range plan.Phases { + for _, step := range phase.Steps { + labels = append(labels, phase.Label+"/"+step.Label) + } + } + return labels +} + +func plannedIdentities(plan progress.Plan) map[string]string { + identities := make(map[string]string) + for _, phase := range plan.Phases { + for _, step := range phase.Steps { + identities[phase.Label+"/"+step.Label] = phase.ID + "\x00" + step.ID + } + } + return identities +} + +func collectPreparedEvents(prepared PreparedApply, shell *applyShell) ([]progress.Event, []progress.Phase) { + var events []progress.Event + phases, err := prepared.Run(shell, func(event progress.Event) { events = append(events, event) }) + if err != nil { + panic(err) + } + return events, phases +} + +func assertSettledStream(t *testing.T, events []progress.Event) { + t.Helper() + if err := progress.ValidateStream(events); err != nil { + t.Fatalf("invalid stream: %v\n%#v", err, events) + } + for _, phase := range progress.Snapshot(events) { + if phase.Total > 0 && !phase.Settled() { + t.Fatalf("phase did not settle: %#v", phase) + } + } +} + +func TestPrepareApply_MissingToolPlansPrerequisiteOnce(t *testing.T) { + integ := testIntegration("tool", Dependency{Name: "dep", Detect: "dep detect"}) + probeDir := "/wt/a" + shell := &applyShell{responses: map[string]mockShellResponse{ + probeDir + ":shell[tool detect]": {err: errors.New("missing")}, + probeDir + ":shell[dep detect]": {output: "present"}, + }} + prepared, err := PrepareApply(shell, "/repo", probeDir, []Integration{integ}, nil, []string{probeDir, "/wt/b"}) + if err != nil { + t.Fatal(err) + } + labels := plannedLabels(prepared.Plan()) + if got := strings.Count(strings.Join(labels, "\n"), "Prerequisites/Install tool"); got != 1 { + t.Fatalf("install steps = %d, want 1: %v", got, labels) + } + if got := strings.Count(strings.Join(labels, "\n"), "Setup tool"); got != 2 { + t.Fatalf("setup steps = %d, want 2: %v", got, labels) + } +} + +func TestPrepareApplyForTarget_ProbesExistingWorktreeAndBindsSharedExecution(t *testing.T) { + integ := testIntegration("tool") + shell := &applyShell{responses: map[string]mockShellResponse{ + "/repo/main:shell[tool detect]": {output: "installed"}, + "/repo/feature:shell[tool setup '/repo/feature']": {}, + }} + prepared, err := PrepareApplyForTarget(shell, "/repo", "/repo/main", "/repo/main", []Integration{integ}, nil, []string{"/repo/feature"}) + if err != nil { + t.Fatal(err) + } + prepared, err = prepared.BindPhase("integrations", "Integrations") + if err != nil { + t.Fatal(err) + } + execution, err := progress.Start(prepared.Plan(), func(progress.Event) {}) + if err != nil { + t.Fatal(err) + } + if err := prepared.RunIn(execution, shell); err != nil { + t.Fatal(err) + } + if err := execution.Finish("done"); err != nil { + t.Fatal(err) + } + if got := prepared.Plan().Phases; len(got) != 1 || got[0].ID != "integrations" { + t.Fatalf("bound phases = %#v", got) + } + for _, call := range shell.calls { + if strings.Contains(call, "/repo/feature:shell[tool detect]") { + t.Fatalf("future target was probed: %v", shell.calls) + } + } +} + +func TestPreparedApply_InstallFailureSkipsEverySetup(t *testing.T) { + integ := testIntegration("tool") + shell := &applyShell{responses: map[string]mockShellResponse{ + "/wt/a:shell[tool detect]": {err: errors.New("missing")}, + "/wt/a:shell[tool install]": {err: errors.New("install broke")}, + "/wt/a:shell[tool setup '/wt/a']": {output: "must not run"}, + "/wt/b:shell[tool setup '/wt/b']": {output: "must not run"}, + }} + prepared, err := PrepareApply(shell, "/repo", "/wt/a", []Integration{integ}, nil, []string{"/wt/a", "/wt/b"}) + if err != nil { + t.Fatal(err) + } + events, _ := collectPreparedEvents(prepared, shell) + assertSettledStream(t, events) + states := progress.Snapshot(events) + for _, phase := range states { + for _, step := range phase.Steps { + if strings.HasPrefix(step.Name, "Setup tool") && (step.Status != progress.StepSkipped || !strings.Contains(step.Message, "blocked by Install tool")) { + t.Fatalf("setup step = %#v", step) + } + } + } +} + +func TestPreparedApply_DeclarationTotalIsFixedAcrossPrefixes(t *testing.T) { + a := testIntegration("alpha") + b := testIntegration("beta") + shell := &applyShell{responses: map[string]mockShellResponse{ + "/wt/a:shell[alpha detect]": {output: "installed"}, + "/wt/a:shell[beta detect]": {err: errors.New("missing")}, + "/wt/a:shell[alpha setup '/wt/a']": {output: "ok"}, + "/wt/b:shell[alpha setup '/wt/b']": {output: "ok"}, + "/wt/a:shell[beta install]": {output: "ok"}, + "/wt/a:shell[beta setup '/wt/a']": {output: "ok"}, + "/wt/b:shell[beta setup '/wt/b']": {output: "ok"}, + }} + prepared, err := PrepareApply(shell, "/repo", "/wt/a", []Integration{a, b}, nil, []string{"/wt/a", "/wt/b"}) + if err != nil { + t.Fatal(err) + } + events, _ := collectPreparedEvents(prepared, shell) + assertSettledStream(t, events) + declarationEnd := 0 + for i, event := range events { + if event.Close { + declarationEnd = i + 1 + } + } + want := -1 + for i := declarationEnd; i <= len(events); i++ { + _, total := progress.CheckpointProgress(progress.Snapshot(events[:i])) + if want < 0 { + want = total + } else if total != want { + t.Fatalf("prefix %d total = %d, want fixed %d", i, total, want) + } + } +} + +func TestPrepareApply_InstalledToolPlansSetupOnlyAndDoesNotReprobe(t *testing.T) { + integ := testIntegration("tool", Dependency{Name: "dep", Detect: "dep detect"}) + shell := &applyShell{responses: map[string]mockShellResponse{ + "/wt/a:shell[tool detect]": {output: "installed"}, + "/wt/a:shell[dep detect]": {output: "installed"}, + "/wt/a:shell[tool setup '/wt/a']": {output: "ok"}, + }} + prepared, err := PrepareApply(shell, "/repo", "/wt/a", []Integration{integ}, nil, []string{"/wt/a"}) + if err != nil { + t.Fatal(err) + } + labels := strings.Join(plannedLabels(prepared.Plan()), "\n") + if strings.Contains(labels, "Install") || strings.Count(labels, "Setup tool") != 1 { + t.Fatalf("plan = %s", labels) + } + events, _ := collectPreparedEvents(prepared, shell) + assertSettledStream(t, events) + shell.mu.Lock() + calls := append([]string(nil), shell.calls...) + shell.mu.Unlock() + if strings.Count(strings.Join(calls, "\n"), "tool detect") != 1 || strings.Count(strings.Join(calls, "\n"), "dep detect") != 1 { + t.Fatalf("detection calls repeated: %v", calls) + } +} + +func TestPreparedApply_PlanInspectionCannotMutateExecution(t *testing.T) { + integ := testIntegration("tool") + shell := &applyShell{responses: map[string]mockShellResponse{ + "/wt/a:shell[tool detect]": {output: "installed"}, + "/wt/a:shell[tool setup '/wt/a']": {output: "ok"}, + }} + prepared, err := PrepareApply(shell, "/repo", "/wt/a", []Integration{integ}, nil, []string{"/wt/a"}) + if err != nil { + t.Fatal(err) + } + + inspection := prepared.Plan() + inspection.Phases[0].Label = "mutated phase" + inspection.Phases[0].Steps[0].Label = "mutated step" + + var events []progress.Event + if _, err := prepared.Run(shell, func(event progress.Event) { events = append(events, event) }); err != nil { + t.Fatal(err) + } + states := progress.Snapshot(events) + if len(states) != 1 || states[0].Name != "/wt/a" || states[0].Steps[0].Name != "Setup tool" { + t.Fatalf("inspection mutated execution: %#v", states) + } +} + +func TestPreparedApply_Empty(t *testing.T) { + prepared, err := PrepareApply(&applyShell{}, "/repo", "", nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if !prepared.Empty() { + t.Fatal("an apply with no frozen operations must be empty") + } + + prepared, err = PrepareApply(&applyShell{}, "/repo", "/wt/a", nil, []Integration{{ + Name: "tool", Teardown: TeardownSpec{Dirs: []string{".tool/"}}, + }}, []string{"/wt/a"}) + if err != nil { + t.Fatal(err) + } + if prepared.Empty() { + t.Fatal("an apply with a frozen operation must not be empty") + } +} + +func TestPreparedApply_RunSurfacesStartError(t *testing.T) { + prepared := PreparedApply{plan: progress.Plan{Phases: []progress.PlannedPhase{{Label: "missing ID"}}}} + if _, err := prepared.Run(&applyShell{}, func(progress.Event) {}); err == nil || !strings.Contains(err.Error(), "empty ID") { + t.Fatalf("Run error = %v, want invalid-plan error", err) + } +} + +func TestPreparedApply_RunSurfacesTransitionDeliveryError(t *testing.T) { + phaseID := progress.PhaseID("phase") + stepID := progress.StepID("declared") + prepared := PreparedApply{ + plan: progress.Plan{Phases: []progress.PlannedPhase{{ + ID: phaseID, Label: "Phase", Steps: []progress.PlannedStep{{ID: stepID, Label: "Step"}}, + }}}, + operations: []applyOperation{{ + phaseID: phaseID, phaseName: "Phase", stepID: progress.StepID("other"), label: "Step", + kind: applyFailure, failure: errors.New("failed"), + }}, + } + if _, err := prepared.Run(&applyShell{}, func(progress.Event) {}); err == nil || !strings.Contains(err.Error(), "no step ID") { + t.Fatalf("Run error = %v, want transition error", err) + } +} + +func TestPreparedApply_RunSurfacesFinishDeliveryError(t *testing.T) { + phaseID := progress.PhaseID("phase") + stepID := progress.StepID("declared") + prepared := PreparedApply{plan: progress.Plan{Phases: []progress.PlannedPhase{{ + ID: phaseID, Label: "Phase", Steps: []progress.PlannedStep{{ID: stepID, Label: "Step"}}, + }}}} + emitErr := errors.New("sink closed") + _, err := prepared.Run(&applyShell{}, func(event progress.Event) { + if event.Status == progress.StepSkipped { + panic(emitErr) + } + }) + if !errors.Is(err, emitErr) { + t.Fatalf("Run error = %v, want %v", err, emitErr) + } +} + +func TestPrepareApply_DisableOnlyWithoutTargetsFails(t *testing.T) { + _, err := PrepareApply(&applyShell{}, "/repo", "", nil, []Integration{{Name: "tool"}}, nil) + if err == nil || !strings.Contains(err.Error(), "no target worktree") { + t.Fatalf("PrepareApply error = %v, want no-target error", err) + } +} + +func TestPreparedApply_DependencyInstallFailureSkipsToolAndSetup(t *testing.T) { + integ := testIntegration("tool", Dependency{Name: "dep", Detect: "dep detect", Install: "dep install"}) + shell := &applyShell{responses: map[string]mockShellResponse{ + "/wt/a:shell[tool detect]": {err: errors.New("missing")}, + "/wt/a:shell[dep detect]": {err: errors.New("missing")}, + "/wt/a:shell[dep install]": {err: errors.New("dep broke")}, + }} + prepared, err := PrepareApply(shell, "/repo", "/wt/a", []Integration{integ}, nil, []string{"/wt/a"}) + if err != nil { + t.Fatal(err) + } + events, _ := collectPreparedEvents(prepared, shell) + assertSettledStream(t, events) + if step := findStep(t, events, "Install dependency dep"); step.Status != progress.StepFailed { + t.Fatalf("dependency = %#v", step) + } + if step := findStep(t, events, "Install tool"); step.Status != progress.StepSkipped || !strings.Contains(step.Message, "blocked by Install dependency dep") { + t.Fatalf("tool = %#v", step) + } + if step := findStep(t, events, "Setup tool"); step.Status != progress.StepSkipped || !strings.Contains(step.Message, "blocked by Install tool") { + t.Fatalf("setup = %#v", step) + } +} + +func TestPreparedApply_MissingInstallableDependencyRunsPipeline(t *testing.T) { + integ := testIntegration("tool", Dependency{Name: "dep", Detect: "dep detect", Install: "dep install"}) + shell := &applyShell{responses: map[string]mockShellResponse{ + "/wt/a:shell[tool detect]": {err: errors.New("missing")}, + "/wt/a:shell[dep detect]": {err: errors.New("missing")}, + "/wt/a:shell[dep install]": {output: "ok"}, + "/wt/a:shell[tool install]": {output: "ok"}, + "/wt/a:shell[tool setup '/wt/a']": {output: "ok"}, + }} + prepared, err := PrepareApply(shell, "/repo", "/wt/a", []Integration{integ}, nil, []string{"/wt/a"}) + if err != nil { + t.Fatal(err) + } + events, _ := collectPreparedEvents(prepared, shell) + assertSettledStream(t, events) + for _, label := range []string{"Install dependency dep", "Install tool", "Setup tool"} { + if step := findStep(t, events, label); step.Status != progress.StepDone { + t.Fatalf("%s = %#v", label, step) + } + } +} + +func TestPreparedApply_MissingDependencyWithoutInstallerFailsHonestly(t *testing.T) { + integ := testIntegration("tool", Dependency{Name: "dep", Detect: "dep detect"}) + shell := &applyShell{responses: map[string]mockShellResponse{ + "/wt/a:shell[tool detect]": {err: errors.New("missing")}, + "/wt/a:shell[dep detect]": {err: errors.New("missing")}, + }} + prepared, err := PrepareApply(shell, "/repo", "/wt/a", []Integration{integ}, nil, []string{"/wt/a"}) + if err != nil { + t.Fatal(err) + } + events, _ := collectPreparedEvents(prepared, shell) + assertSettledStream(t, events) + dep := findStep(t, events, "Install dependency dep") + if dep.Status != progress.StepFailed || dep.Error == nil || !strings.Contains(dep.Error.Error(), "no install command") { + t.Fatalf("dependency = %#v", dep) + } +} + +func TestPreparedApply_SetupFailureStillSettles(t *testing.T) { + integ := testIntegration("tool") + shell := &applyShell{responses: map[string]mockShellResponse{ + "/wt/a:shell[tool detect]": {output: "installed"}, + "/wt/a:shell[tool setup '/wt/a']": {err: errors.New("setup broke")}, + }} + prepared, err := PrepareApply(shell, "/repo", "/wt/a", []Integration{integ}, nil, []string{"/wt/a"}) + if err != nil { + t.Fatal(err) + } + events, _ := collectPreparedEvents(prepared, shell) + assertSettledStream(t, events) + if step := findStep(t, events, "Setup tool"); step.Status != progress.StepFailed { + t.Fatalf("setup = %#v", step) + } +} + +func TestPreparedApply_TeardownFailureStillRemovesArtifacts(t *testing.T) { + wt := t.TempDir() + artifact := filepath.Join(wt, ".tool") + if err := os.MkdirAll(artifact, 0o755); err != nil { + t.Fatal(err) + } + integ := Integration{Name: "tool", Teardown: TeardownSpec{Command: "tool clean", Dirs: []string{".tool/"}}} + shell := &applyShell{responses: map[string]mockShellResponse{wt + ":shell[tool clean]": {err: errors.New("clean broke")}}} + prepared, err := PrepareApply(shell, "/repo", wt, nil, []Integration{integ}, []string{wt}) + if err != nil { + t.Fatal(err) + } + events, _ := collectPreparedEvents(prepared, shell) + assertSettledStream(t, events) + if _, err := os.Stat(artifact); !os.IsNotExist(err) { + t.Fatalf("artifact still exists: %v", err) + } + if step := findStep(t, events, "Teardown tool"); step.Status != progress.StepFailed { + t.Fatalf("teardown = %#v", step) + } + if step := findStep(t, events, RemoveDirStepName(".tool/", wt)); step.Status != progress.StepDone { + t.Fatalf("removal = %#v", step) + } +} + +func TestPreparedApply_RevalidatesTeardownPathAfterCommand(t *testing.T) { + wt := t.TempDir() + outside := t.TempDir() + managedParent := filepath.Join(wt, "managed") + outsideArtifact := filepath.Join(outside, "artifact") + if err := os.MkdirAll(filepath.Join(managedParent, "artifact"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(outsideArtifact, 0o755); err != nil { + t.Fatal(err) + } + integ := Integration{Name: "tool", Teardown: TeardownSpec{Command: "tool clean", Dirs: []string{"managed/artifact"}}} + shell := applyShellFunc(func(dir, command string) (string, error) { + if dir != wt || command != "tool clean" { + return "", fmt.Errorf("unexpected shell call: %s %s", dir, command) + } + if err := os.RemoveAll(managedParent); err != nil { + return "", err + } + return "", os.Symlink(outside, managedParent) + }) + prepared, err := PrepareApply(shell, "/repo", wt, nil, []Integration{integ}, []string{wt}) + if err != nil { + t.Fatal(err) + } + var events []progress.Event + if _, err := prepared.Run(shell, func(event progress.Event) { events = append(events, event) }); err != nil { + t.Fatal(err) + } + if step := findStep(t, events, RemoveDirStepName("managed/artifact", wt)); step.Status != progress.StepFailed || step.Error == nil || !strings.Contains(step.Error.Error(), "symlink") { + t.Fatalf("removal step = %#v, want visible symlink failure", step) + } + if _, err := os.Stat(outsideArtifact); err != nil { + t.Fatalf("outside artifact was touched: %v", err) + } +} + +func TestPreparedApply_RevalidatesIndexDestinationAfterInstall(t *testing.T) { + mainWT := t.TempDir() + targetWT := t.TempDir() + outside := t.TempDir() + sourceIndex := filepath.Join(mainWT, "managed", "index") + targetParent := filepath.Join(targetWT, "managed") + outsideIndex := filepath.Join(outside, "index") + if err := os.MkdirAll(sourceIndex, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(targetParent, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(outsideIndex, 0o755); err != nil { + t.Fatal(err) + } + marker := filepath.Join(outsideIndex, "keep") + if err := os.WriteFile(marker, []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + integ := Integration{ + Name: "tool", Detect: DetectSpec{Command: "tool detect"}, + Install: InstallSpec{Command: "tool install"}, IndexCopyDir: "managed/index", + } + shell := applyShellFunc(func(dir, command string) (string, error) { + switch command { + case "tool detect": + return "", errors.New("missing") + case "tool install": + if err := os.RemoveAll(targetParent); err != nil { + return "", err + } + return "", os.Symlink(outside, targetParent) + default: + return "", fmt.Errorf("unexpected shell call: %s %s", dir, command) + } + }) + prepared, err := PrepareApply(shell, "/repo", mainWT, []Integration{integ}, nil, []string{targetWT}) + if err != nil { + t.Fatal(err) + } + var events []progress.Event + if _, err := prepared.Run(shell, func(event progress.Event) { events = append(events, event) }); err != nil { + t.Fatal(err) + } + if step := findStep(t, events, "Copy index for tool"); step.Status != progress.StepFailed || step.Error == nil || !strings.Contains(step.Error.Error(), "symlink") { + t.Fatalf("copy step = %#v, want visible symlink failure", step) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("outside index was touched: %v", err) + } +} + +func TestPrepareApplySemanticIDsDoNotDependOnInputOrder(t *testing.T) { + alpha := testIntegration("alpha") + beta := testIntegration("beta") + responses := map[string]mockShellResponse{ + "/wt/a:shell[alpha detect]": {output: "installed"}, + "/wt/a:shell[beta detect]": {output: "installed"}, + "/wt/b:shell[alpha detect]": {output: "installed"}, + "/wt/b:shell[beta detect]": {output: "installed"}, + } + first, err := PrepareApply(&applyShell{responses: responses}, "/repo", "/wt/a", []Integration{alpha, beta}, nil, []string{"/wt/a", "/wt/b"}) + if err != nil { + t.Fatal(err) + } + second, err := PrepareApply(&applyShell{responses: responses}, "/repo", "/wt/a", []Integration{beta, alpha}, nil, []string{"/wt/b", "/wt/a"}) + if err != nil { + t.Fatal(err) + } + if got, want := plannedIdentities(second.Plan()), plannedIdentities(first.Plan()); !reflect.DeepEqual(got, want) { + t.Fatalf("reordered identities = %#v, want %#v", got, want) + } +} + +func TestPrepareApplyRejectsDuplicateIntegrationIdentityBeforeDetection(t *testing.T) { + integ := testIntegration("tool") + shell := &applyShell{} + _, err := PrepareApply(shell, "/repo", "/wt/a", []Integration{integ, integ}, nil, []string{"/wt/a"}) + if err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("PrepareApply error = %v, want duplicate identity error", err) + } + if len(shell.calls) != 0 { + t.Fatalf("detection ran before duplicate validation: %v", shell.calls) + } +} + +func TestPrepareApplyRejectsConflictingDependencySpecsBeforeDetection(t *testing.T) { + one := testIntegration("one", Dependency{Name: "dep", Detect: "dep one", Install: "install one"}) + two := testIntegration("two", Dependency{Name: "dep", Detect: "dep two", Install: "install two"}) + shell := &applyShell{} + _, err := PrepareApply(shell, "/repo", "/wt/a", []Integration{one, two}, nil, []string{"/wt/a"}) + if err == nil || !strings.Contains(err.Error(), "conflicting") { + t.Fatalf("PrepareApply error = %v, want dependency conflict", err) + } + if len(shell.calls) != 0 { + t.Fatalf("detection ran before dependency validation: %v", shell.calls) + } +} + +func TestPrepareApplyRejectsMalformedDetectionBeforeSideEffects(t *testing.T) { + tests := []struct { + name string + integ Integration + }{ + {name: "empty integration detect", integ: Integration{Name: "tool", Setup: SetupSpec{Command: "setup"}}}, + {name: "empty dependency detect", integ: testIntegration("tool", Dependency{Name: "dep", Install: "dep install"})}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + shell := &applyShell{} + _, err := PrepareApply(shell, "/repo", "/wt/a", []Integration{tc.integ}, nil, []string{"/wt/a"}) + if err == nil { + t.Fatal("malformed detection accepted") + } + if len(shell.calls) != 0 { + t.Fatalf("shell called before preflight completed: %v", shell.calls) + } + }) + } +} + +func TestPrepareApplyRejectsUnsafeManagedPathsBeforeDetection(t *testing.T) { + tests := []struct { + name string + toEnable []Integration + toDisable []Integration + }{ + { + name: "absolute index copy directory", + toEnable: []Integration{{ + Name: "tool", Detect: DetectSpec{Command: "tool detect"}, IndexCopyDir: filepath.Join(string(filepath.Separator), "victim"), + }}, + }, + { + name: "parent index copy directory", + toEnable: []Integration{{ + Name: "tool", Detect: DetectSpec{Command: "tool detect"}, IndexCopyDir: "../victim", + }}, + }, + { + name: "unclean index copy directory", + toEnable: []Integration{{ + Name: "tool", Detect: DetectSpec{Command: "tool detect"}, IndexCopyDir: "cache/../victim", + }}, + }, + { + name: "current teardown directory", + toDisable: []Integration{{Name: "tool", Teardown: TeardownSpec{Dirs: []string{"."}}}}, + }, + { + name: "empty teardown directory", + toDisable: []Integration{{Name: "tool", Teardown: TeardownSpec{Dirs: []string{""}}}}, + }, + { + name: "parent teardown directory", + toDisable: []Integration{{Name: "tool", Teardown: TeardownSpec{Dirs: []string{"../victim"}}}}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + shell := &applyShell{} + _, err := PrepareApplyForTarget(shell, t.TempDir(), t.TempDir(), t.TempDir(), tc.toEnable, tc.toDisable, []string{t.TempDir()}) + if err == nil || !strings.Contains(err.Error(), "managed path") { + t.Fatalf("PrepareApplyForTarget error = %v, want managed path validation error", err) + } + if len(shell.calls) != 0 { + t.Fatalf("detection ran before managed path validation: %v", shell.calls) + } + }) + } +} + +func TestPrepareApplyRejectsSymlinkedManagedPathBeforeDetection(t *testing.T) { + outside := t.TempDir() + tests := []struct { + name string + toEnable []Integration + toDisable []Integration + linkRoot string + }{ + { + name: "index source", + toEnable: []Integration{{ + Name: "tool", Detect: DetectSpec{Command: "tool detect"}, IndexCopyDir: "escape/index", + }}, + linkRoot: "main", + }, + { + name: "index destination", + toEnable: []Integration{{ + Name: "tool", Detect: DetectSpec{Command: "tool detect"}, IndexCopyDir: "escape/index", + }}, + linkRoot: "target", + }, + { + name: "teardown destination", + toDisable: []Integration{{Name: "tool", Teardown: TeardownSpec{Dirs: []string{"escape/artifact"}}}}, + linkRoot: "target", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mainWT := t.TempDir() + targetWT := t.TempDir() + linkBase := mainWT + if tc.linkRoot == "target" { + linkBase = targetWT + } + if err := os.Symlink(outside, filepath.Join(linkBase, "escape")); err != nil { + t.Fatal(err) + } + shell := &applyShell{} + _, err := PrepareApplyForTarget(shell, t.TempDir(), mainWT, targetWT, tc.toEnable, tc.toDisable, []string{targetWT}) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("PrepareApplyForTarget error = %v, want symlink validation error", err) + } + if len(shell.calls) != 0 { + t.Fatalf("detection ran before symlink validation: %v", shell.calls) + } + }) + } +} + +func TestPrepareApplyDetectionFallsBackFromCommandToBinary(t *testing.T) { + integ := Integration{ + Name: "tool", Detect: DetectSpec{Command: "tool detect", BinaryName: "tool"}, + Setup: SetupSpec{Command: "tool setup"}, + } + shell := &applyShell{responses: map[string]mockShellResponse{ + "/wt/a:shell[tool detect]": {err: errors.New("command unavailable")}, + "/wt/a:shell[command -v tool]": {output: "/usr/local/bin/tool"}, + }} + prepared, err := PrepareApply(shell, "/repo", "/wt/a", []Integration{integ}, nil, []string{"/wt/a"}) + if err != nil { + t.Fatal(err) + } + labels := strings.Join(plannedLabels(prepared.Plan()), "\n") + if strings.Contains(labels, "Install tool") || !strings.Contains(labels, "Setup tool") { + t.Fatalf("fallback detection plan = %s", labels) + } + wantCalls := []string{"/wt/a:shell[tool detect]", "/wt/a:shell[command -v tool]"} + if !reflect.DeepEqual(shell.calls, wantCalls) { + t.Fatalf("detection calls = %v, want %v", shell.calls, wantCalls) + } +} + +func TestPreparedApplyRejectsInvalidOperationGraphBeforeStart(t *testing.T) { + op := func(id string, dependencies ...string) applyOperation { + return applyOperation{ + phaseID: "phase", phaseName: "Phase", stepID: progress.StepID(id), label: id, + kind: applyShellCommand, dir: "/wt", command: id, dependsOn: dependencies, + } + } + tests := []struct { + name string + operations []applyOperation + }{ + {name: "missing reference", operations: []applyOperation{op("a", "phase\x00missing")}}, + {name: "out of order", operations: []applyOperation{op("a", "phase\x00b"), op("b")}}, + {name: "cycle", operations: []applyOperation{op("a", "phase\x00b"), op("b", "phase\x00a")}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + prepared := PreparedApply{plan: planForOperations(tc.operations), operations: tc.operations} + shell := &applyShell{} + var events []progress.Event + if _, err := prepared.Run(shell, func(event progress.Event) { events = append(events, event) }); err == nil { + t.Fatal("invalid operation graph accepted") + } + if len(events) != 0 || len(shell.calls) != 0 { + t.Fatalf("side effects before graph validation: events=%#v calls=%v", events, shell.calls) + } + }) + } +} + +func TestPrepareApplyOmitsStaticallyEmptyOperations(t *testing.T) { + shell := &applyShell{responses: map[string]mockShellResponse{ + "/wt/a:shell[tool detect]": {output: "installed"}, + }} + prepared, err := PrepareApply(shell, "/repo", "/wt/a", []Integration{{ + Name: "tool", Detect: DetectSpec{Command: "tool detect"}, + }}, []Integration{{Name: "old"}}, []string{"/wt/a"}) + if err != nil { + t.Fatal(err) + } + if !prepared.Empty() || len(prepared.Plan().Phases) != 0 { + t.Fatalf("empty operations were declared: %#v", prepared.Plan()) + } +} + +func TestPrepareApplyKeepsMissingInstallerAsExplicitFailure(t *testing.T) { + shell := &applyShell{responses: map[string]mockShellResponse{ + "/wt/a:shell[tool detect]": {err: errors.New("missing")}, + }} + prepared, err := PrepareApply(shell, "/repo", "/wt/a", []Integration{{ + Name: "tool", Detect: DetectSpec{Command: "tool detect"}, + }}, nil, []string{"/wt/a"}) + if err != nil { + t.Fatal(err) + } + labels := plannedLabels(prepared.Plan()) + if !reflect.DeepEqual(labels, []string{"Prerequisites/Install tool"}) { + t.Fatalf("plan labels = %v, want only explicit installer failure", labels) + } +} + +func TestPrepareApplyDeclaresIndexCopyAndGitignoreOperations(t *testing.T) { + mainWT := t.TempDir() + targetWT := t.TempDir() + if err := os.Mkdir(filepath.Join(mainWT, ".index"), 0o755); err != nil { + t.Fatal(err) + } + integ := Integration{ + Name: "tool", Detect: DetectSpec{Command: "tool detect"}, + Setup: SetupSpec{Command: "tool setup"}, IndexCopyDir: ".index", + GitignoreEntries: []string{".index/"}, + } + shell := &applyShell{responses: map[string]mockShellResponse{ + targetWT + ":shell[tool detect]": {output: "installed"}, + }} + prepared, err := PrepareApply(shell, "/repo", mainWT, []Integration{integ}, nil, []string{targetWT}) + if err != nil { + t.Fatal(err) + } + labels := strings.Join(plannedLabels(prepared.Plan()), "\n") + for _, label := range []string{"Copy index for tool", "Setup tool", "Update .gitignore for tool"} { + if !strings.Contains(labels, label) { + t.Fatalf("plan does not declare %q: %s", label, labels) + } + } +} + +func TestPreparedApplySurfacesFileOperationFailures(t *testing.T) { + tests := []struct { + name string + op applyOperation + files failingApplyFiles + }{ + { + name: "copy", files: failingApplyFiles{copyErr: errors.New("copy broke")}, + op: applyOperation{phaseID: "p", phaseName: "Phase", stepID: "copy", label: "Copy", kind: applyCopy, seedSource: "source", seedDest: "dest"}, + }, + { + name: "gitignore write", files: failingApplyFiles{gitignoreErr: errors.New("write broke")}, + op: applyOperation{phaseID: "p", phaseName: "Phase", stepID: "write", label: "Write", kind: applyGitignore, gitignoreDir: "dir", gitignore: []string{"entry"}}, + }, + { + name: "remove", files: failingApplyFiles{removeErr: errors.New("remove broke")}, + op: applyOperation{phaseID: "p", phaseName: "Phase", stepID: "remove", label: "Remove", kind: applyRemove, dir: "dir"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + prepared := PreparedApply{plan: planForOperations([]applyOperation{tc.op}), operations: []applyOperation{tc.op}, files: tc.files} + phases, err := prepared.Run(&applyShell{}, nil) + if err != nil { + t.Fatal(err) + } + step := phases[0].Steps[0] + if step.Status != progress.StepFailed || step.Error == nil { + t.Fatalf("file failure was not surfaced: %#v", step) + } + }) + } +} + +func TestPreparedApplyReturnsExecutionProjectionAndCompletedStream(t *testing.T) { + integ := testIntegration("tool") + shell := &applyShell{responses: map[string]mockShellResponse{ + "/wt/a:shell[tool detect]": {output: "installed"}, + "/wt/a:shell[tool setup '/wt/a']": {output: "configured"}, + }} + prepared, err := PrepareApply(shell, "/repo", "/wt/a", []Integration{integ}, nil, []string{"/wt/a"}) + if err != nil { + t.Fatal(err) + } + var events []progress.Event + phases, err := prepared.Run(shell, func(event progress.Event) { events = append(events, event) }) + if err != nil { + t.Fatal(err) + } + if err := progress.ValidateCompletedStream(events); err != nil { + t.Fatalf("completed stream invalid: %v", err) + } + plan := prepared.Plan() + if len(phases) != 1 || phases[0].ID != plan.Phases[0].ID || len(phases[0].Steps) != 1 || + phases[0].Steps[0].ID != plan.Phases[0].Steps[0].ID || phases[0].Steps[0].Status != progress.StepDone { + t.Fatalf("Run projection = %#v, plan = %#v", phases, plan) + } +} + +func TestPrepareApplyDeduplicatesIdenticalDependencySpecs(t *testing.T) { + dep := Dependency{Name: "dep", Detect: "dep detect", Install: "dep install"} + one := testIntegration("one", dep) + two := testIntegration("two", dep) + shell := &applyShell{responses: map[string]mockShellResponse{ + "/wt/a:shell[one detect]": {err: errors.New("missing")}, + "/wt/a:shell[two detect]": {err: errors.New("missing")}, + "/wt/a:shell[dep detect]": {err: errors.New("missing")}, + }} + prepared, err := PrepareApply(shell, "/repo", "/wt/a", []Integration{one, two}, nil, []string{"/wt/a"}) + if err != nil { + t.Fatal(err) + } + labels := strings.Join(plannedLabels(prepared.Plan()), "\n") + if strings.Count(labels, "Install dependency dep") != 1 { + t.Fatalf("dependency operation was not deduplicated: %s", labels) + } + if strings.Count(strings.Join(shell.calls, "\n"), "dep detect") != 1 { + t.Fatalf("dependency detection was not deduplicated: %v", shell.calls) + } +} + +func TestPreparedApplyPrerequisiteFailureBlocksOnlyDependentOperations(t *testing.T) { + dependent := testIntegration("dependent", Dependency{Name: "dep", Detect: "dep detect", Install: "dep install"}) + independent := testIntegration("independent") + shell := &applyShell{responses: map[string]mockShellResponse{ + "/wt/a:shell[dependent detect]": {err: errors.New("missing")}, + "/wt/a:shell[independent detect]": {output: "installed"}, + "/wt/a:shell[dep detect]": {err: errors.New("missing")}, + "/wt/a:shell[dep install]": {err: errors.New("dep broke")}, + "/wt/a:shell[independent setup '/wt/a']": {output: "ok"}, + }} + prepared, err := PrepareApply(shell, "/repo", "/wt/a", []Integration{dependent, independent}, nil, []string{"/wt/a"}) + if err != nil { + t.Fatal(err) + } + events, _ := collectPreparedEvents(prepared, shell) + if got := findStep(t, events, "Setup independent"); got.Status != progress.StepDone { + t.Fatalf("independent setup = %#v", got) + } + if got := findStep(t, events, "Setup dependent"); got.Status != progress.StepSkipped { + t.Fatalf("dependent setup = %#v", got) + } +} diff --git a/internal/integration/manager.go b/internal/integration/manager.go index db44e7c..b232d04 100644 --- a/internal/integration/manager.go +++ b/internal/integration/manager.go @@ -5,178 +5,8 @@ import ( "fmt" "os" "path/filepath" - "strings" - - "github.com/abiswas97/sentei/internal/fileutil" - "github.com/abiswas97/sentei/internal/git" ) -// ManagerStatus indicates the outcome of a manager operation step. -type ManagerStatus int - -const ( - StatusRunning ManagerStatus = iota - StatusDone - StatusFailed - StatusSkipped -) - -// ManagerEvent is emitted by EnableIntegration and DisableIntegration to report -// progress for each worktree operation step. -type ManagerEvent struct { - Worktree string - Step string - Status ManagerStatus - Error error -} - -// EnableIntegration installs and sets up integ in every worktree listed in -// wtPaths. For each worktree it: -// 1. Optionally seeds the index directory from mainWTPath. -// 2. Detects whether the tool is already installed; installs it if not. -// 3. Runs the setup command. -// 4. Appends gitignore entries. -// -// Progress is reported via emit. -func EnableIntegration( - shell git.ShellRunner, - repoPath string, - mainWTPath string, - wtPaths []string, - integ Integration, - emit func(ManagerEvent), -) { - for _, wtPath := range wtPaths { - // Step 1: seed index from main worktree when applicable. - if integ.IndexCopyDir != "" && mainWTPath != "" && wtPath != mainWTPath { - src := filepath.Join(mainWTPath, integ.IndexCopyDir) - dst := filepath.Join(wtPath, integ.IndexCopyDir) - // Only copy when source exists; ignore copy errors (non-fatal optimisation). - if _, err := os.Stat(src); err == nil { - _ = os.RemoveAll(dst) - _ = fileutil.CopyDir(src, dst) - } - } - - // Step 2: detect and install. - if !detectTool(shell, wtPath, integ) { - if err := installTool(shell, wtPath, integ, emit); err != nil { - // installTool already emitted the failure event. - continue - } - } else { - // Tool already installed — skip install and dep steps. - for _, dep := range integ.Dependencies { - emit(ManagerEvent{Worktree: wtPath, Step: "Install dependency " + dep.Name, Status: StatusSkipped}) - } - emit(ManagerEvent{Worktree: wtPath, Step: "Install " + integ.Name, Status: StatusSkipped}) - } - - // Step 3: run setup. - stepName := "Setup " + integ.Name - emit(ManagerEvent{Worktree: wtPath, Step: stepName, Status: StatusRunning}) - - workDir := wtPath - if integ.Setup.WorkingDir == "repo" { - workDir = repoPath - } - // Quote the worktree path (it embeds the branch name) before it enters a - // command run via sh -c, so a branch like "a&&rm -rf x" cannot inject. - cmd := strings.ReplaceAll(integ.Setup.Command, "{path}", git.ShellQuote(wtPath)) - - if _, err := shell.RunShell(workDir, cmd); err != nil { - emit(ManagerEvent{Worktree: wtPath, Step: stepName, Status: StatusFailed, Error: err}) - continue - } - emit(ManagerEvent{Worktree: wtPath, Step: stepName, Status: StatusDone}) - - // Step 4: append gitignore entries (best-effort; non-fatal). - _ = appendGitignoreEntries(wtPath, integ.GitignoreEntries) - } -} - -// DisableIntegration tears down integ in every worktree listed in wtPaths. -// For each worktree it runs the teardown command (if any) then removes artifact -// directories. Progress is reported via emit. -func DisableIntegration( - shell git.ShellRunner, - wtPaths []string, - integ Integration, - emit func(ManagerEvent), -) { - for _, wtPath := range wtPaths { - // Step 1: run teardown command. - if integ.Teardown.Command != "" { - stepName := "Teardown " + integ.Name - emit(ManagerEvent{Worktree: wtPath, Step: stepName, Status: StatusRunning}) - if _, err := shell.RunShell(wtPath, integ.Teardown.Command); err != nil { - emit(ManagerEvent{Worktree: wtPath, Step: stepName, Status: StatusFailed, Error: err}) - // Continue to directory removal even if command fails. - } else { - emit(ManagerEvent{Worktree: wtPath, Step: stepName, Status: StatusDone}) - } - } - - // Step 2: remove artifact directories. - for _, dir := range integ.Teardown.Dirs { - dirName := strings.TrimSuffix(dir, "/") - fullPath := filepath.Join(wtPath, dirName) - stepName := fmt.Sprintf("Remove %s in %s", dirName, filepath.Base(wtPath)) - emit(ManagerEvent{Worktree: wtPath, Step: stepName, Status: StatusRunning}) - if err := os.RemoveAll(fullPath); err != nil { - emit(ManagerEvent{Worktree: wtPath, Step: stepName, Status: StatusFailed, Error: err}) - } else { - emit(ManagerEvent{Worktree: wtPath, Step: stepName, Status: StatusDone}) - } - } - } -} - -// detectTool returns true if the integration's binary/command is available. -func detectTool(shell git.ShellRunner, wtPath string, integ Integration) bool { - cmd := integ.Detect.Command - if cmd == "" && integ.Detect.BinaryName != "" { - // Presence, not flags: not every CLI implements --version (ccc - // exits 2), and a tool installed by any manager must be detected. - cmd = "command -v " + integ.Detect.BinaryName - } - if cmd == "" { - return false - } - _, err := shell.RunShell(wtPath, cmd) - return err == nil -} - -// installTool checks dependencies and installs the integration tool. -// It emits events and returns an error on failure. -func installTool(shell git.ShellRunner, wtPath string, integ Integration, emit func(ManagerEvent)) error { - // Check and install dependencies. - for _, dep := range integ.Dependencies { - stepName := "Install dependency " + dep.Name - _, err := shell.RunShell(wtPath, dep.Detect) - if err != nil && dep.Install != "" { - emit(ManagerEvent{Worktree: wtPath, Step: stepName, Status: StatusRunning}) - if _, err2 := shell.RunShell(wtPath, dep.Install); err2 != nil { - emit(ManagerEvent{Worktree: wtPath, Step: stepName, Status: StatusFailed, Error: err2}) - return err2 - } - emit(ManagerEvent{Worktree: wtPath, Step: stepName, Status: StatusDone}) - } else { - emit(ManagerEvent{Worktree: wtPath, Step: stepName, Status: StatusSkipped}) - } - } - - // Install the tool itself. - stepName := "Install " + integ.Name - emit(ManagerEvent{Worktree: wtPath, Step: stepName, Status: StatusRunning}) - if _, err := shell.RunShell(wtPath, integ.Install.Command); err != nil { - emit(ManagerEvent{Worktree: wtPath, Step: stepName, Status: StatusFailed, Error: err}) - return err - } - emit(ManagerEvent{Worktree: wtPath, Step: stepName, Status: StatusDone}) - return nil -} - // appendGitignoreEntries appends missing entries to dir/.gitignore. func appendGitignoreEntries(dir string, entries []string) error { if len(entries) == 0 { @@ -184,12 +14,10 @@ func appendGitignoreEntries(dir string, entries []string) error { } gitignorePath := filepath.Join(dir, ".gitignore") - existing, err := readGitignoreLines(gitignorePath) if err != nil { return fmt.Errorf("reading .gitignore: %w", err) } - var toAdd []string for _, entry := range entries { if !existing[entry] { @@ -199,12 +27,10 @@ func appendGitignoreEntries(dir string, entries []string) error { if len(toAdd) == 0 { return nil } - out, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return fmt.Errorf("opening .gitignore: %w", err) } - for _, entry := range toAdd { if _, err := fmt.Fprintln(out, entry); err != nil { _ = out.Close() @@ -214,8 +40,6 @@ func appendGitignoreEntries(dir string, entries []string) error { return out.Close() } -// readGitignoreLines returns the set of lines in the .gitignore file at path. -// Returns an empty set if the file does not exist. func readGitignoreLines(path string) (map[string]bool, error) { lines := make(map[string]bool) f, err := os.Open(path) @@ -226,7 +50,6 @@ func readGitignoreLines(path string) (map[string]bool, error) { return nil, err } defer func() { _ = f.Close() }() - scanner := bufio.NewScanner(f) for scanner.Scan() { lines[scanner.Text()] = true diff --git a/internal/integration/manager_test.go b/internal/integration/manager_test.go deleted file mode 100644 index 4d4a0e7..0000000 --- a/internal/integration/manager_test.go +++ /dev/null @@ -1,208 +0,0 @@ -package integration - -import ( - "fmt" - "strings" - "testing" -) - -type managerMockShell struct { - responses map[string]mockShellResponse - calls []string -} - -type mockShellResponse struct { - output string - err error -} - -func (m *managerMockShell) RunShell(dir string, command string) (string, error) { - key := fmt.Sprintf("%s:shell[%s]", dir, command) - m.calls = append(m.calls, key) - if resp, ok := m.responses[key]; ok { - return resp.output, resp.err - } - return "", fmt.Errorf("unexpected shell call: %s", key) -} - -func TestEnableIntegration_RunsSetupOnEachWorktree(t *testing.T) { - shell := &managerMockShell{responses: map[string]mockShellResponse{ - "/repo/main:shell[code-review-graph --version]": {output: "1.0"}, - "/repo:shell[code-review-graph build --repo '/repo/main']": {output: "built"}, - "/repo/feat:shell[code-review-graph --version]": {output: "1.0"}, - "/repo:shell[code-review-graph build --repo '/repo/feat']": {output: "built"}, - }} - - integ := codeReviewGraph() - wtPaths := []string{"/repo/main", "/repo/feat"} - var events []ManagerEvent - - EnableIntegration(shell, "/repo", "/repo/main", wtPaths, integ, func(e ManagerEvent) { - events = append(events, e) - }) - - type wantEvent struct { - worktree string - step string - status ManagerStatus - } - // Filter to non-skipped events for setup verification. - var setupEvents []ManagerEvent - var skipCount int - for _, ev := range events { - if ev.Status == StatusSkipped { - skipCount++ - } else { - setupEvents = append(setupEvents, ev) - } - } - - // Should have skip events for deps/install (tool already detected). - if skipCount == 0 { - t.Error("expected some skipped events for deps/install (tool detected)") - } - - // Setup events: Running + Done per worktree. - want := []wantEvent{ - {"/repo/main", "Setup code-review-graph", StatusRunning}, - {"/repo/main", "Setup code-review-graph", StatusDone}, - {"/repo/feat", "Setup code-review-graph", StatusRunning}, - {"/repo/feat", "Setup code-review-graph", StatusDone}, - } - if len(setupEvents) != len(want) { - t.Fatalf("setup event count = %d, want %d\nevents: %+v", len(setupEvents), len(want), setupEvents) - } - for i, w := range want { - got := setupEvents[i] - if got.Worktree != w.worktree || got.Step != w.step || got.Status != w.status { - t.Errorf("event[%d] = {%s, %s, %d}, want {%s, %s, %d}", - i, got.Worktree, got.Step, got.Status, w.worktree, w.step, w.status) - } - } -} - -func TestDisableIntegration_RemovesArtifacts(t *testing.T) { - shell := &managerMockShell{responses: map[string]mockShellResponse{ - "/repo/main:shell[ccc reset --all --force]": {output: ""}, - "/repo/feat:shell[ccc reset --all --force]": {output: ""}, - }} - - integ := cocoindexCode() - wtPaths := []string{"/repo/main", "/repo/feat"} - var events []ManagerEvent - - DisableIntegration(shell, wtPaths, integ, func(e ManagerEvent) { - events = append(events, e) - }) - - if len(events) == 0 { - t.Fatal("expected events to be emitted") - } - - var teardownSteps int - for _, e := range events { - if strings.Contains(e.Step, "Teardown") || strings.Contains(e.Step, "Remove") { - teardownSteps++ - } - } - if teardownSteps < 2 { - t.Errorf("teardown steps = %d, want at least 2", teardownSteps) - } -} - -func TestEnableIntegration_InstallsWhenNotDetected(t *testing.T) { - shell := &managerMockShell{responses: map[string]mockShellResponse{ - // detect fails (not installed) - "/repo/wt:shell[code-review-graph --version]": {err: fmt.Errorf("not found")}, - // dependency checks - "/repo/wt:shell[python3 -c \"import sys; assert sys.version_info >= (3,10)\"]": {output: ""}, - "/repo/wt:shell[pipx --version]": {output: "22.0"}, - // install - "/repo/wt:shell[pipx install code-review-graph]": {output: "installed"}, - // setup - "/repo:shell[code-review-graph build --repo '/repo/wt']": {output: "built"}, - }} - - integ := codeReviewGraph() - var events []ManagerEvent - - EnableIntegration(shell, "/repo", "/repo/wt", []string{"/repo/wt"}, integ, func(e ManagerEvent) { - events = append(events, e) - }) - - var hasInstall bool - for _, e := range events { - if strings.Contains(e.Step, "Install") { - hasInstall = true - } - if e.Status == StatusFailed { - t.Errorf("unexpected failure event: %+v", e) - } - } - if !hasInstall { - t.Errorf("expected Install event, got: %+v", events) - } -} - -func TestEnableIntegration_SetupFailureEmitsFailedEvent(t *testing.T) { - shell := &managerMockShell{responses: map[string]mockShellResponse{ - "/repo/wt:shell[code-review-graph --version]": {output: "1.0"}, - "/repo:shell[code-review-graph build --repo '/repo/wt']": {err: fmt.Errorf("build failed")}, - }} - - integ := codeReviewGraph() - var events []ManagerEvent - - EnableIntegration(shell, "/repo", "/repo/wt", []string{"/repo/wt"}, integ, func(e ManagerEvent) { - events = append(events, e) - }) - - var failed bool - for _, e := range events { - if e.Status == StatusFailed && strings.Contains(e.Step, "Setup") { - failed = true - } - } - if !failed { - t.Errorf("expected StatusFailed for setup, got: %+v", events) - } -} - -func TestDisableIntegration_TeardownFailureEmitsFailedEvent(t *testing.T) { - shell := &managerMockShell{responses: map[string]mockShellResponse{ - "/repo/wt:shell[ccc reset --all --force]": {err: fmt.Errorf("reset failed")}, - }} - - integ := cocoindexCode() - var events []ManagerEvent - - DisableIntegration(shell, []string{"/repo/wt"}, integ, func(e ManagerEvent) { - events = append(events, e) - }) - - var failed bool - for _, e := range events { - if e.Status == StatusFailed && strings.Contains(e.Step, "Teardown") { - failed = true - } - } - if !failed { - t.Errorf("expected StatusFailed for teardown, got: %+v", events) - } -} - -func TestDetectTool_BinaryNameUsesPresenceProbe(t *testing.T) { - shell := &managerMockShell{responses: map[string]mockShellResponse{ - "/repo/wt:shell[command -v ccc]": {output: "/Users/x/.local/bin/ccc"}, - }} - if !detectTool(shell, "/repo/wt", cocoindexCode()) { - t.Error("a binary on PATH must be detected even if it lacks --version") - } - - missing := &managerMockShell{responses: map[string]mockShellResponse{ - "/repo/wt:shell[command -v ccc]": {err: fmt.Errorf("not found")}, - }} - if detectTool(missing, "/repo/wt", cocoindexCode()) { - t.Error("a binary missing from PATH must not be detected") - } -} diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go deleted file mode 100644 index 2023565..0000000 --- a/internal/pipeline/pipeline.go +++ /dev/null @@ -1,140 +0,0 @@ -// Package pipeline defines the shared vocabulary for multi-phase operations -// (worktree creation, repo clone/create/migrate): step statuses, per-step -// results grouped into phases, and the events emitted while a phase runs. -package pipeline - -type StepStatus int - -const ( - StepPending StepStatus = iota - StepRunning - StepDone - StepFailed - StepSkipped -) - -// StepResult is the recorded outcome of a single step within a phase. -type StepResult struct { - Name string - Status StepStatus - Message string - Error error -} - -// Phase is a named group of step results. -type Phase struct { - Name string - Steps []StepResult -} - -// Event is a progress notification emitted while a pipeline runs. -type Event struct { - Phase string - Step string - Status StepStatus - Message string - Error error -} - -// HasFailures reports whether any step in the phase failed. -func (p *Phase) HasFailures() bool { - for _, s := range p.Steps { - if s.Status == StepFailed { - return true - } - } - return false -} - -// PhasesHaveFailures reports whether any step across the phases failed. -func PhasesHaveFailures(phases []Phase) bool { - for i := range phases { - if phases[i].HasFailures() { - return true - } - } - return false -} - -// StepFunc does the work of one step. The returned message becomes the Done -// result's Message; a non-nil error fails the step. -type StepFunc func() (message string, err error) - -// RunStep executes one step with the standard transitions: a Running event, -// then a Done or Failed event mirrored in the returned StepResult. -func RunStep(phase, step string, emit func(Event), fn StepFunc) StepResult { - emit(Event{Phase: phase, Step: step, Status: StepRunning}) - msg, err := fn() - if err != nil { - emit(Event{Phase: phase, Step: step, Status: StepFailed, Error: err}) - return StepResult{Name: step, Status: StepFailed, Error: err} - } - emit(Event{Phase: phase, Step: step, Status: StepDone, Message: msg}) - return StepResult{Name: step, Status: StepDone, Message: msg} -} - -// PhaseRecorder builds a Phase step by step for sequential pipelines, -// mirroring each recorded transition to the emit callback. -type PhaseRecorder struct { - phase Phase - emit func(Event) -} - -func NewPhaseRecorder(name string, emit func(Event)) *PhaseRecorder { - return &PhaseRecorder{phase: Phase{Name: name}, emit: emit} -} - -// Step runs fn with the standard transitions, records the result, and -// reports success so callers can early-return on failure. -func (r *PhaseRecorder) Step(name string, fn StepFunc) bool { - result := RunStep(r.phase.Name, name, r.emit, fn) - r.phase.Steps = append(r.phase.Steps, result) - return result.Status != StepFailed -} - -// Done records a successful step outside the standard Step flow. -func (r *PhaseRecorder) Done(name, message string) { - r.phase.Steps = append(r.phase.Steps, StepResult{Name: name, Status: StepDone, Message: message}) - r.emit(Event{Phase: r.phase.Name, Step: name, Status: StepDone, Message: message}) -} - -// Fail records a failed step outside the standard Step flow (e.g. a -// precondition that fails before the step starts running). -func (r *PhaseRecorder) Fail(name string, err error) { - r.phase.Steps = append(r.phase.Steps, StepResult{Name: name, Status: StepFailed, Error: err}) - r.emit(Event{Phase: r.phase.Name, Step: name, Status: StepFailed, Error: err}) -} - -// Skip records a non-failing skipped step (e.g. best-effort work that could -// not run). -func (r *PhaseRecorder) Skip(name, message string) { - r.phase.Steps = append(r.phase.Steps, StepResult{Name: name, Status: StepSkipped, Message: message}) - r.emit(Event{Phase: r.phase.Name, Step: name, Status: StepSkipped, Message: message}) -} - -// Record appends an externally built result without emitting, for steps with -// non-standard event flows. -func (r *PhaseRecorder) Record(result StepResult) { - r.phase.Steps = append(r.phase.Steps, result) -} - -// Emit forwards an event for the recorder's phase without recording a step, -// for intermediate progress updates within a step. -func (r *PhaseRecorder) Emit(step string, status StepStatus, message string) { - r.emit(Event{Phase: r.phase.Name, Step: step, Status: status, Message: message}) -} - -func (r *PhaseRecorder) Phase() Phase { return r.phase } - -// FirstFailure returns the first failed step across the phases, along with -// the name of the phase it belongs to. -func FirstFailure(phases []Phase) (phaseName string, step StepResult, ok bool) { - for _, p := range phases { - for _, s := range p.Steps { - if s.Status == StepFailed { - return p.Name, s, true - } - } - } - return "", StepResult{}, false -} diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go deleted file mode 100644 index d5c5665..0000000 --- a/internal/pipeline/pipeline_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package pipeline - -import ( - "errors" - "testing" -) - -func collectEvents() (*[]Event, func(Event)) { - events := &[]Event{} - return events, func(e Event) { *events = append(*events, e) } -} - -func TestRunStep_Success(t *testing.T) { - events, emit := collectEvents() - - result := RunStep("Phase", "Step", emit, func() (string, error) { - return "did it", nil - }) - - want := StepResult{Name: "Step", Status: StepDone, Message: "did it"} - if result != want { - t.Errorf("result = %+v, want %+v", result, want) - } - if len(*events) != 2 { - t.Fatalf("got %d events, want 2 (Running, Done)", len(*events)) - } - if (*events)[0].Status != StepRunning { - t.Errorf("first event status = %v, want StepRunning", (*events)[0].Status) - } - if (*events)[1].Status != StepDone || (*events)[1].Message != "did it" { - t.Errorf("second event = %+v, want Done with message", (*events)[1]) - } -} - -func TestRunStep_Failure(t *testing.T) { - events, emit := collectEvents() - stepErr := errors.New("boom") - - result := RunStep("Phase", "Step", emit, func() (string, error) { - return "", stepErr - }) - - if result.Status != StepFailed || !errors.Is(result.Error, stepErr) { - t.Errorf("result = %+v, want failed with boom", result) - } - if len(*events) != 2 { - t.Fatalf("got %d events, want 2 (Running, Failed)", len(*events)) - } - if (*events)[1].Status != StepFailed || !errors.Is((*events)[1].Error, stepErr) { - t.Errorf("second event = %+v, want Failed with boom", (*events)[1]) - } -} - -func TestPhaseRecorder(t *testing.T) { - events, emit := collectEvents() - rec := NewPhaseRecorder("Phase", emit) - - if ok := rec.Step("first", func() (string, error) { return "msg", nil }); !ok { - t.Error("successful step should report ok") - } - if ok := rec.Step("second", func() (string, error) { return "", errors.New("nope") }); ok { - t.Error("failed step should report !ok") - } - rec.Skip("third", "not needed") - rec.Done("fourth", "manual done") - rec.Fail("fifth", errors.New("precondition")) - rec.Record(StepResult{Name: "sixth", Status: StepSkipped}) - rec.Emit("first", StepRunning, "progress note") - - phase := rec.Phase() - if phase.Name != "Phase" { - t.Errorf("phase name = %q, want Phase", phase.Name) - } - wantStatuses := []StepStatus{StepDone, StepFailed, StepSkipped, StepDone, StepFailed, StepSkipped} - if len(phase.Steps) != len(wantStatuses) { - t.Fatalf("got %d steps, want %d", len(phase.Steps), len(wantStatuses)) - } - for i, want := range wantStatuses { - if phase.Steps[i].Status != want { - t.Errorf("step[%d] status = %v, want %v", i, phase.Steps[i].Status, want) - } - } - if !phase.HasFailures() { - t.Error("phase with failed steps must report HasFailures") - } - - // Record must not emit; everything else must. 2 (Step) + 2 (Step) + 1 (Skip) - // + 1 (Done) + 1 (Fail) + 0 (Record) + 1 (Emit) = 8. - if len(*events) != 8 { - t.Errorf("got %d events, want 8", len(*events)) - } -} - -func TestFirstFailure(t *testing.T) { - err := errors.New("broke") - phases := []Phase{ - {Name: "A", Steps: []StepResult{{Name: "ok", Status: StepDone}}}, - {Name: "B", Steps: []StepResult{{Name: "bad", Status: StepFailed, Error: err}, {Name: "worse", Status: StepFailed}}}, - } - - phaseName, step, ok := FirstFailure(phases) - if !ok || phaseName != "B" || step.Name != "bad" || !errors.Is(step.Error, err) { - t.Errorf("FirstFailure = (%q, %+v, %v), want (B, bad, true)", phaseName, step, ok) - } - - if _, _, ok := FirstFailure(phases[:1]); ok { - t.Error("FirstFailure on all-done phases must report !ok") - } -} - -func TestPhasesHaveFailures(t *testing.T) { - clean := []Phase{{Name: "A", Steps: []StepResult{{Status: StepDone}, {Status: StepSkipped}}}} - if PhasesHaveFailures(clean) { - t.Error("clean phases must not report failures") - } - if !PhasesHaveFailures(append(clean, Phase{Steps: []StepResult{{Status: StepFailed}}})) { - t.Error("a failed step must be reported") - } -} diff --git a/internal/playground/setup_test.go b/internal/playground/setup_test.go index 086d5c6..32629b4 100644 --- a/internal/playground/setup_test.go +++ b/internal/playground/setup_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/abiswas97/sentei/internal/testtmp" ) func TestSetup_CreatesExpectedWorktrees(t *testing.T) { @@ -15,7 +17,9 @@ func TestSetup_CreatesExpectedWorktrees(t *testing.T) { } defer cleanup() - out, err := exec.Command("git", "-C", repoPath, "worktree", "list", "--porcelain").Output() + wtList := exec.Command("git", "-C", repoPath, "worktree", "list", "--porcelain") + wtList.Env = testtmp.HermeticGitEnv() + out, err := wtList.Output() if err != nil { t.Fatalf("git worktree list error: %v", err) } @@ -70,7 +74,9 @@ func TestSetup_ConcurrentSessionsAreIsolated(t *testing.T) { // Destroying session B must leave session A fully functional. cleanupB() - if _, err := exec.Command("git", "-C", repoA, "worktree", "list", "--porcelain").Output(); err != nil { + wtListA := exec.Command("git", "-C", repoA, "worktree", "list", "--porcelain") + wtListA.Env = testtmp.HermeticGitEnv() + if _, err := wtListA.Output(); err != nil { t.Fatalf("session A broken after session B cleanup: %v", err) } } diff --git a/internal/progress/execution.go b/internal/progress/execution.go new file mode 100644 index 0000000..24236f9 --- /dev/null +++ b/internal/progress/execution.go @@ -0,0 +1,466 @@ +package progress + +import ( + "fmt" + "sync" +) + +// StepFunc does the work of one declared step. The returned message becomes +// the successful result message; a non-nil error fails the step. +type StepFunc func() (message string, err error) + +// Execution owns the mutable state of a validated Plan. All transitions and +// emissions are serialized; Run releases the lock while user work executes. +type Execution struct { + mu sync.Mutex + emit func(Event) + phases map[PhaseID]*executionPhase + order []PhaseID + pending []queuedEvent + emitting bool + nextSequence uint64 + deliveredSequence uint64 + delivery *sync.Cond + deliveryErr error + lifecycle executionLifecycle + finishTarget uint64 +} + +type executionLifecycle uint8 + +const ( + executionActive executionLifecycle = iota + executionFinishing + executionFinished +) + +type queuedEvent struct { + sequence uint64 + event Event +} + +type executionPhase struct { + id PhaseID + label string + steps map[StepID]*executionStep + order []StepID +} + +type executionStep struct { + id StepID + label string + checkpoints int + checkpoint int + status StepStatus + message string + err error +} + +// Start validates a plan, copies it into execution state, and emits the whole +// declaration prefix before any phase-close markers. +func Start(plan Plan, emit func(Event)) (*Execution, error) { + if emit == nil { + emit = func(Event) {} + } + x := &Execution{ + emit: emit, + phases: make(map[PhaseID]*executionPhase, len(plan.Phases)), + order: make([]PhaseID, 0, len(plan.Phases)), + } + x.delivery = sync.NewCond(&x.mu) + for phaseIndex, plannedPhase := range plan.Phases { + if plannedPhase.ID == "" { + return nil, fmt.Errorf("phase %d has empty ID", phaseIndex) + } + if _, exists := x.phases[plannedPhase.ID]; exists { + return nil, fmt.Errorf("duplicate phase ID %q", plannedPhase.ID) + } + if len(plannedPhase.Steps) == 0 { + return nil, fmt.Errorf("phase %q has no steps", plannedPhase.ID) + } + label := plannedPhase.Label + if label == "" { + label = plannedPhase.ID + } + phase := &executionPhase{ + id: plannedPhase.ID, + label: label, + steps: make(map[StepID]*executionStep, len(plannedPhase.Steps)), + order: make([]StepID, 0, len(plannedPhase.Steps)), + } + for stepIndex, plannedStep := range plannedPhase.Steps { + if plannedStep.ID == "" { + return nil, fmt.Errorf("phase %q step %d has empty ID", plannedPhase.ID, stepIndex) + } + if _, exists := phase.steps[plannedStep.ID]; exists { + return nil, fmt.Errorf("phase %q has duplicate step ID %q", plannedPhase.ID, plannedStep.ID) + } + stepLabel := plannedStep.Label + if stepLabel == "" { + stepLabel = plannedStep.ID + } + phase.steps[plannedStep.ID] = &executionStep{ + id: plannedStep.ID, + label: stepLabel, + checkpoints: max(plannedStep.Checkpoints, 1), + status: StepPending, + } + phase.order = append(phase.order, plannedStep.ID) + } + x.phases[plannedPhase.ID] = phase + x.order = append(x.order, plannedPhase.ID) + } + + x.mu.Lock() + drain := false + for _, phaseID := range x.order { + phase := x.phases[phaseID] + for _, stepID := range phase.order { + step := phase.steps[stepID] + _, queuedDrain := x.queueLocked(Event{ + Phase: phase.id, PhaseLabel: phase.label, + Step: step.id, StepLabel: step.label, + Status: StepPending, Of: step.checkpoints, + }) + drain = drain || queuedDrain + } + } + for _, phaseID := range x.order { + phase := x.phases[phaseID] + _, queuedDrain := x.queueLocked(Event{Phase: phase.id, PhaseLabel: phase.label, Close: true}) + drain = drain || queuedDrain + } + x.mu.Unlock() + if drain { + if err := x.drain(); err != nil { + return nil, err + } + } + return x, nil +} + +// Running marks a step active and optionally advances its checkpoint. +func (x *Execution) Running(phaseID PhaseID, stepID StepID, checkpoint int, message string) error { + x.mu.Lock() + if err := x.mutationErrorLocked(); err != nil { + x.mu.Unlock() + return err + } + phase, step, err := x.step(phaseID, stepID) + if err != nil { + x.mu.Unlock() + return err + } + if terminal(step.status) { + x.mu.Unlock() + return fmt.Errorf("phase %q step %q is already terminal", phaseID, stepID) + } + if checkpoint < step.checkpoint { + x.mu.Unlock() + return fmt.Errorf("phase %q step %q checkpoint regressed from %d to %d", phaseID, stepID, step.checkpoint, checkpoint) + } + if checkpoint > step.checkpoints { + x.mu.Unlock() + return fmt.Errorf("phase %q step %q checkpoint %d exceeds declared %d", phaseID, stepID, checkpoint, step.checkpoints) + } + step.status = StepRunning + step.checkpoint = checkpoint + if message != "" { + step.message = message + } + _, drain := x.queueLocked(Event{ + Phase: phase.id, PhaseLabel: phase.label, + Step: step.id, StepLabel: step.label, + Status: StepRunning, Checkpoint: checkpoint, Of: step.checkpoints, Message: message, + }) + x.mu.Unlock() + if drain { + return x.drain() + } + return nil +} + +// Done resolves a declared step successfully. +func (x *Execution) Done(phaseID PhaseID, stepID StepID, message string) (StepResult, error) { + return x.resolve(phaseID, stepID, StepDone, message, nil) +} + +// Fail resolves a declared step unsuccessfully. +func (x *Execution) Fail(phaseID PhaseID, stepID StepID, err error) (StepResult, error) { + if err == nil { + return StepResult{}, fmt.Errorf("phase %q step %q has nil failure", phaseID, stepID) + } + return x.resolve(phaseID, stepID, StepFailed, "", err) +} + +// Skip resolves a declared step without running it. +func (x *Execution) Skip(phaseID PhaseID, stepID StepID, reason string) (StepResult, error) { + if reason == "" { + return StepResult{}, fmt.Errorf("phase %q step %q has empty skip reason", phaseID, stepID) + } + return x.resolve(phaseID, stepID, StepSkipped, reason, nil) +} + +// Run emits the standard running and terminal transitions. fn executes +// outside the mutex so independent steps can progress concurrently. +func (x *Execution) Run(phaseID PhaseID, stepID StepID, fn StepFunc) (StepResult, error) { + if fn == nil { + return StepResult{}, fmt.Errorf("phase %q step %q has nil function", phaseID, stepID) + } + if err := x.claim(phaseID, stepID); err != nil { + return StepResult{}, err + } + message, err := fn() + if err != nil { + return x.Fail(phaseID, stepID, err) + } + return x.Done(phaseID, stepID, message) +} + +func (x *Execution) claim(phaseID PhaseID, stepID StepID) error { + x.mu.Lock() + if err := x.mutationErrorLocked(); err != nil { + x.mu.Unlock() + return err + } + phase, step, err := x.step(phaseID, stepID) + if err != nil { + x.mu.Unlock() + return err + } + if step.status != StepPending { + x.mu.Unlock() + return fmt.Errorf("phase %q step %q cannot be claimed from status %d", phaseID, stepID, step.status) + } + step.status = StepRunning + _, drain := x.queueLocked(Event{ + Phase: phase.id, PhaseLabel: phase.label, + Step: step.id, StepLabel: step.label, + Status: StepRunning, Of: step.checkpoints, + }) + x.mu.Unlock() + if drain { + return x.drain() + } + return nil +} + +// SkipPending skips only untouched steps in one phase. +func (x *Execution) SkipPending(phaseID PhaseID, reason string) error { + if reason == "" { + return fmt.Errorf("phase %q has empty skip reason", phaseID) + } + x.mu.Lock() + if err := x.mutationErrorLocked(); err != nil { + x.mu.Unlock() + return err + } + phase, exists := x.phases[phaseID] + if !exists { + x.mu.Unlock() + return fmt.Errorf("unknown phase ID %q", phaseID) + } + drain := false + for _, stepID := range phase.order { + step := phase.steps[stepID] + if step.status == StepPending { + _, _, queuedDrain := x.resolveLocked(phase, step, StepSkipped, reason, nil) + drain = drain || queuedDrain + } + } + x.mu.Unlock() + if drain { + return x.drain() + } + return nil +} + +// Finish is the terminal safety net and producer-shutdown barrier: every +// unresolved step becomes skipped, and the method waits until all events +// queued through that terminalization have been delivered. Producers must +// join their workers before calling Finish. Finish must not be called +// reentrantly from an emit callback. +func (x *Execution) Finish(reason string) error { + if reason == "" { + return fmt.Errorf("finish reason is empty") + } + x.mu.Lock() + drain := false + if x.lifecycle == executionActive { + x.lifecycle = executionFinishing + x.finishTarget = x.nextSequence + for _, phaseID := range x.order { + phase := x.phases[phaseID] + for _, stepID := range phase.order { + step := phase.steps[stepID] + if terminal(step.status) { + continue + } + if x.deliveryErr != nil { + step.status = StepSkipped + step.checkpoint = step.checkpoints + step.message = reason + step.err = nil + continue + } + _, sequence, queuedDrain := x.resolveLocked(phase, step, StepSkipped, reason, nil) + x.finishTarget = sequence + drain = drain || queuedDrain + } + } + } + target := x.finishTarget + x.mu.Unlock() + if drain { + _ = x.drain() + } + err := x.waitForDelivery(target) + x.mu.Lock() + if x.lifecycle == executionFinishing { + x.lifecycle = executionFinished + } + x.mu.Unlock() + return err +} + +// Phases returns a plan-ordered snapshot of the execution's internal state. +// The returned phases and step slices may be mutated by the caller. +func (x *Execution) Phases() []Phase { + x.mu.Lock() + defer x.mu.Unlock() + + phases := make([]Phase, 0, len(x.order)) + for _, phaseID := range x.order { + state := x.phases[phaseID] + phase := Phase{ID: state.id, Name: state.label, Steps: make([]StepResult, 0, len(state.order))} + for _, stepID := range state.order { + step := state.steps[stepID] + phase.Steps = append(phase.Steps, StepResult{ + ID: step.id, Name: step.label, Status: step.status, Message: step.message, Error: step.err, + }) + } + phases = append(phases, phase) + } + return phases +} + +func (x *Execution) resolve(phaseID PhaseID, stepID StepID, status StepStatus, message string, stepErr error) (StepResult, error) { + x.mu.Lock() + if err := x.mutationErrorLocked(); err != nil { + x.mu.Unlock() + return StepResult{}, err + } + phase, step, err := x.step(phaseID, stepID) + if err != nil { + x.mu.Unlock() + return StepResult{}, err + } + if terminal(step.status) { + x.mu.Unlock() + return StepResult{}, fmt.Errorf("phase %q step %q is already terminal", phaseID, stepID) + } + result, _, drain := x.resolveLocked(phase, step, status, message, stepErr) + x.mu.Unlock() + if drain { + if err := x.drain(); err != nil { + return result, err + } + } + return result, nil +} + +func (x *Execution) mutationErrorLocked() error { + if x.deliveryErr != nil { + return x.deliveryErr + } + if x.lifecycle != executionActive { + return fmt.Errorf("execution has begun finishing") + } + return nil +} + +func (x *Execution) resolveLocked(phase *executionPhase, step *executionStep, status StepStatus, message string, stepErr error) (StepResult, uint64, bool) { + step.status = status + step.checkpoint = step.checkpoints + step.message = message + step.err = stepErr + sequence, drain := x.queueLocked(Event{ + Phase: phase.id, PhaseLabel: phase.label, + Step: step.id, StepLabel: step.label, + Status: status, Of: step.checkpoints, Message: message, Error: stepErr, + }) + return StepResult{ID: step.id, Name: step.label, Status: status, Message: message, Error: stepErr}, sequence, drain +} + +func (x *Execution) queueLocked(event Event) (uint64, bool) { + x.nextSequence++ + sequence := x.nextSequence + x.pending = append(x.pending, queuedEvent{sequence: sequence, event: event}) + if x.emitting { + return sequence, false + } + x.emitting = true + return sequence, true +} + +func (x *Execution) drain() (err error) { + defer func() { + if recovered := recover(); recovered != nil { + x.mu.Lock() + x.deliveryErr = callbackPanicError(recovered) + x.pending = nil + x.emitting = false + x.delivery.Broadcast() + err = x.deliveryErr + x.mu.Unlock() + } + }() + for { + x.mu.Lock() + if len(x.pending) == 0 { + x.emitting = false + x.mu.Unlock() + return nil + } + queued := x.pending[0] + x.pending = x.pending[1:] + x.mu.Unlock() + x.emit(queued.event) + x.mu.Lock() + x.deliveredSequence = queued.sequence + x.delivery.Broadcast() + x.mu.Unlock() + } +} + +func (x *Execution) waitForDelivery(target uint64) error { + x.mu.Lock() + defer x.mu.Unlock() + for x.deliveredSequence < target && x.deliveryErr == nil { + x.delivery.Wait() + } + return x.deliveryErr +} + +func callbackPanicError(recovered any) error { + if err, ok := recovered.(error); ok { + return fmt.Errorf("progress emit callback panicked: %w", err) + } + return fmt.Errorf("progress emit callback panicked: %v", recovered) +} + +func (x *Execution) step(phaseID PhaseID, stepID StepID) (*executionPhase, *executionStep, error) { + phase, exists := x.phases[phaseID] + if !exists { + return nil, nil, fmt.Errorf("unknown phase ID %q", phaseID) + } + step, exists := phase.steps[stepID] + if !exists { + return nil, nil, fmt.Errorf("phase %q has no step ID %q", phaseID, stepID) + } + return phase, step, nil +} + +func terminal(status StepStatus) bool { + return status == StepDone || status == StepFailed || status == StepSkipped +} diff --git a/internal/progress/execution_test.go b/internal/progress/execution_test.go new file mode 100644 index 0000000..160e9c6 --- /dev/null +++ b/internal/progress/execution_test.go @@ -0,0 +1,632 @@ +package progress + +import ( + "errors" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestExecution_FinishSettlesDistinctStepsWithEqualLabels(t *testing.T) { + plan := Plan{Phases: []PlannedPhase{{ID: "integrations", Label: "Integrations", Steps: []PlannedStep{ + {ID: "ccc.copy-index", Label: "Copy index from main"}, + {ID: "crg.copy-index", Label: "Copy index from main"}, + }}}} + var events []Event + x, err := Start(plan, func(ev Event) { events = append(events, ev) }) + if err != nil { + t.Fatal(err) + } + if _, err := x.Done("integrations", "ccc.copy-index", ""); err != nil { + t.Fatal(err) + } + if err := x.Finish("blocked by earlier failure"); err != nil { + t.Fatal(err) + } + + states := Snapshot(events) + if len(states) != 1 || len(states[0].Steps) != 2 { + t.Fatalf("states = %#v", states) + } + if !states[0].Settled() { + t.Fatalf("phase did not settle: %#v", states[0]) + } + if states[0].Steps[0].ID != "ccc.copy-index" || states[0].Steps[1].ID != "crg.copy-index" { + t.Fatalf("step IDs collapsed: %#v", states[0].Steps) + } + if states[0].Steps[1].Status != StepSkipped { + t.Fatalf("second step = %#v", states[0].Steps[1]) + } + if states[0].Steps[1].Message != "blocked by earlier failure" { + t.Fatalf("skip reason = %q", states[0].Steps[1].Message) + } + if err := ValidateStream(events); err != nil { + t.Fatalf("execution emitted invalid stream: %v", err) + } +} + +func TestExecution_RejectsUndeclaredAndTerminalMutation(t *testing.T) { + x, err := Start(Plan{Phases: []PlannedPhase{{ID: "p", Label: "Phase", Steps: []PlannedStep{{ID: "s", Label: "Step"}}}}}, func(Event) {}) + if err != nil { + t.Fatal(err) + } + if _, err := x.Done("p", "missing", ""); err == nil { + t.Fatal("undeclared step accepted") + } + if _, err := x.Done("p", "s", ""); err != nil { + t.Fatal(err) + } + if _, err := x.Fail("p", "s", errors.New("late")); err == nil { + t.Fatal("terminal mutation accepted") + } +} + +func TestExecution_CheckpointsAreMonotonicUnderConcurrency(t *testing.T) { + var mu sync.Mutex + var events []Event + x, err := Start(Plan{Phases: []PlannedPhase{{ID: "p", Label: "Phase", Steps: []PlannedStep{{ID: "s", Label: "Step", Checkpoints: 2}}}}}, func(ev Event) { + mu.Lock() + defer mu.Unlock() + events = append(events, ev) + }) + if err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = x.Running("p", "s", 1, "first") + }() + } + wg.Wait() + if err := x.Running("p", "s", 2, "second"); err != nil { + t.Fatal(err) + } + if _, err := x.Done("p", "s", "complete"); err != nil { + t.Fatal(err) + } + + mu.Lock() + gotEvents := append([]Event(nil), events...) + mu.Unlock() + if err := ValidateStream(gotEvents); err != nil { + t.Fatalf("concurrent stream invalid: %v", err) + } + step := Snapshot(gotEvents)[0].Steps[0] + if step.Reached != 2 || step.Declared != 2 { + t.Fatalf("checkpoint progress = %d/%d, want 2/2", step.Reached, step.Declared) + } +} + +func TestExecution_StartRejectsInvalidIDs(t *testing.T) { + tests := []struct { + name string + plan Plan + }{ + {"empty phase ID", Plan{Phases: []PlannedPhase{{Label: "Phase"}}}}, + {"duplicate phase ID", Plan{Phases: []PlannedPhase{{ID: "p"}, {ID: "p"}}}}, + {"empty step ID", Plan{Phases: []PlannedPhase{{ID: "p", Steps: []PlannedStep{{Label: "Step"}}}}}}, + {"duplicate step ID", Plan{Phases: []PlannedPhase{{ID: "p", Steps: []PlannedStep{{ID: "s"}, {ID: "s"}}}}}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, err := Start(tc.plan, func(Event) {}); err == nil { + t.Fatal("invalid plan accepted") + } + }) + } +} + +func TestExecution_StartEmitsCompleteNormalizedDeclarationPrefix(t *testing.T) { + var events []Event + _, err := Start(Plan{Phases: []PlannedPhase{ + {ID: "a", Label: "Alpha", Steps: []PlannedStep{{ID: "one", Label: "One", Checkpoints: 0}}}, + {ID: "b", Label: "Beta", Steps: []PlannedStep{{ID: "two", Label: "Two", Checkpoints: 3}}}, + }}, func(ev Event) { events = append(events, ev) }) + if err != nil { + t.Fatal(err) + } + if len(events) != 4 { + t.Fatalf("events = %#v, want two declarations then two closes", events) + } + if events[0].Status != StepPending || events[0].Of != 1 || events[0].PhaseLabel != "Alpha" || events[0].StepLabel != "One" { + t.Fatalf("first declaration = %#v", events[0]) + } + if events[1].Status != StepPending || events[1].Of != 3 { + t.Fatalf("second declaration = %#v", events[1]) + } + if !events[2].Close || !events[3].Close { + t.Fatalf("declaration was not a complete prefix: %#v", events) + } + if err := ValidateStream(events); err != nil { + t.Fatalf("declaration stream invalid: %v", err) + } +} + +func TestExecution_RejectsCheckpointRegressionAndOverflow(t *testing.T) { + x, err := Start(Plan{Phases: []PlannedPhase{{ID: "p", Steps: []PlannedStep{{ID: "s", Checkpoints: 2}}}}}, func(Event) {}) + if err != nil { + t.Fatal(err) + } + if err := x.Running("p", "s", 1, "first"); err != nil { + t.Fatal(err) + } + if err := x.Running("p", "s", 0, "stale"); err == nil { + t.Fatal("checkpoint regression accepted") + } + if err := x.Running("p", "s", 3, "overflow"); err == nil { + t.Fatal("checkpoint overflow accepted") + } +} + +func TestExecution_SkipPendingLeavesRunningStepAlone(t *testing.T) { + var events []Event + x, err := Start(Plan{Phases: []PlannedPhase{{ID: "p", Steps: []PlannedStep{{ID: "running"}, {ID: "pending"}}}}}, func(ev Event) { + events = append(events, ev) + }) + if err != nil { + t.Fatal(err) + } + if err := x.Running("p", "running", 0, ""); err != nil { + t.Fatal(err) + } + if err := x.SkipPending("p", "blocked"); err != nil { + t.Fatal(err) + } + states := Snapshot(events) + if states[0].Steps[0].Status != StepRunning || states[0].Steps[1].Status != StepSkipped { + t.Fatalf("steps = %#v", states[0].Steps) + } +} + +func TestExecution_RunDoesNotHoldLockWhileFunctionRuns(t *testing.T) { + x, err := Start(Plan{Phases: []PlannedPhase{{ID: "p", Steps: []PlannedStep{{ID: "run"}, {ID: "other"}}}}}, func(Event) {}) + if err != nil { + t.Fatal(err) + } + entered := make(chan struct{}) + release := make(chan struct{}) + done := make(chan error, 1) + go func() { + _, err := x.Run("p", "run", func() (string, error) { + close(entered) + <-release + return "ok", nil + }) + done <- err + }() + <-entered + if _, err := x.Skip("p", "other", "not needed"); err != nil { + t.Fatalf("parallel transition blocked or failed: %v", err) + } + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } +} + +func TestExecution_EmitCallbackCanSynchronouslyTransitionAnotherStep(t *testing.T) { + var x *Execution + var events []Event + var callbackErr error + x, startErr := Start(Plan{Phases: []PlannedPhase{{ID: "p", Steps: []PlannedStep{{ID: "a"}, {ID: "b"}}}}}, func(ev Event) { + events = append(events, ev) + if ev.Step == "a" && ev.Status == StepDone { + callbackErr = x.Running("p", "b", 0, "triggered by a") + if callbackErr == nil { + _, callbackErr = x.Skip("p", "b", "triggered by a") + } + } + }) + if startErr != nil { + t.Fatal(startErr) + } + + done := make(chan error, 1) + go func() { + _, err := x.Done("p", "a", "complete") + done <- err + }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("emit callback deadlocked while transitioning another step") + } + if callbackErr != nil { + t.Fatalf("callback transition: %v", callbackErr) + } + if err := ValidateStream(events); err != nil { + t.Fatalf("reentrant emission order invalid: %v", err) + } + statuses := []StepStatus{events[len(events)-3].Status, events[len(events)-2].Status, events[len(events)-1].Status} + if statuses[0] != StepDone || statuses[1] != StepRunning || statuses[2] != StepSkipped { + t.Fatalf("reentrant event order = %v, want done/running/skipped", statuses) + } + states := Snapshot(events) + if states[0].Steps[0].Status != StepDone || states[0].Steps[1].Status != StepSkipped { + t.Fatalf("states = %#v", states) + } +} + +func TestExecution_RunClaimsPendingStepOnce(t *testing.T) { + x, err := Start(Plan{Phases: []PlannedPhase{{ID: "p", Steps: []PlannedStep{{ID: "s"}}}}}, func(Event) {}) + if err != nil { + t.Fatal(err) + } + var invocations atomic.Int32 + entered := make(chan struct{}) + release := make(chan struct{}) + firstDone := make(chan error, 1) + go func() { + _, err := x.Run("p", "s", func() (string, error) { + invocations.Add(1) + close(entered) + <-release + return "first", nil + }) + firstDone <- err + }() + <-entered + + _, secondErr := x.Run("p", "s", func() (string, error) { + invocations.Add(1) + return "second", nil + }) + if secondErr == nil { + t.Fatal("second Run claimed an already-running step") + } + if got := invocations.Load(); got != 1 { + t.Fatalf("step body invoked %d times, want 1", got) + } + close(release) + if err := <-firstDone; err != nil { + t.Fatal(err) + } +} + +func TestExecution_FinishWaitsForQueuedTerminalEvents(t *testing.T) { + delivered := make(chan Event, 16) + callbackBlocked := make(chan struct{}) + releaseCallback := make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(releaseCallback) }) + + x, err := Start(Plan{Phases: []PlannedPhase{{ID: "p", Steps: []PlannedStep{{ID: "a"}, {ID: "b"}}}}}, func(ev Event) { + if ev.Step == "a" && ev.Status == StepRunning { + close(callbackBlocked) + <-releaseCallback + } + delivered <- ev + }) + if err != nil { + t.Fatal(err) + } + runningDone := make(chan error, 1) + go func() { runningDone <- x.Running("p", "a", 0, "working") }() + <-callbackBlocked + + finishStarted := make(chan struct{}) + finishDone := make(chan error, 1) + go func() { + close(finishStarted) + finishDone <- x.Finish("shutdown") + }() + <-finishStarted + select { + case err := <-finishDone: + t.Fatalf("Finish returned before blocked callback delivery: %v", err) + case <-time.After(100 * time.Millisecond): + } + + releaseOnce.Do(func() { close(releaseCallback) }) + if err := <-runningDone; err != nil { + t.Fatal(err) + } + if err := <-finishDone; err != nil { + t.Fatal(err) + } + close(delivered) + events := make([]Event, 0, 6) + for ev := range delivered { + events = append(events, ev) + } + if err := ValidateStream(events); err != nil { + t.Fatalf("flushed stream invalid: %v", err) + } + if len(events) != 6 { + t.Fatalf("delivered %d events, want 6: %#v", len(events), events) + } + tail := events[3:] + if tail[0].Step != "a" || tail[0].Status != StepRunning || + tail[1].Step != "a" || tail[1].Status != StepSkipped || + tail[2].Step != "b" || tail[2].Status != StepSkipped { + t.Fatalf("terminal delivery order = %#v", tail) + } +} + +func TestExecution_CallbackPanicBecomesDeliveryError(t *testing.T) { + x, err := Start(Plan{Phases: []PlannedPhase{{ID: "p", Steps: []PlannedStep{{ID: "s"}}}}}, func(ev Event) { + if ev.Status == StepRunning { + panic("callback boom") + } + }) + if err != nil { + t.Fatal(err) + } + var runningErr error + var escaped any + func() { + defer func() { escaped = recover() }() + runningErr = x.Running("p", "s", 0, "working") + }() + if escaped != nil { + t.Fatalf("callback panic escaped instead of becoming a delivery error: %v", escaped) + } + if runningErr == nil { + t.Fatal("Running hid callback delivery failure") + } + if !strings.Contains(runningErr.Error(), "callback boom") { + t.Fatalf("Running delivery error lost panic detail: %v", runningErr) + } + if err := x.Finish("shutdown"); err == nil { + t.Fatal("Finish did not propagate stored callback delivery failure") + } +} + +func TestExecutionPhasesProjectsPlanOrderAndStableIdentity(t *testing.T) { + x, err := Start(Plan{Phases: []PlannedPhase{ + {ID: "first", Label: "Same label", Steps: []PlannedStep{{ID: "a", Label: "Same step"}, {ID: "b", Label: "Same step"}}}, + {ID: "second", Label: "Same label", Steps: []PlannedStep{{ID: "c", Label: "Last step"}}}, + }}, nil) + if err != nil { + t.Fatal(err) + } + + got := x.Phases() + if len(got) != 2 || got[0].ID != "first" || got[1].ID != "second" { + t.Fatalf("phase identity/order = %#v", got) + } + if got[0].Name != "Same label" || got[1].Name != "Same label" { + t.Fatalf("phase labels = %#v", got) + } + if len(got[0].Steps) != 2 || got[0].Steps[0].ID != "a" || got[0].Steps[1].ID != "b" { + t.Fatalf("step identity/order = %#v", got[0].Steps) + } + if got[0].Steps[0].Name != "Same step" || got[0].Steps[1].Name != "Same step" { + t.Fatalf("equal step labels were not preserved: %#v", got[0].Steps) + } +} + +func TestExecutionPhasesReturnsDefensiveCopy(t *testing.T) { + x, err := Start(Plan{Phases: []PlannedPhase{{ID: "p", Label: "Phase", Steps: []PlannedStep{{ID: "s", Label: "Step"}}}}}, nil) + if err != nil { + t.Fatal(err) + } + + got := x.Phases() + got[0].ID = "changed" + got[0].Name = "Changed" + got[0].Steps[0].ID = "changed" + got[0].Steps[0].Name = "Changed" + got[0].Steps[0].Status = StepDone + + again := x.Phases() + if again[0].ID != "p" || again[0].Name != "Phase" || again[0].Steps[0].ID != "s" || + again[0].Steps[0].Name != "Step" || again[0].Steps[0].Status != StepPending { + t.Fatalf("mutating Phases result changed execution: %#v", again) + } +} + +func TestExecutionPhasesReflectsTerminalStateWhenDeliveryFails(t *testing.T) { + stepErr := errors.New("operation failed") + x, err := Start(Plan{Phases: []PlannedPhase{{ID: "p", Label: "Phase", Steps: []PlannedStep{{ID: "s", Label: "Step"}}}}}, func(ev Event) { + if ev.Status == StepFailed { + panic("delivery failed") + } + }) + if err != nil { + t.Fatal(err) + } + result, deliveryErr := x.Fail("p", "s", stepErr) + if deliveryErr == nil { + t.Fatal("terminal callback panic was hidden") + } + + got := x.Phases() + if len(got) != 1 || len(got[0].Steps) != 1 { + t.Fatalf("Phases() = %#v", got) + } + step := got[0].Steps[0] + if step != result || step.Status != StepFailed || !errors.Is(step.Error, stepErr) { + t.Fatalf("projected step = %#v, result = %#v", step, result) + } +} + +func TestExecutionRejectsNilFailureAndEmptyReasons(t *testing.T) { + newExecution := func(t *testing.T) *Execution { + t.Helper() + x, err := Start(Plan{Phases: []PlannedPhase{{ID: "p", Steps: []PlannedStep{{ID: "s"}}}}}, nil) + if err != nil { + t.Fatal(err) + } + return x + } + tests := []struct { + name string + call func(*Execution) error + }{ + {"nil failure", func(x *Execution) error { _, err := x.Fail("p", "s", nil); return err }}, + {"empty skip reason", func(x *Execution) error { _, err := x.Skip("p", "s", ""); return err }}, + {"empty skip-pending reason", func(x *Execution) error { return x.SkipPending("p", "") }}, + {"empty finish reason", func(x *Execution) error { return x.Finish("") }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := tc.call(newExecution(t)); err == nil { + t.Fatal("invalid empty value accepted") + } + }) + } +} + +func TestExecutionFinishIsConcurrentAndIdempotent(t *testing.T) { + var mu sync.Mutex + var events []Event + x, err := Start(Plan{Phases: []PlannedPhase{{ID: "p", Steps: []PlannedStep{{ID: "a"}, {ID: "b"}}}}}, func(ev Event) { + mu.Lock() + events = append(events, ev) + mu.Unlock() + }) + if err != nil { + t.Fatal(err) + } + + const callers = 20 + start := make(chan struct{}) + errs := make(chan error, callers) + for i := 0; i < callers; i++ { + go func() { + <-start + errs <- x.Finish("shutdown") + }() + } + close(start) + for i := 0; i < callers; i++ { + if err := <-errs; err != nil { + t.Fatal(err) + } + } + if err := x.Finish("second reason is ignored"); err != nil { + t.Fatal(err) + } + + mu.Lock() + gotEvents := append([]Event(nil), events...) + mu.Unlock() + terminalCount := 0 + for _, ev := range gotEvents { + if ev.Status == StepSkipped { + terminalCount++ + } + } + if terminalCount != 2 { + t.Fatalf("Finish emitted %d terminal events, want 2: %#v", terminalCount, gotEvents) + } + if err := ValidateStream(gotEvents); err != nil { + t.Fatalf("completed stream invalid: %v", err) + } +} + +func TestExecutionRejectsTransitionsOnceFinishBegins(t *testing.T) { + finishCallback := make(chan struct{}) + release := make(chan struct{}) + var once sync.Once + x, err := Start(Plan{Phases: []PlannedPhase{{ID: "p", Steps: []PlannedStep{{ID: "a"}, {ID: "b"}}}}}, func(ev Event) { + if ev.Status == StepSkipped { + once.Do(func() { close(finishCallback) }) + <-release + } + }) + if err != nil { + t.Fatal(err) + } + finishDone := make(chan error, 1) + go func() { finishDone <- x.Finish("shutdown") }() + <-finishCallback + + var runCalled atomic.Bool + calls := []struct { + name string + call func() error + }{ + {"Running", func() error { return x.Running("p", "a", 0, "late") }}, + {"Done", func() error { _, err := x.Done("p", "a", "late"); return err }}, + {"Fail", func() error { _, err := x.Fail("p", "a", errors.New("late")); return err }}, + {"Skip", func() error { _, err := x.Skip("p", "a", "late"); return err }}, + {"Run", func() error { + _, err := x.Run("p", "a", func() (string, error) { runCalled.Store(true); return "", nil }) + return err + }}, + {"SkipPending", func() error { return x.SkipPending("p", "late") }}, + } + for _, tc := range calls { + if err := tc.call(); err == nil { + t.Errorf("%s accepted after Finish began", tc.name) + } + } + close(release) + if err := <-finishDone; err != nil { + t.Fatal(err) + } + if runCalled.Load() { + t.Fatal("rejected Run invoked its function") + } +} + +func TestExecutionFinishTerminalizesStateAfterEarlierDeliveryFailure(t *testing.T) { + x, err := Start(Plan{Phases: []PlannedPhase{{ID: "p", Steps: []PlannedStep{{ID: "running"}, {ID: "pending"}}}}}, func(ev Event) { + if ev.Status == StepRunning { + panic("delivery failed") + } + }) + if err != nil { + t.Fatal(err) + } + runningErr := x.Running("p", "running", 0, "working") + if runningErr == nil { + t.Fatal("Running hid delivery failure") + } + finishErr := x.Finish("shutdown") + if finishErr == nil || finishErr != runningErr { + t.Fatalf("Finish error = %v, want sticky %v", finishErr, runningErr) + } + for _, step := range x.Phases()[0].Steps { + if step.Status != StepSkipped || step.Message != "shutdown" { + t.Fatalf("Finish did not terminalize internal state: %#v", x.Phases()) + } + } +} + +func TestExecutionCallbackPanicsNeverEscapeLifecycleMethods(t *testing.T) { + t.Run("Start", func(t *testing.T) { + var escaped any + var err error + func() { + defer func() { escaped = recover() }() + _, err = Start(Plan{Phases: []PlannedPhase{{ID: "p", Steps: []PlannedStep{{ID: "s"}}}}}, func(Event) { panic("start") }) + }() + if escaped != nil || err == nil { + t.Fatalf("escaped = %v, err = %v", escaped, err) + } + }) + + t.Run("Finish", func(t *testing.T) { + x, err := Start(Plan{Phases: []PlannedPhase{{ID: "p", Steps: []PlannedStep{{ID: "s"}}}}}, func(ev Event) { + if ev.Status == StepSkipped { + panic("finish") + } + }) + if err != nil { + t.Fatal(err) + } + var escaped any + func() { + defer func() { escaped = recover() }() + err = x.Finish("shutdown") + }() + if escaped != nil || err == nil { + t.Fatalf("escaped = %v, err = %v", escaped, err) + } + if got := x.Phases()[0].Steps[0]; got.Status != StepSkipped || got.Message != "shutdown" { + t.Fatalf("state = %#v", got) + } + }) +} diff --git a/internal/progress/plan.go b/internal/progress/plan.go new file mode 100644 index 0000000..9d8027d --- /dev/null +++ b/internal/progress/plan.go @@ -0,0 +1,37 @@ +package progress + +// Plan declares a flow's work upfront: phases, their steps, and each step's +// checkpoint count. Start validates and owns execution of this plan. +type Plan struct { + Phases []PlannedPhase +} + +// Clone returns an independently mutable copy of the plan. +func (p Plan) Clone() Plan { + var clone Plan + if p.Phases != nil { + clone.Phases = make([]PlannedPhase, len(p.Phases)) + } + for i := range p.Phases { + clone.Phases[i] = p.Phases[i] + if p.Phases[i].Steps != nil { + clone.Phases[i].Steps = append([]PlannedStep{}, p.Phases[i].Steps...) + } + } + return clone +} + +// PlannedPhase declares one phase. +type PlannedPhase struct { + ID PhaseID + Label string + Steps []PlannedStep +} + +// PlannedStep declares one step. Checkpoints below 1 declare an atomic step +// (one checkpoint: its resolution). +type PlannedStep struct { + ID StepID + Label string + Checkpoints int +} diff --git a/internal/progress/plan_test.go b/internal/progress/plan_test.go new file mode 100644 index 0000000..41757be --- /dev/null +++ b/internal/progress/plan_test.go @@ -0,0 +1,525 @@ +package progress + +import ( + "errors" + "math/rand" + "reflect" + "strings" + "testing" +) + +func collectEvents() (*[]Event, func(Event)) { + events := &[]Event{} + return events, func(event Event) { *events = append(*events, event) } +} + +func TestPlanCloneDeepCopiesStableFields(t *testing.T) { + original := Plan{Phases: []PlannedPhase{{ + ID: "stable-phase", Label: "Phase", + Steps: []PlannedStep{{ID: "stable-step", Label: "Step", Checkpoints: 3}}, + }}} + + clone := original.Clone() + if !reflect.DeepEqual(clone, original) { + t.Fatalf("Clone() = %#v, want %#v", clone, original) + } + clone.Phases[0].ID = "changed-phase" + clone.Phases[0].Steps[0].ID = "changed-step" + if original.Phases[0].ID != "stable-phase" || original.Phases[0].Steps[0].ID != "stable-step" { + t.Fatalf("mutating clone changed original: %#v", original) + } +} + +func TestPlanClonePreservesNilAndEmptySlices(t *testing.T) { + if clone := (Plan{}).Clone(); clone.Phases != nil { + t.Fatalf("nil phases cloned as %#v", clone.Phases) + } + original := Plan{Phases: []PlannedPhase{{ID: "nil"}, {ID: "empty", Steps: []PlannedStep{}}}} + clone := original.Clone() + if clone.Phases[0].Steps != nil { + t.Fatalf("nil steps cloned as %#v", clone.Phases[0].Steps) + } + if clone.Phases[1].Steps == nil { + t.Fatal("explicitly empty steps cloned as nil") + } +} + +func TestExecutionStartAcceptsEmptyPlan(t *testing.T) { + x, err := Start(Plan{}, nil) + if err != nil { + t.Fatal(err) + } + if err := x.Finish("empty execution complete"); err != nil { + t.Fatal(err) + } +} + +func TestExecutionStartRejectsPhaseWithoutSteps(t *testing.T) { + if _, err := Start(Plan{Phases: []PlannedPhase{{ID: "empty"}}}, nil); err == nil { + t.Fatal("phase with zero steps accepted") + } +} + +func TestStart_EstablishesTotalsBeforeWork(t *testing.T) { + events, emit := collectEvents() + _, err := Start(Plan{Phases: []PlannedPhase{ + {ID: "feat-1", Steps: []PlannedStep{{ID: "setup-a"}, {ID: "setup-b"}}}, + }}, emit) + if err != nil { + t.Fatal(err) + } + + states := Snapshot(*events) + if len(states) != 1 { + t.Fatalf("expected 1 phase, got %d", len(states)) + } + p := states[0] + if p.Total != 2 || p.Done != 0 { + t.Errorf("declared phase = %d/%d, want 0/2 before any work", p.Done, p.Total) + } + if !p.Closed { + t.Error("phase must be closed by Start") + } + if p.Settled() { + t.Error("declared-but-unworked phase must not be settled") + } +} + +func TestValidateStream_StrictByDefaultForUnlabeledEvents(t *testing.T) { + events := []Event{{Phase: "p", Step: "undeclared", Status: StepRunning}} + if err := ValidateStream(events); err == nil { + t.Fatal("strict validation accepted unlabeled undeclared work") + } +} + +func TestValidateStream_MixedMetadataDoesNotFallBack(t *testing.T) { + events := []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "declared", StepLabel: "Declared", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Close: true}, + {Phase: "p", PhaseLabel: "Phase", Step: "undeclared", StepLabel: "Undeclared", Status: StepDone}, + } + if err := ValidateStream(events); err == nil { + t.Fatal("strict validation silently accepted mixed legacy and stable semantics") + } +} + +func TestSnapshot_CheckpointProgressWithinSteps(t *testing.T) { + events, emit := collectEvents() + _, err := Start(Plan{Phases: []PlannedPhase{ + {ID: "Removing worktrees", Steps: []PlannedStep{{ID: "wt-a", Checkpoints: 2}}}, + }}, emit) + if err != nil { + t.Fatal(err) + } + emit(Event{Phase: "Removing worktrees", Step: "wt-a", Status: StepRunning, Checkpoint: 1, Of: 2}) + + states := Snapshot(*events) + step := states[0].Steps[0] + if step.Reached != 1 || step.Declared != 2 { + t.Errorf("running step checkpoints = %d/%d, want 1/2", step.Reached, step.Declared) + } + if step.Status != StepRunning { + t.Error("checkpoint progress must not resolve the step") + } + reached, declared := CheckpointProgress(states) + if reached != 1 || declared != 2 { + t.Errorf("overall checkpoints = %d/%d, want 1/2", reached, declared) + } +} + +func TestSnapshot_CheckpointsNeverRegress(t *testing.T) { + events := []Event{ + {Phase: "P", Step: "s", Status: StepPending, Of: 3}, + {Phase: "P", Step: "s", Status: StepRunning, Checkpoint: 2, Of: 3}, + {Phase: "P", Step: "s", Status: StepRunning, Checkpoint: 1, Of: 3}, + } + states := Snapshot(events) + if got := states[0].Steps[0].Reached; got != 2 { + t.Errorf("reached = %d, want 2 (stale checkpoint must not regress)", got) + } +} + +func TestSnapshot_ResolutionReachesFinalCheckpoint(t *testing.T) { + for _, status := range []StepStatus{StepDone, StepFailed, StepSkipped} { + events := []Event{ + {Phase: "P", Step: "s", Status: StepPending, Of: 3}, + {Phase: "P", Step: "s", Status: status}, + } + step := Snapshot(events)[0].Steps[0] + if step.Reached != 3 { + t.Errorf("status %v: reached = %d, want 3 (resolution reaches the final checkpoint)", status, step.Reached) + } + } +} + +func TestSnapshot_UndeclaredStreamsMatchStepCounting(t *testing.T) { + events := []Event{ + {Phase: "P", Step: "a", Status: StepDone}, + {Phase: "P", Step: "b", Status: StepRunning}, + } + states := Snapshot(events) + reached, declared := CheckpointProgress(states) + if reached != states[0].Done || declared != states[0].Total { + t.Errorf("undeclared stream: checkpoints %d/%d must equal step counts %d/%d", + reached, declared, states[0].Done, states[0].Total) + } +} + +func TestSettled_RequiresCloseAndCompletion(t *testing.T) { + cases := []struct { + name string + p PhaseState + want bool + }{ + {"open complete phase", PhaseState{Total: 2, Done: 2}, false}, + {"closed incomplete phase", PhaseState{Total: 2, Done: 1, Closed: true}, false}, + {"closed complete phase", PhaseState{Total: 2, Done: 2, Closed: true}, true}, + {"closed empty phase", PhaseState{Closed: true}, false}, + } + for _, tc := range cases { + if got := tc.p.Settled(); got != tc.want { + t.Errorf("%s: Settled() = %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestValidateStream_FlagsNewStepAfterClose(t *testing.T) { + events := []Event{ + {Phase: "P", PhaseLabel: "Phase", Step: "declared", StepLabel: "Declared", Status: StepPending, Of: 1}, + {Phase: "P", PhaseLabel: "Phase", Close: true}, + {Phase: "P", PhaseLabel: "Phase", Step: "declared", StepLabel: "Declared", Status: StepDone}, + {Phase: "P", PhaseLabel: "Phase", Step: "smuggled", StepLabel: "Smuggled", Status: StepRunning}, + } + if err := ValidateStream(events); err == nil { + t.Error("a new step after close must be flagged") + } + if err := ValidateStream(events[:3]); err != nil { + t.Errorf("work on declared steps after close is legitimate: %v", err) + } +} + +func TestValidateStream_RequiresCompleteDeclarationPrefix(t *testing.T) { + tests := []struct { + name string + events []Event + }{ + { + name: "undeclared work", + events: []Event{ + {Phase: "p", PhaseLabel: "Phase", Close: true}, + {Phase: "p", PhaseLabel: "Phase", Step: "missing", StepLabel: "Missing", Status: StepRunning}, + }, + }, + { + name: "declaration after close", + events: []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Close: true}, + {Phase: "q", PhaseLabel: "Late phase", Step: "late", StepLabel: "Late", Status: StepPending, Of: 1}, + }, + }, + { + name: "work before every phase closes", + events: []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepPending, Of: 1}, + {Phase: "q", PhaseLabel: "Other", Step: "t", StepLabel: "Other step", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Close: true}, + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepRunning}, + }, + }, + { + name: "declared phase never closes", + events: []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepPending, Of: 1}, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := ValidateStream(tc.events); err == nil { + t.Fatal("invalid stream accepted") + } + }) + } +} + +func TestValidateStream_RejectsTerminalMutationAndCheckpointErrors(t *testing.T) { + prefix := []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepPending, Of: 2}, + {Phase: "p", PhaseLabel: "Phase", Close: true}, + } + tests := []struct { + name string + work []Event + }{ + {"terminal mutation", []Event{{Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepDone}, {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepFailed, Error: errors.New("late")}}}, + {"checkpoint regression", []Event{{Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepRunning, Checkpoint: 2}, {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepRunning, Checkpoint: 1}}}, + {"checkpoint overflow", []Event{{Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepRunning, Checkpoint: 3}}}, + {"checkpoint total changed", []Event{{Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepRunning, Checkpoint: 1, Of: 3}}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + events := append(append([]Event(nil), prefix...), tc.work...) + if err := ValidateStream(events); err == nil { + t.Fatal("invalid stream accepted") + } + }) + } +} + +func TestValidateStreamRejectsUnstableLabels(t *testing.T) { + tests := []struct { + name string + events []Event + }{ + { + name: "phase label changes between declarations", + events: []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "a", StepLabel: "A", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Changed", Step: "b", StepLabel: "B", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Close: true}, + {Phase: "p", PhaseLabel: "Phase", Step: "a", StepLabel: "A", Status: StepDone}, + {Phase: "p", PhaseLabel: "Phase", Step: "b", StepLabel: "B", Status: StepDone}, + }, + }, + { + name: "phase label changes on close", + events: []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Changed", Close: true}, + }, + }, + { + name: "step label changes during work", + events: []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Close: true}, + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Changed", Status: StepDone}, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := ValidateStream(tc.events); err == nil || !strings.Contains(err.Error(), "label") { + t.Fatalf("ValidateStream() error = %v, want label error", err) + } + }) + } +} + +func TestValidateStreamRequiresExplicitStableLabels(t *testing.T) { + if err := ValidateStream(nil); err != nil { + t.Fatalf("empty execution rejected: %v", err) + } + tests := []struct { + name string + events []Event + }{ + { + name: "declaration phase label", + events: []Event{ + {Phase: "p", Step: "s", StepLabel: "Step", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Close: true}, + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepDone}, + }, + }, + { + name: "declaration step label", + events: []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "s", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Close: true}, + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepDone}, + }, + }, + { + name: "close phase label", + events: []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepPending, Of: 1}, + {Phase: "p", Close: true}, + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepDone}, + }, + }, + { + name: "running phase label", + events: []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Close: true}, + {Phase: "p", Step: "s", StepLabel: "Step", Status: StepRunning}, + }, + }, + { + name: "running step label", + events: []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Close: true}, + {Phase: "p", PhaseLabel: "Phase", Step: "s", Status: StepRunning}, + }, + }, + { + name: "terminal phase label disappearance", + events: []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Close: true}, + {Phase: "p", Step: "s", StepLabel: "Step", Status: StepDone}, + }, + }, + { + name: "terminal label disappearance", + events: []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Close: true}, + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepRunning}, + {Phase: "p", PhaseLabel: "Phase", Step: "s", Status: StepDone}, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := ValidateStream(tc.events); err == nil || !strings.Contains(err.Error(), "label") { + t.Fatalf("ValidateStream() error = %v, want label error", err) + } + }) + } +} + +func TestValidateStreamRequiresTerminalPayloads(t *testing.T) { + prefix := []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Close: true}, + } + tests := []struct { + name string + event Event + }{ + {"failed without error", Event{Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepFailed}}, + {"skipped without reason", Event{Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepSkipped}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + events := append(append([]Event(nil), prefix...), tc.event) + if err := ValidateStream(events); err == nil { + t.Fatal("terminal event without required payload accepted") + } + }) + } +} + +func TestValidateCompletedStream(t *testing.T) { + failure := errors.New("failed") + tests := []struct { + name string + events []Event + wantErr bool + }{ + {name: "empty"}, + { + name: "valid", + events: []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "done", StepLabel: "Done", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Step: "failed", StepLabel: "Failed", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Step: "skipped", StepLabel: "Skipped", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Close: true}, + {Phase: "p", PhaseLabel: "Phase", Step: "done", StepLabel: "Done", Status: StepDone}, + {Phase: "p", PhaseLabel: "Phase", Step: "failed", StepLabel: "Failed", Status: StepFailed, Error: failure}, + {Phase: "p", PhaseLabel: "Phase", Step: "skipped", StepLabel: "Skipped", Status: StepSkipped, Message: "not needed"}, + }, + }, + { + name: "pending", + events: []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Close: true}, + }, + wantErr: true, + }, + { + name: "running", + events: []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Close: true}, + {Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepRunning}, + }, + wantErr: true, + }, + {name: "close only", events: []Event{{Phase: "p", PhaseLabel: "Phase", Close: true}}, wantErr: true}, + { + name: "one incomplete step", + events: []Event{ + {Phase: "p", PhaseLabel: "Phase", Step: "done", StepLabel: "Done", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Step: "pending", StepLabel: "Pending", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Phase", Close: true}, + {Phase: "p", PhaseLabel: "Phase", Step: "done", StepLabel: "Done", Status: StepDone}, + }, + wantErr: true, + }, + { + name: "unclosed phase", + events: []Event{{Phase: "p", PhaseLabel: "Phase", Step: "s", StepLabel: "Step", Status: StepPending, Of: 1}}, + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ValidateCompletedStream(tc.events) + if (err != nil) != tc.wantErr { + t.Fatalf("ValidateCompletedStream() error = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} + +// Property: across any prefix of any interleaving, per-phase totals and the +// overall checkpoint fill are monotonic non-decreasing. +func TestSnapshot_MonotonicUnderRandomInterleavings(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + for trial := 0; trial < 50; trial++ { + var stream []Event + _, err := Start(Plan{Phases: []PlannedPhase{ + {ID: "A", Steps: []PlannedStep{{ID: "a1", Checkpoints: 2}, {ID: "a2"}}}, + {ID: "B", Steps: []PlannedStep{{ID: "b1", Checkpoints: 3}}}, + }}, func(e Event) { stream = append(stream, e) }) + if err != nil { + t.Fatal(err) + } + + work := []Event{ + {Phase: "A", Step: "a1", Status: StepRunning, Checkpoint: 1, Of: 2}, + {Phase: "A", Step: "a1", Status: StepDone}, + {Phase: "A", Step: "a2", Status: StepFailed}, + {Phase: "B", Step: "b1", Status: StepRunning, Checkpoint: 1, Of: 3}, + {Phase: "B", Step: "b1", Status: StepRunning, Checkpoint: 2, Of: 3}, + {Phase: "B", Step: "b1", Status: StepDone}, + } + rng.Shuffle(len(work), func(i, j int) { + // Preserve per-step event order; shuffle across steps only. + if work[i].Step == work[j].Step { + return + } + work[i], work[j] = work[j], work[i] + }) + stream = append(stream, work...) + + lastFill := -1.0 + totals := map[string]int{} + for i := 1; i <= len(stream); i++ { + states := Snapshot(stream[:i]) + reached, declared := CheckpointProgress(states) + if declared > 0 { + fill := float64(reached) / float64(declared) + if fill+1e-9 < lastFill { + t.Fatalf("trial %d prefix %d: fill regressed %.3f -> %.3f", trial, i, lastFill, fill) + } + lastFill = fill + } + for _, p := range states { + if p.Total < totals[p.Name] { + t.Fatalf("trial %d prefix %d: phase %s total regressed %d -> %d", trial, i, p.Name, totals[p.Name], p.Total) + } + totals[p.Name] = p.Total + if p.Done > p.Total { + t.Fatalf("trial %d prefix %d: phase %s done %d exceeds total %d", trial, i, p.Name, p.Done, p.Total) + } + } + } + } +} diff --git a/internal/progress/progress.go b/internal/progress/progress.go new file mode 100644 index 0000000..adff387 --- /dev/null +++ b/internal/progress/progress.go @@ -0,0 +1,92 @@ +// Package progress is the single vocabulary for multi-phase operation +// progress (worktree removal, integration apply, teardown, worktree +// creation, repo create/clone/migrate, cleanup): step statuses, per-step +// results grouped into phases, the events emitted while a phase runs, and +// the fold from an event stream to per-phase display state. +package progress + +// PhaseID and StepID are stable identities. Labels are presentation and may +// be repeated or changed without changing the work they identify. +// +// These remain aliases temporarily so legacy producers can populate Event +// fields from strings while they migrate to Execution. +type PhaseID = string +type StepID = string + +type StepStatus int + +const ( + StepPending StepStatus = iota + StepRunning + StepDone + StepFailed + StepSkipped +) + +// StepResult is the recorded outcome of a single step within a phase. +type StepResult struct { + ID StepID + Name string + Status StepStatus + Message string + Error error +} + +// Phase is an identified, named group of step results. +type Phase struct { + ID PhaseID + Name string + Steps []StepResult +} + +// Event is a progress notification emitted while a flow runs. +// +// Declaration rides the same stream: a Pending event with Of set declares a +// step and its checkpoint count upfront; an event with Close set marks the +// phase as complete-in-plan (no more steps will be added). A Running event +// with Checkpoint set reports intra-step progress ("reached k of Of"). +type Event struct { + Phase PhaseID + PhaseLabel string + Step StepID + StepLabel string + Status StepStatus + Checkpoint int // reached checkpoint (1-based) within the step, on Running events + Of int // the step's declared checkpoint count (>= 1 when set) + Close bool // phase-close marker: the phase's step set is final + Message string + Error error +} + +// HasFailures reports whether any step in the phase failed. +func (p *Phase) HasFailures() bool { + for _, s := range p.Steps { + if s.Status == StepFailed { + return true + } + } + return false +} + +// PhasesHaveFailures reports whether any step across the phases failed. +func PhasesHaveFailures(phases []Phase) bool { + for i := range phases { + if phases[i].HasFailures() { + return true + } + } + return false +} + +// FirstFailure returns the first failed step across the phases, along with +// the name of the phase it belongs to. +func FirstFailure(phases []Phase) (phaseName string, step StepResult, ok bool) { + for _, p := range phases { + for _, s := range p.Steps { + if s.Status == StepFailed { + return p.Name, s, true + } + } + } + return "", StepResult{}, false +} diff --git a/internal/progress/progress_test.go b/internal/progress/progress_test.go new file mode 100644 index 0000000..d449173 --- /dev/null +++ b/internal/progress/progress_test.go @@ -0,0 +1,44 @@ +package progress + +import ( + "errors" + "testing" +) + +// The enum ordering is part of the package contract: consolidation must not +// reorder values that switches and comparisons across the codebase rely on. +func TestStepStatus_OrderingIsStable(t *testing.T) { + want := []StepStatus{StepPending, StepRunning, StepDone, StepFailed, StepSkipped} + for i, status := range want { + if int(status) != i { + t.Errorf("status %v = %d, want %d (enum ordering is a contract)", status, int(status), i) + } + } +} + +func TestFirstFailure(t *testing.T) { + err := errors.New("broke") + phases := []Phase{ + {Name: "A", Steps: []StepResult{{Name: "ok", Status: StepDone}}}, + {Name: "B", Steps: []StepResult{{Name: "bad", Status: StepFailed, Error: err}, {Name: "worse", Status: StepFailed}}}, + } + + phaseName, step, ok := FirstFailure(phases) + if !ok || phaseName != "B" || step.Name != "bad" || !errors.Is(step.Error, err) { + t.Errorf("FirstFailure = (%q, %+v, %v), want (B, bad, true)", phaseName, step, ok) + } + + if _, _, ok := FirstFailure(phases[:1]); ok { + t.Error("FirstFailure on all-done phases must report !ok") + } +} + +func TestPhasesHaveFailures(t *testing.T) { + clean := []Phase{{Name: "A", Steps: []StepResult{{Status: StepDone}, {Status: StepSkipped}}}} + if PhasesHaveFailures(clean) { + t.Error("clean phases must not report failures") + } + if !PhasesHaveFailures(append(clean, Phase{Steps: []StepResult{{Status: StepFailed}}})) { + t.Error("a failed step must be reported") + } +} diff --git a/internal/progress/snapshot.go b/internal/progress/snapshot.go new file mode 100644 index 0000000..381349f --- /dev/null +++ b/internal/progress/snapshot.go @@ -0,0 +1,136 @@ +package progress + +// PhaseState is the folded display state of one phase: its steps in +// first-appearance order, the derived counts, and whether the phase's step +// set is final (Closed). +type PhaseState struct { + ID PhaseID + Name string + Steps []StepState + Total int + Done int + Failed int + Closed bool +} + +// StepState is the folded display state of one step. Declared is the step's +// checkpoint count (1 for atomic steps); Reached is how many checkpoints +// have been crossed, monotonic and clamped to Declared. Resolution (Done, +// Failed, Skipped) reaches the final checkpoint. +type StepState struct { + ID StepID + Name string + Status StepStatus + Message string + Error error + Reached int + Declared int +} + +// Settled reports whether the phase may render done treatment: its step set +// is final and every declared step is resolved. A phase with no steps is +// never settled (it renders pending or skipped, not done). +func (p PhaseState) Settled() bool { + return p.Closed && p.Total > 0 && p.Done == p.Total +} + +// CheckpointProgress sums reached and declared checkpoints across phases, +// the overall bar's fill source. Undeclared steps count as one checkpoint +// reached on resolution, so streams without declarations yield the same +// ratio as step counting. +func CheckpointProgress(states []PhaseState) (reached, declared int) { + for _, p := range states { + for _, s := range p.Steps { + reached += s.Reached + declared += s.Declared + } + } + return reached, declared +} + +// Snapshot folds an event stream into per-phase display state, preserving +// the order phases and steps first appeared in. Done, Skipped, and Failed +// steps all count as resolved (a phase with a best-effort skip still +// reaches completion); a later event for a step supersedes its status. +// Declaration events (Pending bursts, close markers) establish totals, +// checkpoint counts, and the Closed flag; the fold is forgiving toward +// undeclared streams, which keep discovery semantics. +func Snapshot(events []Event) []PhaseState { + phases := map[PhaseID]*PhaseState{} + var order []PhaseID + + phaseFor := func(id PhaseID, label string) *PhaseState { + ps, exists := phases[id] + if !exists { + if label == "" { + label = id + } + ps = &PhaseState{ID: id, Name: label} + phases[id] = ps + order = append(order, id) + } else if ps.Name == "" && label != "" { + ps.Name = label + } + return ps + } + + for _, ev := range events { + ps := phaseFor(ev.Phase, ev.PhaseLabel) + if ev.Close { + ps.Closed = true + continue + } + + idx := -1 + for i := range ps.Steps { + if ps.Steps[i].ID == ev.Step { + idx = i + break + } + } + if idx == -1 { + label := ev.StepLabel + if label == "" { + label = ev.Step + } + ps.Steps = append(ps.Steps, StepState{ID: ev.Step, Name: label, Declared: 1}) + idx = len(ps.Steps) - 1 + } + step := &ps.Steps[idx] + if step.Name == "" && ev.StepLabel != "" { + step.Name = ev.StepLabel + } + + step.Status = ev.Status + if ev.Message != "" { + step.Message = ev.Message + } + if ev.Error != nil { + step.Error = ev.Error + } + step.Declared = max(step.Declared, ev.Of) + switch ev.Status { + case StepRunning: + step.Reached = max(step.Reached, min(ev.Checkpoint, step.Declared)) + case StepDone, StepFailed, StepSkipped: + step.Reached = step.Declared + } + } + + var result []PhaseState + for _, id := range order { + ps := phases[id] + ps.Total = len(ps.Steps) + for _, s := range ps.Steps { + switch s.Status { + case StepDone, StepSkipped: + ps.Done++ + case StepFailed: + ps.Failed++ + ps.Done++ + } + } + result = append(result, *ps) + } + return result +} diff --git a/internal/progress/snapshot_test.go b/internal/progress/snapshot_test.go new file mode 100644 index 0000000..f8cd6a2 --- /dev/null +++ b/internal/progress/snapshot_test.go @@ -0,0 +1,96 @@ +package progress + +import ( + "errors" + "reflect" + "testing" +) + +func TestSnapshot_EmptyEvents(t *testing.T) { + if got := Snapshot(nil); got != nil { + t.Errorf("Snapshot(nil) = %v, want nil", got) + } +} + +func TestSnapshot_FoldsEventsIntoPhases(t *testing.T) { + events := []Event{ + {Phase: "Setup", Step: "Create worktree", Status: StepRunning}, + {Phase: "Setup", Step: "Create worktree", Status: StepDone}, + {Phase: "Setup", Step: "Merge base", Status: StepSkipped}, + {Phase: "Deps", Step: "npm install", Status: StepRunning}, + {Phase: "Deps", Step: "npm install", Status: StepFailed}, + } + + got := Snapshot(events) + + if len(got) != 2 { + t.Fatalf("expected 2 phases, got %d: %+v", len(got), got) + } + if got[0].Name != "Setup" || got[1].Name != "Deps" { + t.Errorf("phase order = [%s %s], want [Setup Deps]", got[0].Name, got[1].Name) + } + + setup := got[0] + if setup.Total != 2 || setup.Done != 2 || setup.Failed != 0 { + t.Errorf("Setup counts = total %d done %d failed %d, want 2/2/0 (skipped counts as done)", + setup.Total, setup.Done, setup.Failed) + } + if setup.Steps[0].Status != StepDone { + t.Error("a later event for the same step must overwrite its status") + } + + deps := got[1] + if deps.Total != 1 || deps.Done != 1 || deps.Failed != 1 { + t.Errorf("Deps counts = total %d done %d failed %d, want 1/1/1 (failed counts as done)", + deps.Total, deps.Done, deps.Failed) + } +} + +func TestSnapshot_RunningStepNotCounted(t *testing.T) { + got := Snapshot([]Event{ + {Phase: "Setup", Step: "Create worktree", Status: StepRunning}, + }) + + if got[0].Done != 0 || got[0].Failed != 0 || got[0].Total != 1 { + t.Errorf("running step counts = total %d done %d failed %d, want 1/0/0", + got[0].Total, got[0].Done, got[0].Failed) + } +} + +func TestSnapshot_DeterministicAndOrderPreserving(t *testing.T) { + events := []Event{ + {Phase: "B", Step: "b1", Status: StepRunning}, + {Phase: "A", Step: "a1", Status: StepDone}, + {Phase: "B", Step: "b2", Status: StepDone}, + {Phase: "A", Step: "a2", Status: StepRunning}, + } + + first := Snapshot(events) + second := Snapshot(events) + + if !reflect.DeepEqual(first, second) { + t.Errorf("two folds of the same stream differ:\n%+v\n%+v", first, second) + } + if first[0].Name != "B" || first[1].Name != "A" { + t.Errorf("phases must keep first-mention order, got [%s %s]", first[0].Name, first[1].Name) + } + if first[0].Steps[0].Name != "b1" || first[0].Steps[1].Name != "b2" { + t.Errorf("steps must keep first-mention order within their phase, got %+v", first[0].Steps) + } +} + +func TestSnapshot_PreservesErrorAndLabel(t *testing.T) { + stepErr := errors.New("boom") + got := Snapshot([]Event{ + {Phase: "p", PhaseLabel: "Readable phase", Step: "s", StepLabel: "Readable step", Status: StepPending, Of: 1}, + {Phase: "p", PhaseLabel: "Readable phase", Close: true}, + {Phase: "p", Step: "s", Status: StepFailed, Error: stepErr}, + }) + if len(got) != 1 || got[0].ID != "p" || got[0].Name != "Readable phase" { + t.Fatalf("phase = %#v", got) + } + step := got[0].Steps[0] + if step.ID != "s" || step.Name != "Readable step" || !errors.Is(step.Error, stepErr) { + t.Fatalf("step = %#v", step) + } +} diff --git a/internal/progress/tracker.go b/internal/progress/tracker.go deleted file mode 100644 index 09f9cdd..0000000 --- a/internal/progress/tracker.go +++ /dev/null @@ -1,180 +0,0 @@ -package progress - -import "strings" - -// StepStatus represents the current state of a step. -type StepStatus int - -const ( - Pending StepStatus = iota - Running - Done - Failed -) - -// Step holds the display name and current status of a single step. -type Step struct { - Name string - Status StepStatus - Error string -} - -// Group is a named collection of steps (e.g., a phase or worktree). -type Group struct { - Name string - Steps []Step -} - -// Tracker tracks progress across groups of steps. It is initialized with -// a known total so the progress bar shows the correct denominator from -// the start, even before all steps have emitted events. -type Tracker struct { - groups []*Group - groupIndex map[string]int - stepIndex map[string]int // "group:step" → index in group.Steps - total int -} - -// New creates a Tracker with a known total step count. -// Use this when the total is calculable before execution starts. -func New(total int) *Tracker { - return &Tracker{ - groupIndex: make(map[string]int), - stepIndex: make(map[string]int), - total: total, - } -} - -// NewFromGroups creates a Tracker from pre-declared groups and steps. -// Total is calculated automatically. -func NewFromGroups(groups []Group) *Tracker { - t := &Tracker{ - groupIndex: make(map[string]int), - stepIndex: make(map[string]int), - } - for _, g := range groups { - gCopy := g - t.groupIndex[g.Name] = len(t.groups) - for i, s := range gCopy.Steps { - key := g.Name + ":" + s.Name - t.stepIndex[key] = i - } - t.total += len(gCopy.Steps) - t.groups = append(t.groups, &gCopy) - } - return t -} - -// Update records a step status change. If the group or step doesn't exist -// yet, it is created dynamically (for event-driven progress where steps -// are discovered at runtime). -func (t *Tracker) Update(group, step string, status StepStatus, errMsg string) { - key := group + ":" + step - - g := t.getOrCreateGroup(group) - - if idx, ok := t.stepIndex[key]; ok { - g.Steps[idx].Status = status - g.Steps[idx].Error = errMsg - return - } - - t.stepIndex[key] = len(g.Steps) - g.Steps = append(g.Steps, Step{Name: step, Status: status, Error: errMsg}) -} - -// Done returns the count of completed steps (Done or Failed). -func (t *Tracker) Done() int { - done := 0 - for _, g := range t.groups { - for _, s := range g.Steps { - if s.Status == Done || s.Status == Failed { - done++ - } - } - } - return done -} - -// Total returns the known total step count. If steps were added dynamically -// beyond the initial total, it returns the larger of the two. -func (t *Tracker) Total() int { - actual := 0 - for _, g := range t.groups { - actual += len(g.Steps) - } - if actual > t.total { - return actual - } - return t.total -} - -// Groups returns the current groups with their steps, in insertion order. -func (t *Tracker) Groups() []Group { - result := make([]Group, len(t.groups)) - for i, g := range t.groups { - result[i] = *g - } - return result -} - -// IsComplete returns true when all steps have terminal status (Done or Failed). -func (t *Tracker) IsComplete() bool { - return t.Done() >= t.Total() && t.Total() > 0 -} - -// Percent returns the completion percentage (0-100). -func (t *Tracker) Percent() int { - total := t.Total() - if total == 0 { - return 0 - } - return (t.Done() * 100) / total -} - -// FailedCount returns the number of steps with Failed status. -func (t *Tracker) FailedCount() int { - count := 0 - for _, g := range t.groups { - for _, s := range g.Steps { - if s.Status == Failed { - count++ - } - } - } - return count -} - -// Bar renders a progress bar string: ████░░░░ 5/9 -func (t *Tracker) Bar(width int) string { - total := t.Total() - done := t.Done() - - filled := 0 - if total > 0 { - filled = (done * width) / total - } - if filled > width { - filled = width - } - - var b strings.Builder - for range filled { - b.WriteRune('\u2588') // █ - } - for range width - filled { - b.WriteRune('\u2591') // ░ - } - - return b.String() -} - -func (t *Tracker) getOrCreateGroup(name string) *Group { - if idx, ok := t.groupIndex[name]; ok { - return t.groups[idx] - } - g := &Group{Name: name} - t.groupIndex[name] = len(t.groups) - t.groups = append(t.groups, g) - return g -} diff --git a/internal/progress/tracker_test.go b/internal/progress/tracker_test.go deleted file mode 100644 index 521467f..0000000 --- a/internal/progress/tracker_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package progress - -import ( - "strings" - "testing" -) - -func TestNew_TotalSetUpfront(t *testing.T) { - tr := New(10) - if tr.Total() != 10 { - t.Errorf("expected total=10, got %d", tr.Total()) - } - if tr.Done() != 0 { - t.Errorf("expected done=0, got %d", tr.Done()) - } -} - -func TestNewFromGroups_CalculatesTotal(t *testing.T) { - tr := NewFromGroups([]Group{ - {Name: "setup", Steps: []Step{{Name: "s1"}, {Name: "s2"}}}, - {Name: "deps", Steps: []Step{{Name: "d1"}}}, - }) - if tr.Total() != 3 { - t.Errorf("expected total=3, got %d", tr.Total()) - } -} - -func TestUpdate_RecordsStepStatus(t *testing.T) { - tr := New(3) - tr.Update("worktree-a", "install", Running, "") - tr.Update("worktree-a", "install", Done, "") - - groups := tr.Groups() - if len(groups) != 1 { - t.Fatalf("expected 1 group, got %d", len(groups)) - } - if len(groups[0].Steps) != 1 { - t.Fatalf("expected 1 step, got %d", len(groups[0].Steps)) - } - if groups[0].Steps[0].Status != Done { - t.Errorf("expected Done, got %d", groups[0].Steps[0].Status) - } -} - -func TestUpdate_DeduplicatesSteps(t *testing.T) { - tr := New(3) - // Same step emits Running then Done — should be 1 step, not 2. - tr.Update("wt", "setup", Running, "") - tr.Update("wt", "setup", Done, "") - - groups := tr.Groups() - if len(groups[0].Steps) != 1 { - t.Errorf("expected 1 step (deduplicated), got %d", len(groups[0].Steps)) - } -} - -func TestDone_CountsDoneAndFailed(t *testing.T) { - tr := New(4) - tr.Update("g", "s1", Done, "") - tr.Update("g", "s2", Failed, "oops") - tr.Update("g", "s3", Running, "") - - if tr.Done() != 2 { - t.Errorf("expected done=2 (1 Done + 1 Failed), got %d", tr.Done()) - } -} - -func TestTotal_UpfrontTotalIsMinimum(t *testing.T) { - tr := New(5) - // Only 2 steps discovered so far — total should still be 5. - tr.Update("g", "s1", Done, "") - tr.Update("g", "s2", Running, "") - - if tr.Total() != 5 { - t.Errorf("expected total=5 (upfront), got %d", tr.Total()) - } -} - -func TestTotal_GrowsBeyondUpfront(t *testing.T) { - tr := New(2) - // More steps discovered than declared upfront — total grows. - tr.Update("g", "s1", Done, "") - tr.Update("g", "s2", Done, "") - tr.Update("g", "s3", Done, "") - - if tr.Total() != 3 { - t.Errorf("expected total=3 (actual > upfront), got %d", tr.Total()) - } -} - -func TestIsComplete(t *testing.T) { - tr := New(2) - if tr.IsComplete() { - t.Error("should not be complete with 0 steps") - } - - tr.Update("g", "s1", Done, "") - if tr.IsComplete() { - t.Error("should not be complete with 1/2 done") - } - - tr.Update("g", "s2", Done, "") - if !tr.IsComplete() { - t.Error("should be complete with 2/2 done") - } -} - -func TestIsComplete_FailedCountsAsComplete(t *testing.T) { - tr := New(2) - tr.Update("g", "s1", Done, "") - tr.Update("g", "s2", Failed, "err") - if !tr.IsComplete() { - t.Error("should be complete with 1 Done + 1 Failed = 2/2") - } -} - -func TestFailedCount(t *testing.T) { - tr := New(3) - tr.Update("g", "s1", Done, "") - tr.Update("g", "s2", Failed, "err1") - tr.Update("g", "s3", Failed, "err2") - if tr.FailedCount() != 2 { - t.Errorf("expected 2 failed, got %d", tr.FailedCount()) - } -} - -func TestGroups_PreservesInsertionOrder(t *testing.T) { - tr := New(3) - tr.Update("charlie", "s1", Done, "") - tr.Update("alpha", "s1", Done, "") - tr.Update("bravo", "s1", Done, "") - - groups := tr.Groups() - if len(groups) != 3 { - t.Fatalf("expected 3 groups, got %d", len(groups)) - } - if groups[0].Name != "charlie" || groups[1].Name != "alpha" || groups[2].Name != "bravo" { - t.Errorf("expected insertion order charlie/alpha/bravo, got %s/%s/%s", - groups[0].Name, groups[1].Name, groups[2].Name) - } -} - -func TestGroups_StepsPreserveOrder(t *testing.T) { - tr := New(3) - tr.Update("g", "install", Running, "") - tr.Update("g", "setup", Running, "") - tr.Update("g", "verify", Running, "") - - steps := tr.Groups()[0].Steps - if steps[0].Name != "install" || steps[1].Name != "setup" || steps[2].Name != "verify" { - t.Error("steps should preserve insertion order") - } -} - -func TestUpdate_ErrorMessage(t *testing.T) { - tr := New(1) - tr.Update("g", "s1", Failed, "connection refused") - if tr.Groups()[0].Steps[0].Error != "connection refused" { - t.Error("expected error message to be stored") - } -} - -func TestUpdate_ErrorClearedOnRetry(t *testing.T) { - tr := New(1) - tr.Update("g", "s1", Failed, "timeout") - tr.Update("g", "s1", Done, "") - if tr.Groups()[0].Steps[0].Error != "" { - t.Error("expected error to be cleared after success") - } -} - -func TestBar_EmptyTracker(t *testing.T) { - tr := New(0) - bar := tr.Bar(10) - if len(bar) == 0 { - t.Error("expected a bar even for empty tracker") - } -} - -func TestBar_HalfFilled(t *testing.T) { - tr := New(4) - tr.Update("g", "s1", Done, "") - tr.Update("g", "s2", Done, "") - - bar := tr.Bar(10) - filled := strings.Count(bar, "\u2588") - empty := strings.Count(bar, "\u2591") - if filled != 5 || empty != 5 { - t.Errorf("expected 5 filled + 5 empty for 2/4, got %d filled + %d empty", filled, empty) - } -} - -func TestPercent_Zero(t *testing.T) { - tr := New(0) - if tr.Percent() != 0 { - t.Errorf("expected 0%%, got %d%%", tr.Percent()) - } -} - -func TestPercent_Half(t *testing.T) { - tr := New(4) - tr.Update("g", "s1", Done, "") - tr.Update("g", "s2", Done, "") - if tr.Percent() != 50 { - t.Errorf("expected 50%%, got %d%%", tr.Percent()) - } -} - -func TestPercent_Full(t *testing.T) { - tr := New(3) - tr.Update("g", "s1", Done, "") - tr.Update("g", "s2", Done, "") - tr.Update("g", "s3", Done, "") - if tr.Percent() != 100 { - t.Errorf("expected 100%%, got %d%%", tr.Percent()) - } -} - -func TestBar_FullyComplete(t *testing.T) { - tr := New(2) - tr.Update("g", "s1", Done, "") - tr.Update("g", "s2", Done, "") - - bar := tr.Bar(10) - filled := strings.Count(bar, "\u2588") - if filled != 10 { - t.Errorf("expected 10 filled for 2/2, got %d", filled) - } -} - -func TestNewFromGroups_PreDeclaredStepsUpdateCorrectly(t *testing.T) { - tr := NewFromGroups([]Group{ - {Name: "wt-main", Steps: []Step{ - {Name: "install"}, - {Name: "setup"}, - }}, - {Name: "wt-feature", Steps: []Step{ - {Name: "install"}, - {Name: "setup"}, - }}, - }) - - if tr.Total() != 4 { - t.Fatalf("expected total=4, got %d", tr.Total()) - } - if tr.Done() != 0 { - t.Fatalf("expected done=0 initially, got %d", tr.Done()) - } - - tr.Update("wt-main", "install", Done, "") - tr.Update("wt-main", "setup", Done, "") - tr.Update("wt-feature", "install", Running, "") - - if tr.Done() != 2 { - t.Errorf("expected done=2, got %d", tr.Done()) - } - if tr.Total() != 4 { - t.Errorf("expected total=4 (stable), got %d", tr.Total()) - } -} - -func TestMultipleGroups_ProgressAccumulatesCorrectly(t *testing.T) { - tr := New(9) // 3 worktrees × 3 steps each - for _, wt := range []string{"main", "feature", "hotfix"} { - for _, step := range []string{"install", "setup", "verify"} { - tr.Update(wt, step, Running, "") - tr.Update(wt, step, Done, "") - } - } - - if tr.Done() != 9 { - t.Errorf("expected done=9, got %d", tr.Done()) - } - if tr.Total() != 9 { - t.Errorf("expected total=9, got %d", tr.Total()) - } - if !tr.IsComplete() { - t.Error("should be complete") - } -} diff --git a/internal/progress/validate.go b/internal/progress/validate.go new file mode 100644 index 0000000..f12507d --- /dev/null +++ b/internal/progress/validate.go @@ -0,0 +1,156 @@ +package progress + +import "fmt" + +// ValidateStream checks the stable stream contract: a complete +// declaration-and-close prefix must precede work, every event must carry its +// stable display labels, and every transition must target declared identities +// without mutating terminal state. +func ValidateStream(events []Event) error { + type declaration struct { + checkpoints int + terminal bool + reached int + } + declarations := map[string]*declaration{} + declaredPhases := map[PhaseID]bool{} + closed := map[PhaseID]bool{} + phaseLabels := map[PhaseID]string{} + stepLabels := map[string]string{} + prefixStage := 0 // 0: declarations, 1: closes, 2: work + for i, ev := range events { + key := ev.Phase + "\x00" + ev.Step + if ev.PhaseLabel == "" { + return fmt.Errorf("event %d: phase %q has empty label", i, ev.Phase) + } + if !ev.Close && ev.StepLabel == "" { + return fmt.Errorf("event %d: phase %q step %q has empty label", i, ev.Phase, ev.Step) + } + if label := phaseLabels[ev.Phase]; label != "" && label != ev.PhaseLabel { + return fmt.Errorf("event %d: phase %q label changed from %q to %q", i, ev.Phase, label, ev.PhaseLabel) + } + phaseLabels[ev.Phase] = ev.PhaseLabel + if ev.StepLabel != "" { + if label := stepLabels[key]; label != "" && label != ev.StepLabel { + return fmt.Errorf("event %d: phase %q step %q label changed from %q to %q", i, ev.Phase, ev.Step, label, ev.StepLabel) + } + stepLabels[key] = ev.StepLabel + } + if ev.Status == StepPending && !ev.Close { + if prefixStage != 0 { + return fmt.Errorf("event %d: declaration for step %q appears after declaration prefix", i, ev.Step) + } + if ev.Phase == "" || ev.Step == "" { + return fmt.Errorf("event %d: declaration has empty phase or step ID", i) + } + if ev.Of < 1 { + return fmt.Errorf("event %d: step %q declares invalid checkpoint count %d", i, ev.Step, ev.Of) + } + if _, exists := declarations[key]; exists { + return fmt.Errorf("event %d: duplicate declaration for phase %q step %q", i, ev.Phase, ev.Step) + } + declaredPhases[ev.Phase] = true + declarations[key] = &declaration{checkpoints: ev.Of} + continue + } + if ev.Close { + if prefixStage == 2 { + return fmt.Errorf("event %d: phase %q closes after work began", i, ev.Phase) + } + if ev.Phase == "" { + return fmt.Errorf("event %d: close marker has empty phase ID", i) + } + prefixStage = 1 + declaredPhases[ev.Phase] = true + if closed[ev.Phase] { + return fmt.Errorf("event %d: duplicate close for phase %q", i, ev.Phase) + } + closed[ev.Phase] = true + continue + } + prefixStage = 2 + for phaseID := range declaredPhases { + if !closed[phaseID] { + return fmt.Errorf("event %d: work began before phase %q closed", i, phaseID) + } + } + decl, exists := declarations[key] + if !exists { + return fmt.Errorf("event %d: undeclared phase %q step %q", i, ev.Phase, ev.Step) + } + if decl.terminal { + return fmt.Errorf("event %d: terminal mutation for phase %q step %q", i, ev.Phase, ev.Step) + } + if ev.Of != 0 && ev.Of != decl.checkpoints { + return fmt.Errorf("event %d: step %q checkpoint total changed from %d to %d", i, ev.Step, decl.checkpoints, ev.Of) + } + switch ev.Status { + case StepRunning: + if ev.Checkpoint < decl.reached { + return fmt.Errorf("event %d: step %q checkpoint regressed from %d to %d", i, ev.Step, decl.reached, ev.Checkpoint) + } + if ev.Checkpoint > decl.checkpoints { + return fmt.Errorf("event %d: step %q checkpoint %d exceeds declared %d", i, ev.Step, ev.Checkpoint, decl.checkpoints) + } + decl.reached = ev.Checkpoint + case StepDone: + decl.terminal = true + case StepFailed: + if ev.Error == nil { + return fmt.Errorf("event %d: failed step %q has no error", i, ev.Step) + } + decl.terminal = true + case StepSkipped: + if ev.Message == "" { + return fmt.Errorf("event %d: skipped step %q has no reason", i, ev.Step) + } + decl.terminal = true + case StepPending: + return fmt.Errorf("event %d: pending status outside declaration prefix", i) + default: + return fmt.Errorf("event %d: invalid step status %d", i, ev.Status) + } + } + for phaseID := range declaredPhases { + if !closed[phaseID] { + return fmt.Errorf("declaration prefix: phase %q was not closed", phaseID) + } + } + return nil +} + +// ValidateCompletedStream applies the strict stream contract and additionally +// requires a completed execution. +func ValidateCompletedStream(events []Event) error { + if err := ValidateStream(events); err != nil { + return err + } + if len(events) == 0 { + return nil + } + + declared := map[string]Event{} + phaseSteps := map[PhaseID]int{} + terminalSteps := map[string]bool{} + for _, ev := range events { + key := ev.Phase + "\x00" + ev.Step + switch { + case ev.Status == StepPending && !ev.Close: + declared[key] = ev + phaseSteps[ev.Phase]++ + case !ev.Close && terminal(ev.Status): + terminalSteps[key] = true + } + } + for key, declaration := range declared { + if !terminalSteps[key] { + return fmt.Errorf("phase %q step %q is not terminal", declaration.Phase, declaration.Step) + } + } + for _, ev := range events { + if ev.Close && phaseSteps[ev.Phase] == 0 { + return fmt.Errorf("phase %q has no declared steps", ev.Phase) + } + } + return nil +} diff --git a/internal/repo/clone.go b/internal/repo/clone.go index 58ab5d5..54a23bd 100644 --- a/internal/repo/clone.go +++ b/internal/repo/clone.go @@ -9,7 +9,7 @@ import ( "github.com/abiswas97/sentei/internal/fileutil" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" ) type CloneOptions struct { @@ -23,191 +23,184 @@ type CloneResult struct { WorktreePath string DefaultBranch string OriginURL string - Phases []pipeline.Phase + Phases []progress.Phase + Err error +} + +const cloneRollbackPhaseID progress.PhaseID = "clone:rollback" + +type cloneOperationKind uint8 + +const ( + cloneRegular cloneOperationKind = iota + cloneValidation + cloneBare + cloneDetect + cloneWorktree + cloneTracking + cloneRollback +) + +type cloneOperation struct { + phaseID progress.PhaseID + stepID progress.StepID + label string + kind cloneOperationKind + run progress.StepFunc +} + +type preparedClone struct { + result CloneResult + plan progress.Plan + operations []cloneOperation + branch *string } -// DeriveRepoName extracts a repository name from a git URL. -// "git@github.com:user/repo.git" → "repo" -// "https://github.com/user/repo.git" → "repo" -// "https://github.com/user/repo" → "repo" func DeriveRepoName(url string) string { url = strings.TrimSpace(url) - - // Drop query string / fragment so "repo?ref=main" / "repo#frag" don't leak in. if idx := strings.IndexAny(url, "?#"); idx != -1 { url = url[:idx] } - // Drop trailing slashes so "host/user/repo/" yields "repo", not "". url = strings.TrimRight(url, "/") - - // Handle SSH-style URLs: git@host:path if idx := strings.LastIndex(url, ":"); idx != -1 && !strings.Contains(url, "://") { url = url[idx+1:] } - - // Take last path segment name := url if idx := strings.LastIndex(name, "/"); idx != -1 { name = name[idx+1:] } + return strings.TrimSuffix(name, ".git") +} - // Strip .git suffix - name = strings.TrimSuffix(name, ".git") - - return name +func Clone(runner git.CommandRunner, opts CloneOptions, emit func(progress.Event)) CloneResult { + return prepareClone(runner, opts).run(emit) } -func Clone(runner git.CommandRunner, opts CloneOptions, emit func(pipeline.Event)) CloneResult { - result := CloneResult{OriginURL: opts.URL} +func prepareClone(runner git.CommandRunner, opts CloneOptions) preparedClone { repoPath := filepath.Join(opts.Location, opts.Name) - result.RepoPath = repoPath barePath := filepath.Join(repoPath, ".bare") - - // Validate the target before touching the filesystem. An empty or path-like - // name would otherwise turn the current directory (or an existing repo) into - // a bare repo with a "success" message. - if vphase, ok := validateCloneTarget(opts, repoPath); !ok { - result.Phases = append(result.Phases, vphase) - return result - } - - // repoPath did not exist (validated above), so on a failure that leaves no - // usable checkout we can remove exactly what we created and leave nothing - // half-built behind. - rollback := func() { _ = fileutil.RemoveAllRetry(repoPath) } - - // Phase 1: Clone - clonePhase := runClonePhase(runner, opts.Location, opts.URL, barePath, emit) - result.Phases = append(result.Phases, clonePhase) - if clonePhase.HasFailures() { - rollback() - return result - } - - // Phase 2: Structure - structPhase := runCloneStructure(runner, repoPath, barePath, emit) - result.Phases = append(result.Phases, structPhase) - if structPhase.HasFailures() { - rollback() - return result - } - - // Phase 3: Worktree - wtPhase, branch, worktreeCreated := runCloneWorktree(runner, repoPath, barePath, emit) - result.Phases = append(result.Phases, wtPhase) - result.DefaultBranch = branch - // Only advertise a worktree path when one was actually created. A failed - // worktree add must not leave WorktreePath set, or consumers report success - // and point the user at a directory that does not exist. - if worktreeCreated { - result.WorktreePath = git.WorktreePath(repoPath, branch) - } - // Roll back only when no usable checkout exists. If the worktree was created - // and merely upstream tracking failed, the repo is usable: keep it. - if wtPhase.HasFailures() && !worktreeCreated { - rollback() - } - - return result -} - -// validateCloneTarget rejects inputs that would corrupt an unintended directory. -// The returned phase is only meaningful (and only surfaced) when ok is false. -func validateCloneTarget(opts CloneOptions, repoPath string) (pipeline.Phase, bool) { - phase := pipeline.Phase{Name: "Validate"} - fail := func(err error) (pipeline.Phase, bool) { - phase.Steps = append(phase.Steps, pipeline.StepResult{Name: "Validate target", Status: pipeline.StepFailed, Error: err}) - return phase, false - } - - switch { - case opts.Name == "": - return fail(errors.New("could not derive a repository name from the URL; pass --name")) - case opts.Name == "." || opts.Name == ".." || strings.ContainsAny(opts.Name, `/\`): - return fail(fmt.Errorf("invalid repository name %q: must be a directory name, not a path", opts.Name)) - } - if _, err := os.Stat(repoPath); err == nil { - return fail(fmt.Errorf("target already exists: %s", repoPath)) + branch := "" + prepared := preparedClone{result: CloneResult{OriginURL: opts.URL, RepoPath: repoPath}, branch: &branch} + add := func(phaseID, phaseLabel, stepID, label string, kind cloneOperationKind, run progress.StepFunc) { + if len(prepared.plan.Phases) == 0 || prepared.plan.Phases[len(prepared.plan.Phases)-1].ID != phaseID { + prepared.plan.Phases = append(prepared.plan.Phases, progress.PlannedPhase{ID: phaseID, Label: phaseLabel}) + } + phase := &prepared.plan.Phases[len(prepared.plan.Phases)-1] + phase.Steps = append(phase.Steps, progress.PlannedStep{ID: stepID, Label: label}) + prepared.operations = append(prepared.operations, cloneOperation{phaseID: phaseID, stepID: stepID, label: label, kind: kind, run: run}) } - - return phase, true -} - -func runClonePhase(runner git.CommandRunner, location, url, barePath string, emit func(pipeline.Event)) pipeline.Phase { - rec := pipeline.NewPhaseRecorder("Clone", emit) - rec.Step("Clone bare repository", func() (string, error) { - _, err := runner.Run(location, "clone", "--bare", url, barePath) + add("clone:validate", "Validate", "target", "Validate target", cloneValidation, func() (string, error) { + switch { + case opts.Name == "": + return "", errors.New("could not derive a repository name from the URL; pass --name") + case opts.Name == "." || opts.Name == ".." || strings.ContainsAny(opts.Name, `/\`): + return "", fmt.Errorf("invalid repository name %q: must be a directory name, not a path", opts.Name) + } + if _, err := os.Stat(repoPath); err == nil { + return "", fmt.Errorf("target already exists: %s", repoPath) + } + return "", nil + }) + add("clone:bare", "Clone", "bare-repository", "Clone bare repository", cloneBare, func() (string, error) { + _, err := runner.Run(opts.Location, "clone", "--bare", opts.URL, barePath) return "", err }) - return rec.Phase() -} - -func runCloneStructure(runner git.CommandRunner, repoPath, barePath string, emit func(pipeline.Event)) pipeline.Phase { - rec := pipeline.NewPhaseRecorder("Structure", emit) - - // Ensure repoPath exists — bare clone creates .bare but not necessarily the parent. - ok := rec.Step("Create .git pointer", func() (string, error) { + add("clone:structure", "Structure", "git-pointer", "Create .git pointer", cloneRegular, func() (string, error) { if err := os.MkdirAll(repoPath, 0755); err != nil { return "", err } return "", os.WriteFile(filepath.Join(repoPath, ".git"), []byte("gitdir: .bare\n"), 0644) }) - if !ok { - return rec.Phase() - } - - rec.Step("Configure refspec", func() (string, error) { + add("clone:structure", "Structure", "refspec", "Configure refspec", cloneRegular, func() (string, error) { _, err := runner.Run(barePath, "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*") return "", err }) - return rec.Phase() -} - -func runCloneWorktree(runner git.CommandRunner, repoPath, barePath string, emit func(pipeline.Event)) (pipeline.Phase, string, bool) { - rec := pipeline.NewPhaseRecorder("Worktree", emit) - - var branch string - rec.Step("Detect default branch", func() (string, error) { + add("clone:worktree", "Worktree", "default-branch", "Detect default branch", cloneDetect, func() (string, error) { branch = git.DetectDefaultBranch(runner, barePath) return branch, nil }) - - // An empty remote leaves HEAD pointing at a branch with no commit; worktree - // add would otherwise fail with a cryptic "invalid reference". Surface it. - if !git.BranchExists(runner, barePath, branch) { - rec.Fail("Create worktree", fmt.Errorf("remote has no commits on %q yet (nothing to check out)", branch)) - return rec.Phase(), branch, false - } - - // Create worktree. The branch is passed explicitly as the commit-ish: - // without it, git derives a NEW branch from the path's basename instead of - // checking out the existing one. - wtPath := git.WorktreePath(repoPath, branch) - ok := rec.Step("Create worktree", func() (string, error) { - _, err := runner.Run(repoPath, "worktree", "add", wtPath, branch) + add("clone:worktree", "Worktree", "checkout", "Create worktree", cloneWorktree, func() (string, error) { + if !git.BranchExists(runner, barePath, branch) { + return "", fmt.Errorf("remote has no commits on %q yet (nothing to check out)", branch) + } + _, err := runner.Run(repoPath, "worktree", "add", git.WorktreePath(repoPath, branch), branch) return "", err }) - if !ok { - return rec.Phase(), branch, false - } - - // Tracking is best-effort: the checkout above is already usable. Populating - // refs/remotes/origin/* (fetch) and setting upstream both need the remote; a - // network/auth failure here must NOT fail the clone. pipeline.StepSkipped keeps - // HasFailures() false so the clone still reports success, just without tracking. - rec.Emit("Set upstream tracking", pipeline.StepRunning, "") - trackErr := func() error { + add("clone:worktree", "Worktree", "tracking", "Set upstream tracking", cloneTracking, func() (string, error) { if _, err := runner.Run(barePath, "fetch", "origin"); err != nil { - return err + return "", err } - _, err := runner.Run(wtPath, "branch", fmt.Sprintf("--set-upstream-to=origin/%s", branch)) - return err - }() - if trackErr != nil { - rec.Skip("Set upstream tracking", "no tracking: "+trackErr.Error()) - return rec.Phase(), branch, true - } - rec.Done("Set upstream tracking", "") + _, err := runner.Run(git.WorktreePath(repoPath, branch), "branch", fmt.Sprintf("--set-upstream-to=origin/%s", branch)) + return "", err + }) + add(cloneRollbackPhaseID, "Rollback", "remove-partial-checkout", "Remove partial checkout", cloneRollback, func() (string, error) { + return "", fileutil.RemoveAllRetry(repoPath) + }) + return prepared +} - return rec.Phase(), branch, true +func (p preparedClone) run(emit func(progress.Event)) CloneResult { + result := p.result + execution, err := progress.Start(p.plan, emit) + if err != nil { + result.Err = fmt.Errorf("starting repository clone: %w", err) + return result + } + failedBy := "" + touched := false + usable := false + for _, operation := range p.operations { + if operation.kind == cloneRollback { + if !touched || usable { + _, err = execution.Skip(operation.phaseID, operation.stepID, "rollback not required") + } else { + _, err = execution.Run(operation.phaseID, operation.stepID, operation.run) + } + if err != nil { + result.Err = errors.Join(result.Err, fmt.Errorf("executing rollback: %w", err)) + } + continue + } + if failedBy != "" { + _, err = execution.Skip(operation.phaseID, operation.stepID, "blocked by "+failedBy) + if err != nil { + result.Err = errors.Join(result.Err, err) + } + continue + } + if operation.kind == cloneBare { + touched = true + } + if operation.kind == cloneTracking { + if err = execution.Running(operation.phaseID, operation.stepID, 0, ""); err == nil { + _, runErr := operation.run() + if runErr != nil { + _, err = execution.Skip(operation.phaseID, operation.stepID, "no tracking: "+runErr.Error()) + } else { + _, err = execution.Done(operation.phaseID, operation.stepID, "") + } + } + } else { + var step progress.StepResult + step, err = execution.Run(operation.phaseID, operation.stepID, operation.run) + if err == nil && step.Status == progress.StepFailed { + failedBy = operation.label + } + } + if err != nil { + result.Err = errors.Join(result.Err, fmt.Errorf("executing %s: %w", operation.label, err)) + failedBy = operation.label + } + if operation.kind == cloneDetect && failedBy == "" { + result.DefaultBranch = *p.branch + } + if operation.kind == cloneWorktree && failedBy == "" { + usable = true + result.WorktreePath = git.WorktreePath(result.RepoPath, result.DefaultBranch) + } + } + finishErr := execution.Finish("repository clone finished") + result.Phases = execution.Phases() + result.Err = errors.Join(result.Err, finishErr) + return result } diff --git a/internal/repo/clone_execution_test.go b/internal/repo/clone_execution_test.go new file mode 100644 index 0000000..3013710 --- /dev/null +++ b/internal/repo/clone_execution_test.go @@ -0,0 +1,98 @@ +package repo + +import ( + "errors" + "testing" + + "github.com/abiswas97/sentei/internal/progress" + "github.com/abiswas97/sentei/internal/testutil/mock" +) + +func TestPreparedClone_FailureAndRollbackPolicy(t *testing.T) { + base := prepareClone(&mock.Runner{}, CloneOptions{URL: "url", Location: "/tmp", Name: "project"}) + if len(base.operations) != 8 { + t.Fatalf("operations = %d, want validation, clone, structure, worktree, tracking, rollback", len(base.operations)) + } + for failedAt := 0; failedAt < 7; failedAt++ { + t.Run(base.operations[failedAt].label, func(t *testing.T) { + prepared := base + prepared.operations = append([]cloneOperation(nil), base.operations...) + for i := range prepared.operations { + prepared.operations[i].run = func() (string, error) { return "", nil } + } + prepared.operations[failedAt].run = func() (string, error) { return "", errors.New("injected") } + result := prepared.run(func(progress.Event) {}) + if result.Err != nil { + t.Fatalf("Err = %v", result.Err) + } + failed := resultStepByID(t, result.Phases, prepared.operations[failedAt].phaseID, prepared.operations[failedAt].stepID) + if failedAt == 6 { + if failed.Status != progress.StepSkipped || result.WorktreePath == "" { + t.Fatalf("tracking result=%#v worktree=%q", failed, result.WorktreePath) + } + } else if failed.Status != progress.StepFailed { + t.Fatalf("failed operation = %#v", failed) + } + rollback := resultStepByID(t, result.Phases, cloneRollbackPhaseID, "remove-partial-checkout") + wantRollback := progress.StepSkipped + if failedAt >= 1 && failedAt <= 5 { + wantRollback = progress.StepDone + } + if rollback.Status != wantRollback { + t.Fatalf("rollback = %#v, want status %v", rollback, wantRollback) + } + }) + } +} + +func TestPreparedClone_RollbackFailureIsSurfaced(t *testing.T) { + prepared := prepareClone(&mock.Runner{}, CloneOptions{URL: "url", Location: "/tmp", Name: "project"}) + for i := range prepared.operations { + prepared.operations[i].run = func() (string, error) { return "", nil } + } + prepared.operations[1].run = func() (string, error) { return "", errors.New("clone") } + prepared.operations[7].run = func() (string, error) { return "", errors.New("rollback") } + result := prepared.run(func(progress.Event) {}) + rollback := resultStepByID(t, result.Phases, cloneRollbackPhaseID, "remove-partial-checkout") + if rollback.Status != progress.StepFailed || rollback.Error == nil { + t.Fatalf("rollback = %#v", rollback) + } +} + +func TestPreparedClone_CallbackPanicPopulatesErr(t *testing.T) { + want := errors.New("delivery") + result := prepareClone(&mock.Runner{}, CloneOptions{URL: "url", Location: "/tmp", Name: "project"}).run(func(progress.Event) { panic(want) }) + if !errors.Is(result.Err, want) || len(result.Phases) != 0 { + t.Fatalf("result = %#v", result) + } +} + +func TestPreparedClone_ResultMatchesCompletedStream(t *testing.T) { + prepared := prepareClone(&mock.Runner{}, CloneOptions{URL: "url", Location: "/tmp", Name: "project"}) + for i := range prepared.operations { + prepared.operations[i].run = func() (string, error) { return "", nil } + } + var events []progress.Event + result := prepared.run(func(event progress.Event) { events = append(events, event) }) + assertRepoStreamParity(t, events, result.Phases) +} + +func assertRepoStreamParity(t *testing.T, events []progress.Event, phases []progress.Phase) { + t.Helper() + if err := progress.ValidateStream(events); err != nil { + t.Fatalf("invalid completed stream: %v", err) + } + for _, phase := range phases { + for _, step := range phase.Steps { + last := progress.Event{} + for _, event := range events { + if event.Phase == phase.ID && event.Step == step.ID && !event.Close { + last = event + } + } + if last.Status != step.Status { + t.Fatalf("%s/%s result=%v stream=%v", phase.ID, step.ID, step.Status, last.Status) + } + } + } +} diff --git a/internal/repo/clone_test.go b/internal/repo/clone_test.go index 26f8fd1..d672c49 100644 --- a/internal/repo/clone_test.go +++ b/internal/repo/clone_test.go @@ -6,7 +6,7 @@ import ( "path/filepath" "testing" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/testutil/mock" ) @@ -55,7 +55,7 @@ func TestClone_Successful(t *testing.T) { fmt.Sprintf("%s/main:[branch --set-upstream-to=origin/main]", repoPath): {Output: ""}, }} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} opts := CloneOptions{ URL: "git@github.com:user/repo.git", Location: dir, @@ -71,7 +71,7 @@ func TestClone_Successful(t *testing.T) { } for _, phase := range result.Phases { for _, step := range phase.Steps { - if step.Status == pipeline.StepFailed { + if step.Status == progress.StepFailed { t.Errorf("step %q failed: %v", step.Name, step.Error) } } @@ -95,7 +95,7 @@ func TestClone_DefaultBranchFallback(t *testing.T) { fmt.Sprintf("%s/main:[branch --set-upstream-to=origin/main]", repoPath): {Output: ""}, }} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} opts := CloneOptions{URL: "git@github.com:user/repo.git", Location: dir, Name: repoName} result := Clone(runner, opts, ec.Emit) @@ -121,7 +121,7 @@ func TestClone_NonStandardDefaultBranch(t *testing.T) { fmt.Sprintf("%s/production:[branch --set-upstream-to=origin/production]", repoPath): {Output: ""}, }} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} opts := CloneOptions{URL: "git@github.com:user/repo.git", Location: dir, Name: repoName} result := Clone(runner, opts, ec.Emit) @@ -130,7 +130,7 @@ func TestClone_NonStandardDefaultBranch(t *testing.T) { } for _, phase := range result.Phases { for _, step := range phase.Steps { - if step.Status == pipeline.StepFailed { + if step.Status == progress.StepFailed { t.Errorf("step %q failed: %v", step.Name, step.Error) } } @@ -147,11 +147,12 @@ func TestClone_NetworkError(t *testing.T) { }, }} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} opts := CloneOptions{URL: "git@github.com:user/repo.git", Location: dir, Name: "repo"} result := Clone(runner, opts, ec.Emit) - if len(result.Phases) == 0 || !result.Phases[0].HasFailures() { + clonePhase := findPhase(result.Phases, "Clone") + if clonePhase == nil || !clonePhase.HasFailures() { t.Error("expected clone phase to fail on network error") } } @@ -172,7 +173,7 @@ func TestClone_FetchFailure_StillSucceedsWithoutTracking(t *testing.T) { fmt.Sprintf("%s:[fetch origin]", barePath): {Output: "", Err: fmt.Errorf("could not read from remote")}, }} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} result := Clone(runner, CloneOptions{URL: "git@h:u/repo.git", Location: dir, Name: "repo"}, ec.Emit) if result.HasFailures() { @@ -188,14 +189,12 @@ func TestClone_EmptyName_RejectedBeforeAnyGitCall(t *testing.T) { // Empty Responses: any git call would return an error, proving none happens. runner := &mock.Runner{Responses: map[string]mock.Response{}} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} opts := CloneOptions{URL: "https://host/user/repo/", Location: dir, Name: ""} result := Clone(runner, opts, ec.Emit) - if len(result.Phases) != 1 || result.Phases[0].Name != "Validate" { - t.Fatalf("expected only a Validate phase, got %+v", result.Phases) - } - if !result.Phases[0].HasFailures() { + validate := findPhase(result.Phases, "Validate") + if validate == nil || !validate.HasFailures() { t.Error("empty name must fail validation") } if result.DefaultBranch != "" { @@ -206,11 +205,12 @@ func TestClone_EmptyName_RejectedBeforeAnyGitCall(t *testing.T) { func TestClone_PathLikeName_Rejected(t *testing.T) { dir := t.TempDir() runner := &mock.Runner{Responses: map[string]mock.Response{}} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} for _, name := range []string{"/abs/target", "../escaped", "nested/name", ".."} { result := Clone(runner, CloneOptions{URL: "u", Location: dir, Name: name}, ec.Emit) - if len(result.Phases) != 1 || !result.Phases[0].HasFailures() { + validate := findPhase(result.Phases, "Validate") + if validate == nil || !validate.HasFailures() { t.Errorf("name %q should be rejected by validation, got %+v", name, result.Phases) } } @@ -228,10 +228,11 @@ func TestClone_ExistingTarget_RejectedAndPreserved(t *testing.T) { } runner := &mock.Runner{Responses: map[string]mock.Response{}} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} result := Clone(runner, CloneOptions{URL: "u", Location: dir, Name: "repo"}, ec.Emit) - if len(result.Phases) != 1 || !result.Phases[0].HasFailures() { + validate := findPhase(result.Phases, "Validate") + if validate == nil || !validate.HasFailures() { t.Fatalf("existing target must be rejected, got %+v", result.Phases) } if _, err := os.Stat(sentinel); err != nil { @@ -262,7 +263,7 @@ func TestClone_WorktreeFailure_RollsBackPartialDir(t *testing.T) { }, } - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} result := Clone(runner, CloneOptions{URL: "u", Location: dir, Name: "repo"}, ec.Emit) if !result.HasFailures() { @@ -282,7 +283,7 @@ func TestClone_TrackingSkip_PreservesRepoDir(t *testing.T) { barePath := filepath.Join(repoPath, ".bare") // Worktree add succeeds; the best-effort fetch fails. The clone must still - // succeed (tracking is pipeline.StepSkipped) and must NOT roll back the repo dir. + // succeed (tracking is progress.StepSkipped) and must NOT roll back the repo dir. runner := &mock.Runner{ Responses: map[string]mock.Response{ fmt.Sprintf("%s:[clone --bare u %s]", dir, barePath): {Output: ""}, @@ -299,7 +300,7 @@ func TestClone_TrackingSkip_PreservesRepoDir(t *testing.T) { }, } - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} result := Clone(runner, CloneOptions{URL: "u", Location: dir, Name: "repo"}, ec.Emit) if result.HasFailures() { diff --git a/internal/repo/create.go b/internal/repo/create.go index adb6b4e..971c21c 100644 --- a/internal/repo/create.go +++ b/internal/repo/create.go @@ -2,6 +2,7 @@ package repo import ( "bytes" + "errors" "fmt" "os" "os/exec" @@ -9,7 +10,7 @@ import ( "strings" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" ) // GhRunner executes gh CLI commands directly without a shell, preventing shell injection. @@ -49,58 +50,66 @@ type CreateResult struct { RepoPath string WorktreePath string GitHubURL string - Phases []pipeline.Phase + Phases []progress.Phase + Err error +} + +const createSetupPhaseID progress.PhaseID = "create:setup" +const createGitHubPhaseID progress.PhaseID = "create:github" + +type createOperation struct { + phaseID progress.PhaseID + stepID progress.StepID + label string + run progress.StepFunc +} + +type preparedCreate struct { + result CreateResult + plan progress.Plan + operations []createOperation + publish bool + name string + githubUser *string } // SetupFailed reports whether a non-GitHub phase failed (the local repo itself is // broken, not merely unpublished) and the first such error. func (r CreateResult) SetupFailed() (bool, error) { + if r.Err != nil { + return true, r.Err + } for _, p := range r.Phases { if p.Name != PhaseGitHub && p.HasFailures() { - _, step, _ := pipeline.FirstFailure([]pipeline.Phase{p}) + _, step, _ := progress.FirstFailure([]progress.Phase{p}) return true, step.Error } } return false, nil } -func Create(runner git.CommandRunner, shell git.ShellRunner, opts CreateOptions, emit func(pipeline.Event)) CreateResult { +func Create(runner git.CommandRunner, shell git.ShellRunner, opts CreateOptions, emit func(progress.Event)) CreateResult { return CreateWithGh(runner, shell, &DefaultGhRunner{}, opts, emit) } -func CreateWithGh(runner git.CommandRunner, _ git.ShellRunner, gh GhRunner, opts CreateOptions, emit func(pipeline.Event)) CreateResult { - result := CreateResult{} - repoPath := filepath.Join(opts.Location, opts.Name) - result.RepoPath = repoPath - - setupPhase := runCreateSetup(runner, repoPath, opts, emit) - result.Phases = append(result.Phases, setupPhase) - if setupPhase.HasFailures() { - return result - } - result.WorktreePath = git.WorktreePath(repoPath, "main") - - if opts.PublishGitHub { - ghPhase := runCreateGitHub(runner, gh, repoPath, opts, emit) - result.Phases = append(result.Phases, ghPhase) - if !ghPhase.HasFailures() { - // Extract GitHub URL from user lookup - for _, step := range ghPhase.Steps { - if step.Name == "Look up GitHub user" && step.Status == pipeline.StepDone { - result.GitHubURL = fmt.Sprintf("github.com/%s/%s", step.Message, opts.Name) - } - } - } - } - - return result +func CreateWithGh(runner git.CommandRunner, _ git.ShellRunner, gh GhRunner, opts CreateOptions, emit func(progress.Event)) CreateResult { + return prepareCreate(runner, gh, opts).run(emit) } -func runCreateSetup(runner git.CommandRunner, repoPath string, opts CreateOptions, emit func(pipeline.Event)) pipeline.Phase { - rec := pipeline.NewPhaseRecorder("Setup", emit) +func prepareCreate(runner git.CommandRunner, gh GhRunner, opts CreateOptions) preparedCreate { + repoPath := filepath.Join(opts.Location, opts.Name) barePath := filepath.Join(repoPath, ".bare") - - ok := rec.Step("Create directory", func() (string, error) { + mainPath := git.WorktreePath(repoPath, "main") + ghUser := "" + prepared := preparedCreate{result: CreateResult{RepoPath: repoPath}, publish: opts.PublishGitHub, name: opts.Name, githubUser: &ghUser} + add := func(phaseID progress.PhaseID, phaseLabel, id, label string, run progress.StepFunc) { + if len(prepared.plan.Phases) == 0 || prepared.plan.Phases[len(prepared.plan.Phases)-1].ID != phaseID { + prepared.plan.Phases = append(prepared.plan.Phases, progress.PlannedPhase{ID: phaseID, Label: phaseLabel}) + } + prepared.plan.Phases[len(prepared.plan.Phases)-1].Steps = append(prepared.plan.Phases[len(prepared.plan.Phases)-1].Steps, progress.PlannedStep{ID: id, Label: label}) + prepared.operations = append(prepared.operations, createOperation{phaseID: phaseID, stepID: id, label: label, run: run}) + } + add(createSetupPhaseID, "Setup", "directory", "Create directory", func() (string, error) { if err := os.MkdirAll(repoPath, 0755); err != nil { return "", err } @@ -110,46 +119,25 @@ func runCreateSetup(runner git.CommandRunner, repoPath string, opts CreateOption } return "", nil }) - if !ok { - return rec.Phase() - } - - ok = rec.Step("Init bare repository", func() (string, error) { + add(createSetupPhaseID, "Setup", "bare-init", "Init bare repository", func() (string, error) { if err := os.MkdirAll(barePath, 0755); err != nil { return "", err } _, err := runner.Run(barePath, "init", "--bare") return "", err }) - if !ok { - return rec.Phase() - } - - ok = rec.Step("Create .git pointer", func() (string, error) { + add(createSetupPhaseID, "Setup", "git-pointer", "Create .git pointer", func() (string, error) { return "", os.WriteFile(filepath.Join(repoPath, ".git"), []byte("gitdir: .bare\n"), 0644) }) - if !ok { - return rec.Phase() - } - - ok = rec.Step("Configure refspec", func() (string, error) { + add(createSetupPhaseID, "Setup", "refspec", "Configure refspec", func() (string, error) { _, err := runner.Run(barePath, "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*") return "", err }) - if !ok { - return rec.Phase() - } - - ok = rec.Step("Create main worktree", func() (string, error) { - _, err := runner.Run(repoPath, "worktree", "add", git.WorktreePath(repoPath, "main"), "-b", "main") + add(createSetupPhaseID, "Setup", "main-worktree", "Create main worktree", func() (string, error) { + _, err := runner.Run(repoPath, "worktree", "add", mainPath, "-b", "main") return "", err }) - if !ok { - return rec.Phase() - } - - rec.Step("Initial commit", func() (string, error) { - mainPath := git.WorktreePath(repoPath, "main") + add(createSetupPhaseID, "Setup", "initial-commit", "Initial commit", func() (string, error) { if err := os.MkdirAll(mainPath, 0755); err != nil { return "", err } @@ -163,27 +151,15 @@ func runCreateSetup(runner git.CommandRunner, repoPath string, opts CreateOption _, err := runner.Run(mainPath, "commit", "-m", "Initial commit") return "", err }) - - return rec.Phase() -} - -func runCreateGitHub(runner git.CommandRunner, gh GhRunner, repoPath string, opts CreateOptions, emit func(pipeline.Event)) pipeline.Phase { - rec := pipeline.NewPhaseRecorder(PhaseGitHub, emit) - barePath := filepath.Join(repoPath, ".bare") - - var ghUser string - ok := rec.Step("Look up GitHub user", func() (string, error) { + if !opts.PublishGitHub { + return prepared + } + add(createGitHubPhaseID, PhaseGitHub, "github-user", "Look up GitHub user", func() (string, error) { var err error ghUser, err = gh.RunGh(repoPath, "api", "user", "--jq", ".login") return ghUser, err }) - if !ok { - return rec.Phase() - } - - // Create the repo without --source/--push — we push manually after - // configuring the remote. - ok = rec.Step("Create GitHub repository", func() (string, error) { + add(createGitHubPhaseID, PhaseGitHub, "github-repository", "Create GitHub repository", func() (string, error) { ghArgs := []string{"repo", "create", opts.Name, "--" + opts.Visibility} if opts.Description != "" { ghArgs = append(ghArgs, "--description", opts.Description) @@ -191,39 +167,57 @@ func runCreateGitHub(runner git.CommandRunner, gh GhRunner, repoPath string, opt _, err := gh.RunGh(repoPath, ghArgs...) return "", err }) - if !ok { - return rec.Phase() - } - - // Configure the remote using gh's configured protocol so the push uses the - // auth the user actually has. Forcing SSH breaks push for an HTTPS-only gh - // login (gh's default), orphaning the just-created empty GitHub repo. - ok = rec.Step("Configure remote", func() (string, error) { + add(createGitHubPhaseID, PhaseGitHub, "github-remote", "Configure remote", func() (string, error) { _, err := runner.Run(barePath, "remote", "set-url", "origin", ghRemoteURL(gh, repoPath, ghUser, opts.Name)) return "", err }) - if !ok { - return rec.Phase() - } - - ok = rec.Step("Push to GitHub", func() (string, error) { - if _, err := runner.Run(git.WorktreePath(repoPath, "main"), "push", "-u", "origin", "main"); err != nil { + add(createGitHubPhaseID, PhaseGitHub, "github-push", "Push to GitHub", func() (string, error) { + if _, err := runner.Run(mainPath, "push", "-u", "origin", "main"); err != nil { // The empty remote repo from "Create GitHub repository" is left behind; // tell the user so they can delete it or push manually before retrying. return "", fmt.Errorf("%w (an empty GitHub repo %q now exists; delete it or push to it manually before retrying)", err, opts.Name) } return "", nil }) - if !ok { - return rec.Phase() - } - - rec.Step("Set remote HEAD", func() (string, error) { + add(createGitHubPhaseID, PhaseGitHub, "github-head", "Set remote HEAD", func() (string, error) { _, err := runner.Run(barePath, "remote", "set-head", "origin", "main") return "", err }) + return prepared +} - return rec.Phase() +func (p preparedCreate) run(emit func(progress.Event)) CreateResult { + result := p.result + execution, err := progress.Start(p.plan, emit) + if err != nil { + result.Err = fmt.Errorf("starting repository create: %w", err) + return result + } + for index, operation := range p.operations { + step, transitionErr := execution.Run(operation.phaseID, operation.stepID, operation.run) + if transitionErr != nil { + result.Err = fmt.Errorf("executing %s: %w", operation.label, transitionErr) + break + } + if operation.phaseID == createSetupPhaseID && index == 5 && step.Status == progress.StepDone { + result.WorktreePath = git.WorktreePath(result.RepoPath, "main") + } + if step.Status != progress.StepFailed { + continue + } + _ = execution.SkipPending(operation.phaseID, "blocked by "+operation.label) + if operation.phaseID == createSetupPhaseID && p.publish { + _ = execution.SkipPending(createGitHubPhaseID, "blocked by "+operation.label) + } + break + } + finishErr := execution.Finish("repository create finished") + result.Phases = execution.Phases() + result.Err = errors.Join(result.Err, finishErr) + if result.Err == nil && result.WorktreePath != "" && p.publish && !progress.PhasesHaveFailures(result.Phases) { + result.GitHubURL = fmt.Sprintf("github.com/%s/%s", *p.githubUser, p.name) + } + return result } // ghRemoteURL returns the origin URL matching gh's configured git protocol, so diff --git a/internal/repo/create_execution_test.go b/internal/repo/create_execution_test.go new file mode 100644 index 0000000..6553bc0 --- /dev/null +++ b/internal/repo/create_execution_test.go @@ -0,0 +1,118 @@ +package repo + +import ( + "errors" + "testing" + + "github.com/abiswas97/sentei/internal/progress" + "github.com/abiswas97/sentei/internal/testutil/mock" +) + +func TestPreparedCreate_FailurePolicyAtEveryDeclaredOperation(t *testing.T) { + prepared := prepareCreate(&mock.Runner{}, &mockGhRunner{}, CreateOptions{ + Name: "project", Location: "/tmp", PublishGitHub: true, Visibility: "private", + }) + if len(prepared.operations) != 11 { + t.Fatalf("operations = %d, want six local plus five GitHub", len(prepared.operations)) + } + for failedAt := range prepared.operations { + t.Run(prepared.operations[failedAt].label, func(t *testing.T) { + candidate := prepared + candidate.operations = append([]createOperation(nil), prepared.operations...) + for i := range candidate.operations { + candidate.operations[i].run = func() (string, error) { return "", nil } + } + candidate.operations[failedAt].run = func() (string, error) { return "", errors.New("injected") } + result := candidate.run(func(progress.Event) {}) + if result.Err != nil { + t.Fatalf("Err = %v, operation failure must remain a step failure", result.Err) + } + for i, op := range candidate.operations { + step := resultStepByID(t, result.Phases, op.phaseID, op.stepID) + want := progress.StepDone + switch { + case i == failedAt: + want = progress.StepFailed + case i > failedAt && op.phaseID == candidate.operations[failedAt].phaseID: + want = progress.StepSkipped + case i > failedAt && candidate.operations[failedAt].phaseID == createSetupPhaseID: + want = progress.StepSkipped + } + if step.Status != want { + t.Fatalf("%s status = %v, want %v", op.label, step.Status, want) + } + } + if failedAt >= 6 && result.WorktreePath == "" { + t.Fatal("GitHub failure discarded usable local worktree") + } + }) + } +} + +func TestPreparedCreate_CallbackPanicPopulatesErr(t *testing.T) { + want := errors.New("delivery") + prepared := prepareCreate(&mock.Runner{}, &mockGhRunner{}, CreateOptions{Name: "project", Location: "/tmp"}) + result := prepared.run(func(progress.Event) { panic(want) }) + if !errors.Is(result.Err, want) { + t.Fatalf("Err = %v, want wrapped callback failure", result.Err) + } + if len(result.Phases) != 0 { + t.Fatalf("Phases = %#v, want only Execution projection", result.Phases) + } +} + +func TestCreateResult_ContractErrorIsHardFailure(t *testing.T) { + want := errors.New("delivery") + result := CreateResult{Err: want} + if !result.HasFailures() { + t.Fatal("contract error must fail the result") + } + failed, err := result.SetupFailed() + if !failed || !errors.Is(err, want) { + t.Fatalf("SetupFailed() = %v, %v; want hard contract failure", failed, err) + } +} + +func TestPreparedCreate_ResultMatchesCompletedStream(t *testing.T) { + prepared := prepareCreate(&mock.Runner{}, &mockGhRunner{}, CreateOptions{Name: "project", Location: "/tmp", PublishGitHub: true}) + for i := range prepared.operations { + prepared.operations[i].run = func() (string, error) { return "", nil } + } + var events []progress.Event + result := prepared.run(func(event progress.Event) { events = append(events, event) }) + if result.Err != nil { + t.Fatal(result.Err) + } + if err := progress.ValidateStream(events); err != nil { + t.Fatalf("invalid completed stream: %v", err) + } + for _, phase := range result.Phases { + for _, step := range phase.Steps { + last := progress.Event{} + for _, event := range events { + if event.Phase == phase.ID && event.Step == step.ID && !event.Close { + last = event + } + } + if last.Status != step.Status { + t.Fatalf("%s/%s result=%v stream=%v", phase.ID, step.ID, step.Status, last.Status) + } + } + } +} + +func resultStepByID(t *testing.T, phases []progress.Phase, phaseID progress.PhaseID, stepID progress.StepID) progress.StepResult { + t.Helper() + for _, phase := range phases { + if phase.ID != phaseID { + continue + } + for _, step := range phase.Steps { + if step.ID == stepID { + return step + } + } + } + t.Fatalf("missing result %s/%s", phaseID, stepID) + return progress.StepResult{} +} diff --git a/internal/repo/create_test.go b/internal/repo/create_test.go index b2128ad..e8e7efc 100644 --- a/internal/repo/create_test.go +++ b/internal/repo/create_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/testutil/mock" ) @@ -37,7 +37,7 @@ func TestCreate_LocalOnly(t *testing.T) { fmt.Sprintf("%s/main:[commit -m Initial commit]", repoPath): {Output: ""}, }} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} opts := CreateOptions{ Name: repoName, Location: dir, @@ -53,7 +53,7 @@ func TestCreate_LocalOnly(t *testing.T) { } for _, phase := range result.Phases { for _, step := range phase.Steps { - if step.Status == pipeline.StepFailed { + if step.Status == progress.StepFailed { t.Errorf("step %q failed: %v", step.Name, step.Error) } } @@ -86,7 +86,7 @@ func TestCreate_WithGitHub(t *testing.T) { fmt.Sprintf("%s:gh[config get git_protocol]", repoPath): {Output: "https"}, }} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} opts := CreateOptions{ Name: repoName, Location: dir, @@ -101,7 +101,7 @@ func TestCreate_WithGitHub(t *testing.T) { } for _, phase := range result.Phases { for _, step := range phase.Steps { - if step.Status == pipeline.StepFailed { + if step.Status == progress.StepFailed { t.Errorf("step %q failed: %v", step.Name, step.Error) } } @@ -136,7 +136,7 @@ func TestCreate_DirAlreadyExists(t *testing.T) { os.MkdirAll(filepath.Join(dir, repoName), 0755) runner := &mock.Runner{Responses: map[string]mock.Response{}} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} opts := CreateOptions{ Name: repoName, Location: dir, @@ -169,7 +169,7 @@ func TestCreate_GitHubPhaseFailure_LocalStillUsable(t *testing.T) { fmt.Sprintf("%s:gh[api user --jq .login]", repoPath): {Output: "", Err: fmt.Errorf("gh: not authenticated")}, }} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} opts := CreateOptions{ Name: repoName, Location: dir, @@ -214,11 +214,11 @@ func TestCreate_PushFailure_ReportsOrphanedRepo(t *testing.T) { fmt.Sprintf("%s:gh[config get git_protocol]", repoPath): {Output: "https"}, }} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} opts := CreateOptions{Name: repoName, Location: dir, PublishGitHub: true, Visibility: "private"} result := CreateWithGh(runner, runner, ghRunner, opts, ec.Emit) - var pushStep *pipeline.StepResult + var pushStep *progress.StepResult for i := range result.Phases { if result.Phases[i].Name != "GitHub" { continue @@ -229,7 +229,7 @@ func TestCreate_PushFailure_ReportsOrphanedRepo(t *testing.T) { } } } - if pushStep == nil || pushStep.Status != pipeline.StepFailed { + if pushStep == nil || pushStep.Status != progress.StepFailed { t.Fatal("expected a failed 'Push to GitHub' step") } msg := pushStep.Error.Error() @@ -245,22 +245,22 @@ func TestCreate_PushFailure_ReportsOrphanedRepo(t *testing.T) { } func TestCreateResult_SetupFailed(t *testing.T) { - setupBroken := CreateResult{Phases: []pipeline.Phase{ - {Name: "Setup", Steps: []pipeline.StepResult{{Name: "Initial commit", Status: pipeline.StepFailed, Error: fmt.Errorf("exit 128")}}}, + setupBroken := CreateResult{Phases: []progress.Phase{ + {Name: "Setup", Steps: []progress.StepResult{{Name: "Initial commit", Status: progress.StepFailed, Error: fmt.Errorf("exit 128")}}}, }} if failed, err := setupBroken.SetupFailed(); !failed || err == nil { t.Errorf("a Setup-phase failure must be hard, got failed=%v err=%v", failed, err) } - githubOnly := CreateResult{Phases: []pipeline.Phase{ - {Name: "Setup", Steps: []pipeline.StepResult{{Status: pipeline.StepDone}}}, - {Name: PhaseGitHub, Steps: []pipeline.StepResult{{Status: pipeline.StepFailed, Error: fmt.Errorf("push")}}}, + githubOnly := CreateResult{Phases: []progress.Phase{ + {Name: "Setup", Steps: []progress.StepResult{{Status: progress.StepDone}}}, + {Name: PhaseGitHub, Steps: []progress.StepResult{{Status: progress.StepFailed, Error: fmt.Errorf("push")}}}, }} if failed, _ := githubOnly.SetupFailed(); failed { t.Error("a GitHub-only failure must be soft (local repo is fine)") } - clean := CreateResult{Phases: []pipeline.Phase{{Name: "Setup", Steps: []pipeline.StepResult{{Status: pipeline.StepDone}}}}} + clean := CreateResult{Phases: []progress.Phase{{Name: "Setup", Steps: []progress.StepResult{{Status: progress.StepDone}}}}} if failed, _ := clean.SetupFailed(); failed { t.Error("a clean result must not report a setup failure") } diff --git a/internal/repo/e2e_test.go b/internal/repo/e2e_test.go index 0a44b55..7d0dc6f 100644 --- a/internal/repo/e2e_test.go +++ b/internal/repo/e2e_test.go @@ -8,7 +8,7 @@ import ( "testing" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/testtmp" "github.com/abiswas97/sentei/internal/testutil/mock" ) @@ -18,7 +18,7 @@ func TestE2E_CreateRepo(t *testing.T) { runner := &git.GitRunner{} shell := &git.DefaultShellRunner{} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} opts := CreateOptions{ Name: "test-repo", Location: dir, @@ -55,7 +55,7 @@ func TestE2E_CreateRepo(t *testing.T) { // No failures for _, phase := range result.Phases { for _, step := range phase.Steps { - if step.Status == pipeline.StepFailed { + if step.Status == progress.StepFailed { t.Errorf("step %q failed: %v", step.Name, step.Error) } } @@ -76,7 +76,7 @@ func TestE2E_CloneRepo(t *testing.T) { // Clone it cloneDir := testtmp.RobustTempDir(t) - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} opts := CloneOptions{ URL: sourceDir, // local path works as URL for git clone Location: cloneDir, @@ -122,13 +122,13 @@ func TestE2E_CloneNonMainDefaultSetsUpstream(t *testing.T) { runner.Run(sourceDir, "-c", "user.email=test@test.com", "-c", "user.name=Test", "commit", "-m", "init") cloneDir := testtmp.RobustTempDir(t) - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} opts := CloneOptions{URL: sourceDir, Location: cloneDir, Name: "cloned"} result := Clone(runner, opts, ec.Emit) for _, phase := range result.Phases { for _, step := range phase.Steps { - if step.Status == pipeline.StepFailed { + if step.Status == progress.StepFailed { t.Errorf("step %q failed: %v", step.Name, step.Error) } } @@ -164,7 +164,7 @@ func TestE2E_MigrateRepo(t *testing.T) { runner.Run(repoPath, "add", "file.txt") runner.Run(repoPath, "-c", "user.email=test@test.com", "-c", "user.name=Test", "commit", "-m", "init") - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} opts := MigrateOptions{RepoPath: repoPath} result := Migrate(runner, shell, opts, ec.Emit) diff --git a/internal/repo/migrate.go b/internal/repo/migrate.go index 9913876..1e806f9 100644 --- a/internal/repo/migrate.go +++ b/internal/repo/migrate.go @@ -1,17 +1,14 @@ package repo import ( - "errors" "fmt" "io/fs" "os" "path/filepath" - "strings" - "time" "github.com/abiswas97/sentei/internal/fileutil" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" ) type MigrateOptions struct { @@ -25,246 +22,17 @@ type MigrateResult struct { BackupSize string Branch string IsDirty bool - Phases []pipeline.Phase + Phases []progress.Phase + Err error } -func Migrate(runner git.CommandRunner, shell git.ShellRunner, opts MigrateOptions, emit func(pipeline.Event)) MigrateResult { - result := MigrateResult{BareRoot: opts.RepoPath} - - // Phase 1: Validate - validatePhase, branch, isDirty := runMigrateValidate(runner, opts.RepoPath, emit) - result.Phases = append(result.Phases, validatePhase) - result.Branch = branch - result.IsDirty = isDirty - if validatePhase.HasFailures() { - return result - } - - // Phase 2: Backup - backupPhase, backupPath, backupSize := runMigrateBackup(shell, opts.RepoPath, emit) - result.Phases = append(result.Phases, backupPhase) - result.BackupPath = backupPath - result.BackupSize = backupSize - if backupPhase.HasFailures() { - return result - } - - // Phase 3: Migrate - migratePhase := runMigrateBare(runner, opts.RepoPath, branch, emit) - result.Phases = append(result.Phases, migratePhase) - if migratePhase.HasFailures() { - return result - } - result.WorktreePath = git.WorktreePath(opts.RepoPath, branch) - - // Phase 4: Copy (best-effort) - copyPhase := runMigrateCopy(backupPath, result.WorktreePath, emit) - result.Phases = append(result.Phases, copyPhase) - - return result -} - -func runMigrateValidate(runner git.CommandRunner, repoPath string, emit func(pipeline.Event)) (pipeline.Phase, string, bool) { - rec := pipeline.NewPhaseRecorder("Validate", emit) - - isDirty := false - ok := rec.Step("Check repository status", func() (string, error) { - statusOutput, err := runner.Run(repoPath, "status", "--porcelain") - if err != nil { - return "", err - } - isDirty = strings.TrimSpace(statusOutput) != "" - if isDirty { - return "uncommitted changes detected", nil - } - return "clean", nil - }) - if !ok { - return rec.Phase(), "", false - } - - var branch string - rec.Step("Detect current branch", func() (string, error) { - var err error - branch, err = runner.Run(repoPath, "branch", "--show-current") - if err != nil { - return "", err - } - // A detached HEAD yields an empty branch name. Reject it now, before any - // destructive phase: otherwise the root is gutted and `worktree add ""` fails, - // leaving an empty directory with no recovery path. - if strings.TrimSpace(branch) == "" { - return "", errors.New("cannot migrate a detached HEAD; check out a branch first") - } - return branch, nil - }) - - return rec.Phase(), branch, isDirty -} - -func runMigrateBackup(shell git.ShellRunner, repoPath string, emit func(pipeline.Event)) (pipeline.Phase, string, string) { - rec := pipeline.NewPhaseRecorder("Backup", emit) - - timestamp := time.Now().Format("20060102_150405") - backupPath := fmt.Sprintf("%s_backup_%s", repoPath, timestamp) - - ok := rec.Step("Copy repository to backup", func() (string, error) { - cpCmd := fmt.Sprintf("cp -a %q %q", repoPath, backupPath) - if _, err := shell.RunShell(filepath.Dir(repoPath), cpCmd); err != nil { - // cp -a may have left a partial copy; remove it. Otherwise the failure - // screen would tell the user to `rm -rf && mv - // ` — deleting their still-intact repo (the Migrate phase never - // ran). - _ = fileutil.RemoveAllRetry(backupPath) - return "", err - } - return backupPath, nil - }) - if !ok { - // Report NO backup (not the constructed path). - return rec.Phase(), "", "" - } - - var size string - rec.Step("Calculate backup size", func() (string, error) { - size = calculateDirSize(backupPath) - return size, nil - }) - - return rec.Phase(), backupPath, size -} - -func runMigrateBare(runner git.CommandRunner, repoPath, branch string, emit func(pipeline.Event)) pipeline.Phase { - rec := pipeline.NewPhaseRecorder("Migrate", emit) - barePath := filepath.Join(repoPath, ".bare") - - // Capture the real origin URL before cloning. `git clone --bare .git .bare` - // rewrites origin to the local .git path, which we then delete; without this - // the migrated repo's origin points at a dead path and push/pull is severed. - originURL, _ := runner.Run(repoPath, "remote", "get-url", "origin") - - ok := rec.Step("Create bare repository", func() (string, error) { - _, err := runner.Run(repoPath, "clone", "--bare", ".git", barePath) - return "", err - }) - if !ok { - return rec.Phase() - } - - // Retry: .git was just read by clone --bare, so on macOS Spotlight may - // briefly hold its object dir (ENOTEMPTY) — a single RemoveAll would fail - // the migration after the backup was already taken. - ok = rec.Step("Remove original .git", func() (string, error) { - return "", fileutil.RemoveAllRetry(filepath.Join(repoPath, ".git")) - }) - if !ok { - return rec.Phase() - } - - ok = rec.Step("Create .git pointer", func() (string, error) { - return "", os.WriteFile(filepath.Join(repoPath, ".git"), []byte("gitdir: .bare\n"), 0644) - }) - if !ok { - return rec.Phase() - } - - ok = rec.Step("Configure refspec", func() (string, error) { - _, err := runner.Run(barePath, "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*") - return "", err - }) - if !ok { - return rec.Phase() - } - - // Restore the real origin URL (clone --bare set it to the local .git path). - // Best-effort: a local-only repo has no origin to restore. - if strings.TrimSpace(originURL) != "" { - ok = rec.Step("Restore origin remote", func() (string, error) { - if _, err := runner.Run(barePath, "remote", "set-url", "origin", originURL); err != nil { - return "", err - } - return originURL, nil - }) - if !ok { - return rec.Phase() - } - } - - // Remove old working files from root (they'll be in the worktree instead). - // Keep only .bare and the .git pointer. - ok = rec.Step("Clean root directory", func() (string, error) { - entries, err := os.ReadDir(repoPath) - if err != nil { - return "", err - } - for _, entry := range entries { - name := entry.Name() - if name == ".bare" || name == ".git" { - continue - } - if err := os.RemoveAll(filepath.Join(repoPath, name)); err != nil { - rec.Emit("Clean root directory", pipeline.StepRunning, - fmt.Sprintf("warning: could not remove %s: %v", name, err)) - } - } - return "", nil - }) - if !ok { - return rec.Phase() - } - - // Create worktree for current branch. The branch is passed explicitly as - // the commit-ish: without it, git derives a NEW branch from the path's - // basename instead of checking out the existing one. - rec.Step("Create worktree", func() (string, error) { - _, err := runner.Run(repoPath, "worktree", "add", git.WorktreePath(repoPath, branch), branch) - return "", err - }) - - return rec.Phase() -} - -func runMigrateCopy(backupPath, worktreePath string, emit func(pipeline.Event)) pipeline.Phase { - rec := pipeline.NewPhaseRecorder("Copy", emit) - - // Copy EVERYTHING from the backup working tree (untracked, ignored, and - // uncommitted-modified files) into the new worktree, which otherwise holds - // only committed content. This makes the worktree a faithful copy so the - // backup is genuinely redundant and safe to delete. Skip git internals. - rec.Step("Restore working files", func() (string, error) { - entries, err := os.ReadDir(backupPath) - if err != nil { - // No backup to restore from (e.g. a clean repo with nothing extra). - return "nothing to restore", nil - } - - copied := 0 - for _, entry := range entries { - name := entry.Name() - if name == ".git" || name == ".bare" { - continue - } - if copyErr := copyTree(filepath.Join(backupPath, name), filepath.Join(worktreePath, name)); copyErr != nil { - rec.Emit("Restore working files", pipeline.StepRunning, - fmt.Sprintf("warning: could not copy %s: %v", name, copyErr)) - continue - } - copied++ - } - - if copied == 0 { - return "nothing to restore", nil - } - return fmt.Sprintf("%d items restored", copied), nil - }) - - return rec.Phase() +func Migrate(runner git.CommandRunner, shell git.ShellRunner, opts MigrateOptions, emit func(progress.Event)) MigrateResult { + return prepareMigrate(runner, shell, opts).run(emit) } // copyTree recursively copies src to dst. It recreates symlinks rather than // following them, and replaces an existing dst rather than writing through it, -// so restoring over a checked-out symlink cannot corrupt the link's target -// (which may live outside the worktree). +// so restoring over a checked-out symlink cannot corrupt the link's target. func copyTree(src, dst string) error { info, err := os.Lstat(src) if err != nil { @@ -288,15 +56,14 @@ func copyTree(src, dst string) error { if err != nil { return err } - for _, e := range entries { - if err := copyTree(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil { + for _, entry := range entries { + if err := copyTree(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil { return err } } return nil default: - // Replace dst instead of writing through an existing file/symlink. _ = os.Remove(dst) return fileutil.CopyFile(src, dst) } @@ -304,12 +71,12 @@ func copyTree(src, dst string) error { func calculateDirSize(path string) string { var totalSize int64 - _ = filepath.WalkDir(path, func(_ string, d fs.DirEntry, err error) error { + _ = filepath.WalkDir(path, func(_ string, entry fs.DirEntry, err error) error { if err != nil { return nil } - if !d.IsDir() { - info, err := d.Info() + if !entry.IsDir() { + info, err := entry.Info() if err == nil { totalSize += info.Size() } @@ -337,16 +104,10 @@ func formatSize(bytes int64) string { } } -// DeleteBackup removes the backup directory. func DeleteBackup(backupPath string) error { return os.RemoveAll(backupPath) } -// RestoreCommand returns the shell command that undoes a migration by restoring -// the pre-migration backup over the repo root. Single source of truth for the -// CLI and TUI failure screens. func (r MigrateResult) RestoreCommand() string { - // Quote operands: a repo path with spaces would otherwise make `rm -rf` - // delete the wrong directories. return fmt.Sprintf("rm -rf %q && mv %q %q", r.BareRoot, r.BackupPath, r.BareRoot) } diff --git a/internal/repo/migrate_execution_test.go b/internal/repo/migrate_execution_test.go new file mode 100644 index 0000000..e8daf20 --- /dev/null +++ b/internal/repo/migrate_execution_test.go @@ -0,0 +1,136 @@ +package repo + +import ( + "errors" + "testing" + + "github.com/abiswas97/sentei/internal/progress" + "github.com/abiswas97/sentei/internal/testutil/mock" +) + +func TestPrepareMigrate_FreezesOriginBeforeExecution(t *testing.T) { + runner := &mock.Runner{Responses: map[string]mock.Response{ + "/repo:[remote get-url origin]": {Output: "git@example/repo.git"}, + }} + prepared := prepareMigrate(runner, runner, MigrateOptions{RepoPath: "/repo"}) + if prepared.err != nil { + t.Fatal(prepared.err) + } + found := false + for _, operation := range prepared.operations { + found = found || operation.stepID == "restore-origin" + } + if !found { + t.Fatal("actual origin did not freeze restore operation") + } + if len(runner.Calls) != 1 || runner.Calls[0] != "/repo:[remote get-url origin]" { + t.Fatalf("preflight calls = %v", runner.Calls) + } +} + +func TestPrepareMigrate_DistinguishesNoOriginFromLookupFailure(t *testing.T) { + t.Run("verified no origin", func(t *testing.T) { + runner := &mock.Runner{Responses: map[string]mock.Response{ + "/repo:[remote get-url origin]": {Err: errors.New("git remote get-url origin: error: No such remote 'origin'")}, + }} + prepared := prepareMigrate(runner, runner, MigrateOptions{RepoPath: "/repo"}) + if prepared.err != nil { + t.Fatalf("err = %v", prepared.err) + } + for _, operation := range prepared.operations { + if operation.stepID == "restore-origin" { + t.Fatal("restore-origin declared without an origin") + } + } + }) + + t.Run("lookup infrastructure failure", func(t *testing.T) { + lookupErr := errors.New("permission denied reading git config") + runner := &mock.Runner{Responses: map[string]mock.Response{ + "/repo:[remote get-url origin]": {Err: lookupErr}, + }} + prepared := prepareMigrate(runner, runner, MigrateOptions{RepoPath: "/repo"}) + result := prepared.run(func(progress.Event) {}) + if !errors.Is(result.Err, lookupErr) { + t.Fatalf("Err = %v, want wrapped lookup failure", result.Err) + } + if len(result.Phases) != 0 || len(runner.Calls) != 1 { + t.Fatalf("destructive work started: phases=%#v calls=%v", result.Phases, runner.Calls) + } + }) +} + +func TestPreparedMigrate_FailurePolicyAndBackupInformation(t *testing.T) { + runner := &mock.Runner{Responses: map[string]mock.Response{"/repo:[remote get-url origin]": {}}} + base := prepareMigrate(runner, runner, MigrateOptions{RepoPath: "/repo"}) + for failedAt := range base.operations { + t.Run(base.operations[failedAt].label, func(t *testing.T) { + prepared := base + backupPath := "/repo_backup_20260715_120000" + prepared.backupPath = &backupPath + prepared.operations = append([]migrateOperation(nil), base.operations...) + for i := range prepared.operations { + prepared.operations[i].run = func(*progress.Execution) (string, error) { return "", nil } + } + prepared.operations[failedAt].run = func(*progress.Execution) (string, error) { return "", errors.New("injected") } + result := prepared.run(func(progress.Event) {}) + if result.Err != nil { + t.Fatalf("Err = %v", result.Err) + } + failed := prepared.operations[failedAt] + if step := resultStepByID(t, result.Phases, failed.phaseID, failed.stepID); step.Status != progress.StepFailed { + t.Fatalf("failed result = %#v", step) + } + for _, later := range prepared.operations[failedAt+1:] { + if step := resultStepByID(t, result.Phases, later.phaseID, later.stepID); step.Status != progress.StepSkipped { + t.Fatalf("later %s = %#v", later.label, step) + } + } + backupExpected := failedAt > prepared.backupCopyIndex + if (result.BackupPath != "") != backupExpected { + t.Fatalf("BackupPath=%q failedAt=%d backupIndex=%d", result.BackupPath, failedAt, prepared.backupCopyIndex) + } + }) + } +} + +func TestPreparedMigrate_CallbackPanicPopulatesErr(t *testing.T) { + want := errors.New("delivery") + runner := &mock.Runner{Responses: map[string]mock.Response{"/repo:[remote get-url origin]": {}}} + result := prepareMigrate(runner, runner, MigrateOptions{RepoPath: "/repo"}).run(func(progress.Event) { panic(want) }) + if !errors.Is(result.Err, want) || len(result.Phases) != 0 { + t.Fatalf("result = %#v", result) + } +} + +func TestPreparedMigrate_BackupPathSurvivesDoneDeliveryPanic(t *testing.T) { + want := errors.New("delivery") + runner := &mock.Runner{Responses: map[string]mock.Response{"/repo:[remote get-url origin]": {}}} + prepared := prepareMigrate(runner, runner, MigrateOptions{RepoPath: "/repo"}) + *prepared.backupPath = "/repo_backup_20260715_120000" + for i := range prepared.operations { + prepared.operations[i].run = func(*progress.Execution) (string, error) { return "", nil } + } + result := prepared.run(func(event progress.Event) { + if event.Phase == "migrate:backup" && event.Step == "copy" && event.Status == progress.StepDone { + panic(want) + } + }) + if !errors.Is(result.Err, want) { + t.Fatalf("Err = %v", result.Err) + } + if result.BackupPath != *prepared.backupPath { + t.Fatalf("BackupPath = %q, want %q", result.BackupPath, *prepared.backupPath) + } +} + +func TestPreparedMigrate_ResultMatchesCompletedStream(t *testing.T) { + runner := &mock.Runner{Responses: map[string]mock.Response{"/repo:[remote get-url origin]": {}}} + prepared := prepareMigrate(runner, runner, MigrateOptions{RepoPath: "/repo"}) + for i := range prepared.operations { + prepared.operations[i].run = func(*progress.Execution) (string, error) { return "", nil } + } + var events []progress.Event + result := prepared.run(func(event progress.Event) { events = append(events, event) }) + assertRepoStreamParity(t, events, result.Phases) +} diff --git a/internal/repo/migrate_prepared.go b/internal/repo/migrate_prepared.go new file mode 100644 index 0000000..313e35a --- /dev/null +++ b/internal/repo/migrate_prepared.go @@ -0,0 +1,280 @@ +package repo + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/abiswas97/sentei/internal/fileutil" + "github.com/abiswas97/sentei/internal/git" + "github.com/abiswas97/sentei/internal/progress" +) + +type migrateOperationKind uint8 + +const ( + migrateRegular migrateOperationKind = iota + migrateStatus + migrateBranch + migrateBackupCopy + migrateBackupSize + migrateWorktree +) + +type migrateOperation struct { + phaseID progress.PhaseID + stepID progress.StepID + label string + kind migrateOperationKind + run func(*progress.Execution) (string, error) +} + +type preparedMigrate struct { + result MigrateResult + plan progress.Plan + operations []migrateOperation + backupCopyIndex int + backupPath *string + branch *string + isDirty *bool + err error +} + +func prepareMigrate(runner git.CommandRunner, _ git.ShellRunner, opts MigrateOptions) preparedMigrate { + repoPath := opts.RepoPath + barePath := filepath.Join(repoPath, ".bare") + originURL, originErr := runner.Run(repoPath, "remote", "get-url", "origin") + preferredBackupPath := fmt.Sprintf("%s_backup_%s", repoPath, time.Now().Format("20060102_150405")) + backupPath := new(string) + branch := "" + isDirty := false + prepared := preparedMigrate{ + result: MigrateResult{BareRoot: repoPath}, backupPath: backupPath, + branch: &branch, isDirty: &isDirty, backupCopyIndex: -1, + } + if originErr != nil && !isMissingOrigin(originErr) { + prepared.err = fmt.Errorf("reading origin remote before migration: %w", originErr) + } + add := func(phaseID, phaseLabel, stepID, label string, kind migrateOperationKind, run func(*progress.Execution) (string, error)) { + if len(prepared.plan.Phases) == 0 || prepared.plan.Phases[len(prepared.plan.Phases)-1].ID != phaseID { + prepared.plan.Phases = append(prepared.plan.Phases, progress.PlannedPhase{ID: phaseID, Label: phaseLabel}) + } + phase := &prepared.plan.Phases[len(prepared.plan.Phases)-1] + phase.Steps = append(phase.Steps, progress.PlannedStep{ID: stepID, Label: label}) + prepared.operations = append(prepared.operations, migrateOperation{phaseID: phaseID, stepID: stepID, label: label, kind: kind, run: run}) + } + add("migrate:validate", "Validate", "status", "Check repository status", migrateStatus, func(*progress.Execution) (string, error) { + output, err := runner.Run(repoPath, "status", "--porcelain") + if err != nil { + return "", err + } + isDirty = strings.TrimSpace(output) != "" + if isDirty { + return "uncommitted changes detected", nil + } + return "clean", nil + }) + add("migrate:validate", "Validate", "branch", "Detect current branch", migrateBranch, func(*progress.Execution) (string, error) { + var err error + branch, err = runner.Run(repoPath, "branch", "--show-current") + if err != nil { + return "", err + } + if strings.TrimSpace(branch) == "" { + return "", errors.New("cannot migrate a detached HEAD; check out a branch first") + } + return branch, nil + }) + add("migrate:backup", "Backup", "copy", "Copy repository to backup", migrateBackupCopy, func(*progress.Execution) (string, error) { + path, err := copyRepositoryBackup(repoPath, preferredBackupPath, copyRepositoryContents) + if err != nil { + return "", err + } + *backupPath = path + return path, nil + }) + prepared.backupCopyIndex = len(prepared.operations) - 1 + add("migrate:backup", "Backup", "size", "Calculate backup size", migrateBackupSize, func(*progress.Execution) (string, error) { + return calculateDirSize(*backupPath), nil + }) + add("migrate:convert", "Migrate", "bare-repository", "Create bare repository", migrateRegular, func(*progress.Execution) (string, error) { + _, err := runner.Run(repoPath, "clone", "--bare", ".git", barePath) + return "", err + }) + add("migrate:convert", "Migrate", "remove-git", "Remove original .git", migrateRegular, func(*progress.Execution) (string, error) { + return "", fileutil.RemoveAllRetry(filepath.Join(repoPath, ".git")) + }) + add("migrate:convert", "Migrate", "git-pointer", "Create .git pointer", migrateRegular, func(*progress.Execution) (string, error) { + return "", os.WriteFile(filepath.Join(repoPath, ".git"), []byte("gitdir: .bare\n"), 0644) + }) + add("migrate:convert", "Migrate", "refspec", "Configure refspec", migrateRegular, func(*progress.Execution) (string, error) { + _, err := runner.Run(barePath, "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*") + return "", err + }) + if strings.TrimSpace(originURL) != "" { + add("migrate:convert", "Migrate", "restore-origin", "Restore origin remote", migrateRegular, func(*progress.Execution) (string, error) { + _, err := runner.Run(barePath, "remote", "set-url", "origin", originURL) + return originURL, err + }) + } + add("migrate:convert", "Migrate", "clean-root", "Clean root directory", migrateRegular, func(execution *progress.Execution) (string, error) { + entries, err := os.ReadDir(repoPath) + if err != nil { + return "", err + } + for _, entry := range entries { + name := entry.Name() + if name == ".bare" || name == ".git" { + continue + } + if err := os.RemoveAll(filepath.Join(repoPath, name)); err != nil { + _ = execution.Running("migrate:convert", "clean-root", 0, fmt.Sprintf("warning: could not remove %s: %v", name, err)) + } + } + return "", nil + }) + add("migrate:convert", "Migrate", "worktree", "Create worktree", migrateWorktree, func(*progress.Execution) (string, error) { + _, err := runner.Run(repoPath, "worktree", "add", git.WorktreePath(repoPath, branch), branch) + return "", err + }) + add("migrate:copy", "Copy", "restore-files", "Restore working files", migrateRegular, func(execution *progress.Execution) (string, error) { + return restoreWorkingFiles(*backupPath, git.WorktreePath(repoPath, branch), copyTree, func(message string) { + _ = execution.Running("migrate:copy", "restore-files", 0, message) + }) + }) + return prepared +} + +func copyRepositoryBackup(source, preferredPath string, copyFn func(string, string) error) (string, error) { + backupPath := preferredPath + for suffix := 0; ; suffix++ { + if suffix > 0 { + backupPath = fmt.Sprintf("%s_%d", preferredPath, suffix) + } + err := os.Mkdir(backupPath, 0700) + if err == nil { + break + } + if errors.Is(err, os.ErrExist) { + continue + } + return "", fmt.Errorf("reserve migration backup %q: %w", backupPath, err) + } + + cleanupFailure := func(operationErr error) (string, error) { + if cleanupErr := fileutil.RemoveAllRetry(backupPath); cleanupErr != nil { + return "", errors.Join(operationErr, fmt.Errorf("clean partial migration backup %q: %w", backupPath, cleanupErr)) + } + return "", operationErr + } + if err := copyFn(source, backupPath); err != nil { + return cleanupFailure(fmt.Errorf("copy repository to migration backup %q: %w", backupPath, err)) + } + if err := os.Chmod(backupPath, 0700); err != nil { + return cleanupFailure(fmt.Errorf("secure migration backup %q: %w", backupPath, err)) + } + return backupPath, nil +} + +func copyRepositoryContents(source, destination string) error { + separator := string(os.PathSeparator) + command := exec.Command("cp", "-a", source+separator+".", destination+separator) + output, err := command.CombinedOutput() + if err == nil { + return nil + } + if detail := strings.TrimSpace(string(output)); detail != "" { + return fmt.Errorf("cp -a: %s: %w", detail, err) + } + return fmt.Errorf("cp -a: %w", err) +} + +// restoreWorkingFiles attempts every backup entry so one bad file does not +// hide later failures, but returns the aggregate error so callers preserve the +// backup instead of treating a partial restore as a successful migration. +func restoreWorkingFiles(backupPath, targetPath string, copyFn func(string, string) error, warn func(string)) (string, error) { + entries, err := os.ReadDir(backupPath) + if err != nil { + return "nothing restored", fmt.Errorf("read migration backup: %w", err) + } + copied := 0 + var restoreErr error + for _, entry := range entries { + name := entry.Name() + if name == ".git" || name == ".bare" { + continue + } + if err := copyFn(filepath.Join(backupPath, name), filepath.Join(targetPath, name)); err != nil { + warn(fmt.Sprintf("warning: could not copy %s: %v", name, err)) + restoreErr = errors.Join(restoreErr, fmt.Errorf("copy %s from migration backup: %w", name, err)) + continue + } + copied++ + } + message := "nothing to restore" + if copied > 0 { + message = fmt.Sprintf("%d items restored", copied) + } + return message, restoreErr +} + +func (p preparedMigrate) run(emit func(progress.Event)) MigrateResult { + result := p.result + if p.err != nil { + result.Err = p.err + return result + } + execution, err := progress.Start(p.plan, emit) + if err != nil { + result.Err = fmt.Errorf("starting repository migration: %w", err) + return result + } + failedBy := "" + for index, operation := range p.operations { + if failedBy != "" { + _, err = execution.Skip(operation.phaseID, operation.stepID, "blocked by "+failedBy) + } else { + var step progress.StepResult + step, err = execution.Run(operation.phaseID, operation.stepID, func() (string, error) { return operation.run(execution) }) + if operation.kind == migrateBackupCopy && step.Status == progress.StepDone { + result.BackupPath = *p.backupPath + } + if err == nil && step.Status == progress.StepFailed { + failedBy = operation.label + } + } + if err != nil { + result.Err = errors.Join(result.Err, fmt.Errorf("executing %s: %w", operation.label, err)) + failedBy = operation.label + } + if failedBy != "" { + continue + } + switch operation.kind { + case migrateStatus: + result.IsDirty = *p.isDirty + case migrateBranch: + result.Branch = *p.branch + case migrateBackupCopy: + result.BackupPath = *p.backupPath + case migrateBackupSize: + result.BackupSize = calculateDirSize(*p.backupPath) + case migrateWorktree: + result.WorktreePath = git.WorktreePath(result.BareRoot, result.Branch) + } + _ = index + } + finishErr := execution.Finish("repository migration finished") + result.Phases = execution.Phases() + result.Err = errors.Join(result.Err, finishErr) + return result +} + +func isMissingOrigin(err error) bool { + message := strings.ToLower(err.Error()) + return strings.Contains(message, "no such remote") && strings.Contains(message, "origin") +} diff --git a/internal/repo/migrate_test.go b/internal/repo/migrate_test.go index 6659b11..becbfad 100644 --- a/internal/repo/migrate_test.go +++ b/internal/repo/migrate_test.go @@ -7,18 +7,129 @@ import ( "strings" "testing" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/testutil/mock" ) -// alwaysOkShell is a ShellRunner that succeeds for all calls — used to -// satisfy the backup phase without needing to predict the timestamp-based path. +// alwaysOkShell is a ShellRunner that succeeds for all calls. type alwaysOkShell struct{} func (s *alwaysOkShell) RunShell(_ string, _ string) (string, error) { return "", nil } +func TestCopyRepositoryBackup_UsesUniqueSiblingWhenPreferredPathExists(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "repo") + preferred := filepath.Join(dir, "repo_backup_20260715_120000") + if err := os.Mkdir(source, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "tracked.txt"), []byte("repository"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(preferred, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(preferred, "owner.txt"), []byte("existing"), 0644); err != nil { + t.Fatal(err) + } + + backupPath, err := copyRepositoryBackup(source, preferred, copyTree) + if err != nil { + t.Fatal(err) + } + if backupPath == preferred { + t.Fatalf("backupPath = existing destination %q", backupPath) + } + if got, err := os.ReadFile(filepath.Join(preferred, "owner.txt")); err != nil || string(got) != "existing" { + t.Fatalf("existing destination changed: content=%q err=%v", got, err) + } + if got, err := os.ReadFile(filepath.Join(backupPath, "tracked.txt")); err != nil || string(got) != "repository" { + t.Fatalf("backup content=%q err=%v", got, err) + } +} + +func TestCopyRepositoryBackup_FailureCleansOnlyReservedDestination(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "repo") + preferred := filepath.Join(dir, "repo_backup_20260715_120000") + if err := os.Mkdir(source, 0755); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(preferred, 0755); err != nil { + t.Fatal(err) + } + ownerFile := filepath.Join(preferred, "owner.txt") + if err := os.WriteFile(ownerFile, []byte("existing"), 0644); err != nil { + t.Fatal(err) + } + + backupPath, err := copyRepositoryBackup(source, preferred, func(_, dst string) error { + if writeErr := os.WriteFile(filepath.Join(dst, "partial"), []byte("partial"), 0644); writeErr != nil { + return writeErr + } + return fmt.Errorf("disk full") + }) + if err == nil || !strings.Contains(err.Error(), "disk full") { + t.Fatalf("err = %v, want copy failure", err) + } + if backupPath != "" { + t.Fatalf("backupPath = %q, want empty on failure", backupPath) + } + if got, readErr := os.ReadFile(ownerFile); readErr != nil || string(got) != "existing" { + t.Fatalf("existing destination changed: content=%q err=%v", got, readErr) + } + if _, statErr := os.Stat(preferred + "_1"); !os.IsNotExist(statErr) { + t.Fatalf("reserved failed destination remains: %v", statErr) + } +} + +func TestCopyRepositoryBackup_TreatsShellMetacharactersAsLiteralPath(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "repo$(touch injected)`touch injected2`") + if err := os.Mkdir(source, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "tracked.txt"), []byte("repository"), 0644); err != nil { + t.Fatal(err) + } + + backupPath, err := copyRepositoryBackup(source, source+"_backup_20260715_120000", copyRepositoryContents) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(dir, "injected")); !os.IsNotExist(err) { + t.Fatalf("$() path content executed: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "injected2")); !os.IsNotExist(err) { + t.Fatalf("backtick path content executed: %v", err) + } + if got, err := os.ReadFile(filepath.Join(backupPath, "tracked.txt")); err != nil || string(got) != "repository" { + t.Fatalf("backup content=%q err=%v", got, err) + } +} + +func TestCopyRepositoryBackup_KeepsBackupRootPrivate(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "repo") + if err := os.Mkdir(source, 0755); err != nil { + t.Fatal(err) + } + + backupPath, err := copyRepositoryBackup(source, source+"_backup_20260715_120000", copyRepositoryContents) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(backupPath) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0700 { + t.Fatalf("backup root mode = %04o, want 0700", got) + } +} + func TestMigrate_Successful(t *testing.T) { dir := t.TempDir() repoPath := filepath.Join(dir, "my-project") @@ -26,6 +137,7 @@ func TestMigrate_Successful(t *testing.T) { barePath := filepath.Join(repoPath, ".bare") runner := &mock.Runner{Responses: map[string]mock.Response{ + fmt.Sprintf("%s:[remote get-url origin]", repoPath): {Err: fmt.Errorf("error: No such remote 'origin'")}, // Validate fmt.Sprintf("%s:[status --porcelain]", repoPath): {Output: ""}, fmt.Sprintf("%s:[branch --show-current]", repoPath): {Output: "main"}, @@ -35,7 +147,7 @@ func TestMigrate_Successful(t *testing.T) { fmt.Sprintf("%s:[worktree add %s/main main]", repoPath, repoPath): {Output: ""}, }} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} opts := MigrateOptions{RepoPath: repoPath} result := Migrate(runner, &alwaysOkShell{}, opts, ec.Emit) @@ -55,7 +167,7 @@ func TestMigrate_Successful(t *testing.T) { // No phase failures for _, phase := range result.Phases { for _, step := range phase.Steps { - if step.Status == pipeline.StepFailed { + if step.Status == progress.StepFailed { t.Errorf("step %q failed: %v", step.Name, step.Error) } } @@ -69,6 +181,7 @@ func TestMigrate_DirtyRepo_WarningContinues(t *testing.T) { barePath := filepath.Join(repoPath, ".bare") runner := &mock.Runner{Responses: map[string]mock.Response{ + fmt.Sprintf("%s:[remote get-url origin]", repoPath): {Err: fmt.Errorf("error: No such remote 'origin'")}, fmt.Sprintf("%s:[status --porcelain]", repoPath): {Output: "M file.txt"}, fmt.Sprintf("%s:[branch --show-current]", repoPath): {Output: "develop"}, fmt.Sprintf("%s:[clone --bare .git %s]", repoPath, barePath): {Output: ""}, @@ -76,14 +189,14 @@ func TestMigrate_DirtyRepo_WarningContinues(t *testing.T) { fmt.Sprintf("%s:[worktree add %s/develop develop]", repoPath, repoPath): {Output: ""}, }} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} opts := MigrateOptions{RepoPath: repoPath} result := Migrate(runner, &alwaysOkShell{}, opts, ec.Emit) // Should still succeed — dirty is a warning, not a failure for _, phase := range result.Phases { for _, step := range phase.Steps { - if step.Status == pipeline.StepFailed { + if step.Status == progress.StepFailed { t.Errorf("step %q failed: %v", step.Name, step.Error) } } @@ -109,6 +222,7 @@ func TestMigrate_CloneFailure_ShowsRollbackInfo(t *testing.T) { barePath := filepath.Join(repoPath, ".bare") runner := &mock.Runner{Responses: map[string]mock.Response{ + fmt.Sprintf("%s:[remote get-url origin]", repoPath): {Err: fmt.Errorf("error: No such remote 'origin'")}, fmt.Sprintf("%s:[status --porcelain]", repoPath): {Output: ""}, fmt.Sprintf("%s:[branch --show-current]", repoPath): {Output: "main"}, fmt.Sprintf("%s:[clone --bare .git %s]", repoPath, barePath): { @@ -116,7 +230,7 @@ func TestMigrate_CloneFailure_ShowsRollbackInfo(t *testing.T) { }, }} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} opts := MigrateOptions{RepoPath: repoPath} result := Migrate(runner, &alwaysOkShell{}, opts, ec.Emit) @@ -154,19 +268,28 @@ func TestMigrate_DetachedHead_RejectedBeforeDestruction(t *testing.T) { os.MkdirAll(filepath.Join(repoPath, ".git"), 0755) runner := &mock.Runner{Responses: map[string]mock.Response{ + fmt.Sprintf("%s:[remote get-url origin]", repoPath): {Err: fmt.Errorf("error: No such remote 'origin'")}, fmt.Sprintf("%s:[status --porcelain]", repoPath): {Output: ""}, fmt.Sprintf("%s:[branch --show-current]", repoPath): {Output: ""}, // detached HEAD }} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} result := Migrate(runner, &alwaysOkShell{}, MigrateOptions{RepoPath: repoPath}, ec.Emit) validate := findPhase(result.Phases, "Validate") if validate == nil || !validate.HasFailures() { t.Fatal("detached HEAD must fail validation") } - if findPhase(result.Phases, "Backup") != nil || findPhase(result.Phases, "Migrate") != nil { - t.Error("no destructive phase should run after a validation failure") + for _, phaseName := range []string{"Backup", "Migrate", "Copy"} { + phase := findPhase(result.Phases, phaseName) + if phase == nil { + t.Fatalf("prepared phase %q missing", phaseName) + } + for _, step := range phase.Steps { + if step.Status != progress.StepSkipped { + t.Errorf("%s/%s status = %v, want skipped", phaseName, step.Name, step.Status) + } + } } for _, c := range runner.Calls { if strings.Contains(c, "clone --bare") { @@ -192,12 +315,12 @@ func TestMigrate_PreservesOriginURL(t *testing.T) { fmt.Sprintf("%s:[worktree add %s/main main]", repoPath, repoPath): {Output: ""}, }} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} result := Migrate(runner, &alwaysOkShell{}, MigrateOptions{RepoPath: repoPath}, ec.Emit) for _, phase := range result.Phases { for _, step := range phase.Steps { - if step.Status == pipeline.StepFailed { + if step.Status == progress.StepFailed { t.Errorf("step %q failed: %v", step.Name, step.Error) } } @@ -220,6 +343,7 @@ func TestMigrate_SlashBranch_ChecksOutExistingBranch(t *testing.T) { barePath := filepath.Join(repoPath, ".bare") runner := &mock.Runner{Responses: map[string]mock.Response{ + fmt.Sprintf("%s:[remote get-url origin]", repoPath): {Err: fmt.Errorf("error: No such remote 'origin'")}, fmt.Sprintf("%s:[status --porcelain]", repoPath): {Output: ""}, fmt.Sprintf("%s:[branch --show-current]", repoPath): {Output: "feature/foo"}, fmt.Sprintf("%s:[clone --bare .git %s]", repoPath, barePath): {Output: ""}, @@ -227,12 +351,12 @@ func TestMigrate_SlashBranch_ChecksOutExistingBranch(t *testing.T) { fmt.Sprintf("%s:[worktree add %s/feature-foo feature/foo]", repoPath, repoPath): {Output: ""}, }} - ec := &mock.EventCollector[pipeline.Event]{} + ec := &mock.EventCollector[progress.Event]{} result := Migrate(runner, &alwaysOkShell{}, MigrateOptions{RepoPath: repoPath}, ec.Emit) for _, phase := range result.Phases { for _, step := range phase.Steps { - if step.Status == pipeline.StepFailed { + if step.Status == progress.StepFailed { t.Errorf("step %q failed: %v", step.Name, step.Error) } } @@ -254,7 +378,7 @@ func TestMigrate_SlashBranch_ChecksOutExistingBranch(t *testing.T) { } } -func findPhase(phases []pipeline.Phase, name string) *pipeline.Phase { +func findPhase(phases []progress.Phase, name string) *progress.Phase { for i := range phases { if phases[i].Name == name { return &phases[i] @@ -263,25 +387,23 @@ func findPhase(phases []pipeline.Phase, name string) *pipeline.Phase { return nil } -// failingShell fails every shell command (used to fail the backup cp). -type failingShell struct{} - -func (s *failingShell) RunShell(_ string, _ string) (string, error) { - return "", fmt.Errorf("cp -a: No space left on device") -} - func TestMigrate_BackupFailure_LeavesNoDestructiveRestore(t *testing.T) { dir := t.TempDir() repoPath := filepath.Join(dir, "proj") os.MkdirAll(filepath.Join(repoPath, ".git"), 0755) runner := &mock.Runner{Responses: map[string]mock.Response{ + fmt.Sprintf("%s:[remote get-url origin]", repoPath): {Err: fmt.Errorf("error: No such remote 'origin'")}, fmt.Sprintf("%s:[status --porcelain]", repoPath): {Output: ""}, fmt.Sprintf("%s:[branch --show-current]", repoPath): {Output: "main"}, }} - ec := &mock.EventCollector[pipeline.Event]{} - result := Migrate(runner, &failingShell{}, MigrateOptions{RepoPath: repoPath}, ec.Emit) + ec := &mock.EventCollector[progress.Event]{} + prepared := prepareMigrate(runner, &alwaysOkShell{}, MigrateOptions{RepoPath: repoPath}) + prepared.operations[prepared.backupCopyIndex].run = func(*progress.Execution) (string, error) { + return "", fmt.Errorf("cp -a: No space left on device") + } + result := prepared.run(ec.Emit) backup := findPhase(result.Phases, "Backup") if backup == nil || !backup.HasFailures() { @@ -293,9 +415,15 @@ func TestMigrate_BackupFailure_LeavesNoDestructiveRestore(t *testing.T) { if result.BackupPath != "" { t.Errorf("BackupPath must be empty on backup failure, got %q", result.BackupPath) } - // The Migrate phase must never have run, so the repo root is untouched. - if findPhase(result.Phases, "Migrate") != nil { - t.Error("Migrate phase must not run after a backup failure") + // The Migrate phase is declared up front but every operation remains skipped. + migrate := findPhase(result.Phases, "Migrate") + if migrate == nil { + t.Fatal("prepared Migrate phase missing") + } + for _, step := range migrate.Steps { + if step.Status != progress.StepSkipped { + t.Errorf("Migrate/%s status = %v, want skipped", step.Name, step.Status) + } } } @@ -344,3 +472,50 @@ func TestCopyTree_DoesNotWriteThroughSymlinks(t *testing.T) { t.Error("source symlink should be recreated as a symlink, not dereferenced") } } + +func TestRestoreWorkingFiles_MissingBackupFails(t *testing.T) { + _, err := restoreWorkingFiles( + filepath.Join(t.TempDir(), "missing-backup"), + t.TempDir(), + copyTree, + func(string) {}, + ) + if err == nil || !strings.Contains(err.Error(), "read migration backup") { + t.Fatalf("err = %v, want backup read failure", err) + } +} + +func TestRestoreWorkingFiles_AttemptsAllEntriesAndReturnsCopyFailures(t *testing.T) { + backup := t.TempDir() + target := t.TempDir() + for _, name := range []string{"a", "b", "c"} { + if err := os.WriteFile(filepath.Join(backup, name), []byte(name), 0644); err != nil { + t.Fatal(err) + } + } + want := fmt.Errorf("disk full") + var copied, warnings []string + message, err := restoreWorkingFiles(backup, target, func(src, _ string) error { + name := filepath.Base(src) + copied = append(copied, name) + if name == "b" { + return want + } + return nil + }, func(message string) { + warnings = append(warnings, message) + }) + + if err == nil || !strings.Contains(err.Error(), "copy b") || !strings.Contains(err.Error(), "disk full") { + t.Fatalf("err = %v, want aggregated copy failure", err) + } + if got := strings.Join(copied, ","); got != "a,b,c" { + t.Fatalf("copy attempts = %q, want all entries", got) + } + if message != "2 items restored" { + t.Fatalf("message = %q", message) + } + if len(warnings) != 1 || !strings.Contains(warnings[0], "could not copy b") { + t.Fatalf("warnings = %v", warnings) + } +} diff --git a/internal/repo/repo.go b/internal/repo/repo.go index 75d9f36..91dcf55 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -4,7 +4,7 @@ import ( "path/filepath" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" ) type RepoContext int @@ -54,6 +54,10 @@ func ResolveBareRoot(runner git.CommandRunner, path string) string { return filepath.Dir(commonDir) } -func (r CloneResult) HasFailures() bool { return pipeline.PhasesHaveFailures(r.Phases) } -func (r CreateResult) HasFailures() bool { return pipeline.PhasesHaveFailures(r.Phases) } -func (r MigrateResult) HasFailures() bool { return pipeline.PhasesHaveFailures(r.Phases) } +func (r CloneResult) HasFailures() bool { return r.Err != nil || progress.PhasesHaveFailures(r.Phases) } +func (r CreateResult) HasFailures() bool { + return r.Err != nil || progress.PhasesHaveFailures(r.Phases) +} +func (r MigrateResult) HasFailures() bool { + return r.Err != nil || progress.PhasesHaveFailures(r.Phases) +} diff --git a/internal/testtmp/hermetic_test.go b/internal/testtmp/hermetic_test.go new file mode 100644 index 0000000..101f762 --- /dev/null +++ b/internal/testtmp/hermetic_test.go @@ -0,0 +1,63 @@ +package testtmp + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// The guard for the isolation itself: under HermeticGitEnv, git must not be +// able to see ANY global configuration, even when one demonstrably exists. +// This is the invariant that makes a test path bug fail loudly instead of +// silently committing with (or overwriting) the developer's real identity. +func TestHermeticGitEnv_GitCannotSeeOutsideConfig(t *testing.T) { + // A decoy "global" config proves the void: point an un-hardened git at + // it, confirm it reads; then confirm the hermetic env reads nothing. + decoy := filepath.Join(t.TempDir(), "gitconfig") + if err := os.WriteFile(decoy, []byte("[user]\n\tname = Decoy\n\temail = decoy@example.com\n"), 0o644); err != nil { + t.Fatal(err) + } + + read := func(env []string) string { + cmd := exec.Command("git", "config", "--global", "--get", "user.name") + cmd.Dir = t.TempDir() + cmd.Env = env + out, _ := cmd.CombinedOutput() + return strings.TrimSpace(string(out)) + } + + if got := read(append(os.Environ(), "GIT_CONFIG_GLOBAL="+decoy)); got != "Decoy" { + t.Fatalf("control failed: expected decoy global config to be readable, got %q", got) + } + if got := read(append(HermeticGitEnv(), "HOME="+filepath.Dir(decoy))); got != "" { + t.Fatalf("hermetic git read a global config: %q", got) + } +} + +func TestHermeticGitEnv_CommitsCarryTestIdentity(t *testing.T) { + dir := t.TempDir() + git := func(args ...string) string { + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = HermeticGitEnv() + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %s", args, out) + } + return strings.TrimSpace(string(out)) + } + git("init", "-q") + if err := os.WriteFile(filepath.Join(dir, "f"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + git("add", "f") + git("commit", "-qm", "probe") + + author := git("log", "--format=%an <%ae> %cn <%ce>", "-1") + want := "sentei-test sentei-test " + if author != want { + t.Fatalf("hermetic commit identity = %q, want %q (a real identity leaking in here is the bug class this guards against)", author, want) + } +} diff --git a/internal/testtmp/testtmp.go b/internal/testtmp/testtmp.go index 15bf78b..4ae98a7 100644 --- a/internal/testtmp/testtmp.go +++ b/internal/testtmp/testtmp.go @@ -15,6 +15,25 @@ import ( "testing" ) +// HermeticGitEnv returns the process environment hardened so a git child +// process cannot read or write ANY configuration outside the repository it +// is pointed at: global and system config are voided, and identity comes +// from the environment. Tests must never depend on, or be able to mutate, +// the developer's real git identity or config; a path bug in a test then +// fails loudly instead of silently poisoning the developer's repositories. +// The .invalid TLD (RFC 2606) can never be claimed by a forge account, so a +// leaked identity can never be attributed to a stranger. +func HermeticGitEnv() []string { + return append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_AUTHOR_NAME=sentei-test", + "GIT_AUTHOR_EMAIL=test@sentei.invalid", + "GIT_COMMITTER_NAME=sentei-test", + "GIT_COMMITTER_EMAIL=test@sentei.invalid", + ) +} + // markNoIndex excludes dir's subtree from macOS Spotlight indexing. func markNoIndex(dir string) { _ = os.WriteFile(filepath.Join(dir, ".metadata_never_index"), nil, 0o644) diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 515b0ab..776f765 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -190,11 +190,12 @@ func projectRoot(t *testing.T) string { } } -// runGit executes a git command in the given directory. +// runGit executes a git command in the given directory, hermetically. func runGit(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v failed in %s: %v\n%s", args, dir, err, out) @@ -202,12 +203,13 @@ func runGit(t *testing.T, dir string, args ...string) string { return string(out) } -// runGitEnv executes a git command with extra environment variables. +// runGitEnv executes a git command with extra environment variables on top +// of the hermetic base. func runGitEnv(t *testing.T, dir string, env []string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir - cmd.Env = append(os.Environ(), env...) + cmd.Env = append(testtmp.HermeticGitEnv(), env...) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v failed in %s: %v\n%s", args, dir, err, out) diff --git a/internal/tui/animated_progress_test.go b/internal/tui/animated_progress_test.go index bbd29de..5f8a601 100644 --- a/internal/tui/animated_progress_test.go +++ b/internal/tui/animated_progress_test.go @@ -4,9 +4,10 @@ import ( "strings" "testing" - "charm.land/bubbles/v2/progress" + progressbar "charm.land/bubbles/v2/progress" "github.com/abiswas97/sentei/internal/git" + "github.com/abiswas97/sentei/internal/progress" ) func TestProgressLayout_Overall(t *testing.T) { @@ -15,9 +16,8 @@ func TestProgressLayout_Overall(t *testing.T) { layout ProgressLayout wantDone, wantTotal int }{ - {"override wins", ProgressLayout{OverallDone: 3, OverallTotal: 10, Phases: []phaseDisplay{{done: 1, total: 1}}}, 3, 10}, - {"discovered phases summed", ProgressLayout{Phases: []phaseDisplay{{done: 2, total: 4}, {done: 1, total: 2}}}, 3, 6}, - {"undiscovered phase counts as outstanding", ProgressLayout{Phases: []phaseDisplay{{done: 2, total: 2}, {total: 0}}}, 2, 3}, + {"discovered phases summed", ProgressLayout{Phases: []progress.PhaseState{{Done: 2, Total: 4}, {Done: 1, Total: 2}}}, 3, 6}, + {"undiscovered phase counts as outstanding", ProgressLayout{Phases: []progress.PhaseState{{Done: 2, Total: 2}, {Total: 0}}}, 2, 3}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -33,7 +33,7 @@ func TestProgressFrame_GatedToProgressViews(t *testing.T) { m := NewModel([]git.Worktree{}, nil, "/repo") cmd := m.bar.SetPercent(0.5) - frame, ok := cmd().(progress.FrameMsg) + frame, ok := cmd().(progressbar.FrameMsg) if !ok { t.Fatal("SetPercent must yield a FrameMsg") } diff --git a/internal/tui/chrome_migration_test.go b/internal/tui/chrome_migration_test.go index 89567d4..e81eba2 100644 --- a/internal/tui/chrome_migration_test.go +++ b/internal/tui/chrome_migration_test.go @@ -7,9 +7,9 @@ import ( "github.com/abiswas97/sentei/internal/cleanup" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/integration" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" + "github.com/abiswas97/sentei/internal/worktree" ) func TestViewProgress_NoPurpleBadge_HasChromeAndBar(t *testing.T) { @@ -33,68 +33,67 @@ func TestViewProgress_NoPurpleBadge_HasChromeAndBar(t *testing.T) { func TestBuildRemovalPhases_PruneStaging(t *testing.T) { m := NewModel(nil, nil, "/repo") m.remove.run = newRemovalRun([]git.Worktree{{Path: "/work/a", Branch: "refs/heads/a"}}) + m.remove.run.events = []progress.Event{ + {Phase: worktree.RemovalPhaseID, PhaseLabel: worktree.RemovalPhaseName, Step: "remove-0", StepLabel: "a", Status: progress.StepPending, Of: 2}, + {Phase: cleanupPhaseID, PhaseLabel: "Prune & cleanup", Step: pruneStepID, StepLabel: "Prune worktree metadata", Status: progress.StepPending, Of: 1}, + {Phase: cleanupPhaseID, PhaseLabel: "Prune & cleanup", Step: cleanupStepID, StepLabel: "Repository cleanup", Status: progress.StepPending, Of: 1}, + {Phase: worktree.RemovalPhaseID, PhaseLabel: worktree.RemovalPhaseName, Close: true}, + {Phase: cleanupPhaseID, PhaseLabel: "Prune & cleanup", Close: true}, + } phases := m.buildRemovalPhases() last := phases[len(phases)-1] - if last.name != "Prune & cleanup" || last.total != 0 { + if last.Name != "Prune & cleanup" || last.Total != 2 { t.Errorf("expected pending prune phase before removal completes, got %+v", last) } // Removal finished: prune phase becomes active work. - m.remove.run.statuses["/work/a"] = statusRemoved + m.remove.run.events = append(m.remove.run.events, progress.Event{Phase: worktree.RemovalPhaseID, Step: "remove-0", Status: progress.StepDone}) phases = m.buildRemovalPhases() last = phases[len(phases)-1] - if last.total != 2 || last.done != 0 { + if last.Total != 2 || last.Done != 0 { t.Errorf("expected active 0/2 prune phase after removal completes, got %+v", last) } pruneErr := error(nil) m.remove.run.pruneErr = &pruneErr m.remove.run.cleanupResult = &cleanup.Result{} + m.remove.run.events = append(m.remove.run.events, + progress.Event{Phase: cleanupPhaseID, Step: pruneStepID, Status: progress.StepDone}, + progress.Event{Phase: cleanupPhaseID, Step: cleanupStepID, Status: progress.StepDone}, + ) phases = m.buildRemovalPhases() last = phases[len(phases)-1] - if last.done != 2 || last.failed != 0 { + if last.Done != 2 || last.Failed != 0 { t.Errorf("expected completed prune phase, got %+v", last) } } -func TestBuildIntegrationPhases_PrePopulatesTargets(t *testing.T) { +func TestBuildIntegrationPhases_WaitsForPreparedDeclaration(t *testing.T) { m := NewMenuModel(nil, nil, "/repo", nil, repo.ContextBareRepo) m.integ.targetWorktrees = []string{"/repo/feature-a", "/repo/feature-b"} phases := m.buildIntegrationPhases() - if len(phases) != 2 { - t.Fatalf("expected 2 pre-populated phases, got %d", len(phases)) - } - for _, p := range phases { - if p.total != 0 { - t.Errorf("pre-populated target %q must be pending (total 0), got %d", p.name, p.total) - } - } - - view := stripANSI(m.viewIntegrationProgress()) - for _, want := range []string{"feature-a", "feature-b", "pending"} { - if !strings.Contains(view, want) { - t.Errorf("expected %q visible before events arrive, view:\n%s", want, view) - } + if len(phases) != 0 { + t.Fatalf("expected no determinate phases before preparation, got %d", len(phases)) } } func TestBuildIntegrationPhases_ErrorBakedIntoLabel(t *testing.T) { m := NewMenuModel(nil, nil, "/repo", nil, repo.ContextBareRepo) - m.integ.events = []integration.ManagerEvent{ - {Worktree: "/repo/a", Step: "Install pipx", Status: integration.StatusFailed, Error: errors.New("exit 1")}, + m.integ.events = []progress.Event{ + {Phase: "/repo/a", Step: "Install pipx", Status: progress.StepFailed, Error: errors.New("exit 1")}, } phases := m.buildIntegrationPhases() - if len(phases) != 1 || len(phases[0].steps) != 1 { + if len(phases) != 1 || len(phases[0].Steps) != 1 { t.Fatalf("unexpected phases: %+v", phases) } - if phases[0].steps[0].status != pipeline.StepFailed { - t.Errorf("expected failed step, got %v", phases[0].steps[0].status) + if phases[0].Steps[0].Status != progress.StepFailed { + t.Errorf("expected failed step, got %v", phases[0].Steps[0].Status) } - if !strings.Contains(phases[0].steps[0].name, "exit 1") { - t.Errorf("expected error in step label, got %q", phases[0].steps[0].name) + if !strings.Contains(phases[0].Steps[0].Name, "exit 1") { + t.Errorf("expected error in step label, got %q", phases[0].Steps[0].Name) } } diff --git a/internal/tui/cleanup_e2e_test.go b/internal/tui/cleanup_e2e_test.go index 2f7fbc5..698c2d6 100644 --- a/internal/tui/cleanup_e2e_test.go +++ b/internal/tui/cleanup_e2e_test.go @@ -15,6 +15,7 @@ import ( "github.com/abiswas97/sentei/internal/config" "github.com/abiswas97/sentei/internal/git" "github.com/abiswas97/sentei/internal/repo" + "github.com/abiswas97/sentei/internal/testtmp" "github.com/abiswas97/sentei/internal/worktree" ) @@ -85,6 +86,7 @@ func runGitCmd(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v failed in %s: %v\n%s", args, dir, err, out) diff --git a/internal/tui/confirm.go b/internal/tui/confirm.go index a32391b..fdb1109 100644 --- a/internal/tui/confirm.go +++ b/internal/tui/confirm.go @@ -1,8 +1,12 @@ package tui import ( + "crypto/sha256" + "errors" "fmt" "os" + "path/filepath" + "sort" "strings" "time" @@ -12,23 +16,22 @@ import ( "github.com/abiswas97/sentei/internal/creator" "github.com/abiswas97/sentei/internal/git" "github.com/abiswas97/sentei/internal/integration" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/worktree" ) type teardownCompleteMsg struct { - results []pipeline.StepResult + results []progress.StepResult + err error } -func unlockLockedWorktrees(runner git.CommandRunner, repoPath string, worktrees []git.Worktree) { - for _, wt := range worktrees { - if wt.IsLocked { - if err := worktree.UnlockWorktree(runner, repoPath, wt.Path); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to unlock %s: %v\n", wt.Path, err) - } - } - } -} +const ( + unlockPhaseID progress.PhaseID = "unlock-worktrees" + teardownPhaseID progress.PhaseID = "teardown-integrations" + cleanupPhaseID progress.PhaseID = "prune-and-cleanup" + pruneStepID progress.StepID = "prune-worktree-metadata" + cleanupStepID progress.StepID = "repository-cleanup" +) func (m Model) updateConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { @@ -47,7 +50,7 @@ func (m Model) updateConfirm(msg tea.Msg) (tea.Model, tea.Cmd) { // beginRemoval starts the deletion of the current selection: fresh run // state, teardown of integration artifacts when present, then the deletion -// pipeline. Entered from the at-risk confirmation gate, or directly from +// progress. Entered from the at-risk confirmation gate, or directly from // the list when every selected worktree is clean and pushed. func (m Model) beginRemoval() (tea.Model, tea.Cmd) { m.progressStartedAt = time.Now() @@ -57,22 +60,132 @@ func (m Model) beginRemoval() (tea.Model, tea.Cmd) { m.remove.run = newRemovalRun(selected) integrations := integration.All() - hasTeardown := false - for _, wt := range selected { - if len(creator.ScanArtifacts(wt.Path, integrations)) > 0 { - hasTeardown = true - break + prepared, err := prepareRemoval(selected, integrations) + if err != nil { + m.remove.run.result.Err = err + m.view = summaryView + return m, nil + } + ch := make(chan progress.Event, (len(prepared.targets)+len(prepared.teardownOps)+len(prepared.unlockOps)+2)*5) + execution, err := progress.Start(prepared.plan, func(event progress.Event) { ch <- event }) + if err != nil { + close(ch) + m.remove.run.result.Err = fmt.Errorf("starting removal progress: %w", err) + m.view = summaryView + return m, nil + } + m.remove.run.execution = execution + m.remove.run.progressCh = ch + m.remove.run.targets = prepared.targets + m.remove.run.teardownOps = prepared.teardownOps + declarationEvents := len(prepared.unlockOps) + len(prepared.teardownOps) + len(prepared.targets) + 2 + len(prepared.plan.Phases) + for range declarationEvents { + m.remove.run.events = append(m.remove.run.events, <-ch) + } + for _, operation := range prepared.unlockOps { + _, transitionErr := execution.Run(unlockPhaseID, operation.stepID, func() (string, error) { + return "", worktree.UnlockWorktree(m.runner, m.repoPath, operation.worktree.Path) + }) + m.remove.run.result.Err = errors.Join(m.remove.run.result.Err, transitionErr) + } + + if len(prepared.teardownOps) > 0 { + m.remove.run.teardownRunning = true + for _, op := range prepared.teardownOps { + m.remove.run.teardownPlanned = append(m.remove.run.teardownPlanned, op.label) } + return m, tea.Batch(waitForRemovalEvent(ch), m.runFrozenTeardownPhase(prepared.teardownOps, execution)) } - unlockLockedWorktrees(m.runner, m.repoPath, selected) + updated, cmd := m.startDeletions() + return updated, tea.Batch(waitForRemovalEvent(ch), cmd) +} - if hasTeardown { - m.remove.run.teardownRunning = true - return m, m.runTeardownPhase(selected, integrations) +func prepareRemoval(worktrees []git.Worktree, integrations []integration.Integration) (removalPreparation, error) { + prepared := removalPreparation{} + seenWorktrees := map[string]bool{} + seenTeardowns := map[string]bool{} + unlock := progress.PlannedPhase{ID: unlockPhaseID, Label: "Unlock"} + teardown := progress.PlannedPhase{ID: teardownPhaseID, Label: "Teardown"} + removal := progress.PlannedPhase{ID: worktree.RemovalPhaseID, Label: worktree.RemovalPhaseName} + + for _, wt := range worktrees { + identity := normalizedWorktreeIdentity(wt) + if seenWorktrees[identity] { + return removalPreparation{}, fmt.Errorf("preparing removal: duplicate worktree identity %q", identity) + } + seenWorktrees[identity] = true + if wt.IsLocked { + stepID := removalSemanticStepID("unlock", identity) + prepared.unlockOps = append(prepared.unlockOps, unlockOperation{stepID: stepID, worktree: wt}) + unlock.Steps = append(unlock.Steps, progress.PlannedStep{ID: stepID, Label: worktreeLabel(wt)}) + } + for _, integ := range integrations { + for _, dir := range integ.Teardown.Dirs { + if _, err := integration.ResolveManagedPath(wt.Path, dir); err != nil { + return removalPreparation{}, fmt.Errorf("preparing removal for integration %q: %w", integ.Name, err) + } + } + } + for _, artifact := range creator.ScanArtifacts(wt.Path, integrations) { + integ := findIntegrationByName(integrations, artifact.IntegrationName) + if integ == nil { + continue + } + type managedArtifact struct { + dir string + } + artifacts := make([]managedArtifact, len(artifact.Dirs)) + for i, dir := range artifact.Dirs { + artifacts[i].dir = filepath.Clean(strings.TrimSuffix(dir, string(filepath.Separator))) + if _, err := integration.ResolveManagedPath(wt.Path, artifacts[i].dir); err != nil { + return removalPreparation{}, fmt.Errorf("preparing removal for integration %q: %w", integ.Name, err) + } + } + sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].dir < artifacts[j].dir }) + groupIdentity := strings.Join([]string{identity, strings.TrimSpace(integ.Name)}, "\x00") + if seenTeardowns[groupIdentity] { + return removalPreparation{}, fmt.Errorf("preparing removal: duplicate teardown identity for %s", integ.Name) + } + seenTeardowns[groupIdentity] = true + if integ.Teardown.Command != "" { + commandIdentity := groupIdentity + "\x00" + strings.TrimSpace(integ.Teardown.Command) + stepID := removalSemanticStepID("teardown-command", commandIdentity) + op := teardownOperation{groupID: groupIdentity, stepID: stepID, label: integration.TeardownStepName(*integ), kind: teardownCommand, wtPath: wt.Path, command: integ.Teardown.Command} + prepared.teardownOps = append(prepared.teardownOps, op) + teardown.Steps = append(teardown.Steps, progress.PlannedStep{ID: stepID, Label: op.label}) + } + for _, artifact := range artifacts { + stepID := removalSemanticStepID("remove-artifact", groupIdentity+"\x00"+filepath.ToSlash(artifact.dir)) + op := teardownOperation{groupID: groupIdentity, stepID: stepID, label: integration.RemoveDirStepName(artifact.dir, wt.Path), kind: teardownArtifacts, wtPath: wt.Path, managedPaths: []string{artifact.dir}} + prepared.teardownOps = append(prepared.teardownOps, op) + teardown.Steps = append(teardown.Steps, progress.PlannedStep{ID: stepID, Label: op.label}) + } + } + stepID := removalSemanticStepID("remove", identity) + prepared.targets = append(prepared.targets, worktree.RemovalTarget{Worktree: wt, StepID: stepID}) + removal.Steps = append(removal.Steps, progress.PlannedStep{ID: stepID, Label: worktreeLabel(wt), Checkpoints: 2}) + } + if len(unlock.Steps) > 0 { + prepared.plan.Phases = append(prepared.plan.Phases, unlock) } + if len(teardown.Steps) > 0 { + prepared.plan.Phases = append(prepared.plan.Phases, teardown) + } + prepared.plan.Phases = append(prepared.plan.Phases, removal, progress.PlannedPhase{ + ID: cleanupPhaseID, Label: "Prune & cleanup", + Steps: []progress.PlannedStep{{ID: pruneStepID, Label: "Prune worktree metadata"}, {ID: cleanupStepID, Label: "Repository cleanup"}}, + }) + return prepared, nil +} - return m.startDeletions() +func normalizedWorktreeIdentity(wt git.Worktree) string { + return filepath.Clean(strings.TrimSpace(wt.Path)) + "\x00" + strings.TrimSpace(wt.Branch) +} + +func removalSemanticStepID(kind, identity string) progress.StepID { + sum := sha256.Sum256([]byte(identity)) + return progress.StepID(fmt.Sprintf("%s:%x", kind, sum[:8])) } // worktreeAtRisk reports whether removing wt could lose work that exists @@ -85,46 +198,94 @@ func worktreeAtRisk(wt git.Worktree) bool { // startDeletions kicks off the deletion goroutine for the current run's // worktree snapshot and begins consuming its events. func (m Model) startDeletions() (tea.Model, tea.Cmd) { - selected := m.remove.run.worktrees - ch := make(chan worktree.DeletionEvent, len(selected)*2) - m.remove.run.progressCh = ch - go worktree.DeleteWorktrees(os.RemoveAll, selected, 5, ch) - return m, waitForDeletionEvent(ch) + execution := m.remove.run.execution + targets := append([]worktree.RemovalTarget(nil), m.remove.run.targets...) + return m, func() tea.Msg { + result := worktree.DeleteWorktrees(execution, worktree.RemovalPhaseID, os.RemoveAll, targets, 5) + return deletionsCompleteMsg{Result: result} + } +} + +func findIntegrationByName(integrations []integration.Integration, name string) *integration.Integration { + for i := range integrations { + if integrations[i].Name == name { + return &integrations[i] + } + } + return nil } const maxTeardownConcurrency = 5 -func (m Model) runTeardownPhase(worktrees []git.Worktree, integrations []integration.Integration) tea.Cmd { +func (m Model) runFrozenTeardownPhase(operations []teardownOperation, execution *progress.Execution) tea.Cmd { return func() tea.Msg { shell := m.shell type indexedResults struct { index int - results []pipeline.StepResult + results []progress.StepResult + err error + } + + type operationGroup struct { + id string + operations []teardownOperation + } + var groups []operationGroup + for _, operation := range operations { + if len(groups) == 0 || groups[len(groups)-1].id != operation.groupID { + groups = append(groups, operationGroup{id: operation.groupID}) + } + groups[len(groups)-1].operations = append(groups[len(groups)-1].operations, operation) } - resultsCh := make(chan indexedResults, len(worktrees)) + resultsCh := make(chan indexedResults, len(groups)) sem := make(chan struct{}, maxTeardownConcurrency) - for i, wt := range worktrees { + for i, group := range groups { sem <- struct{}{} - go func(idx int, wtPath string) { + go func(idx int, group operationGroup) { defer func() { <-sem }() - results := creator.Teardown(shell, wtPath, integrations, func(pipeline.Event) {}) - resultsCh <- indexedResults{index: idx, results: results} - }(i, wt.Path) + indexed := indexedResults{index: idx} + for _, op := range group.operations { + result, transitionErr := execution.Run(teardownPhaseID, op.stepID, func() (string, error) { + switch op.kind { + case teardownCommand: + return shell.RunShell(op.wtPath, op.command) + case teardownArtifacts: + for _, managedPath := range op.managedPaths { + path, err := integration.ResolveManagedPath(op.wtPath, managedPath) + if err != nil { + return "", fmt.Errorf("revalidating artifact path: %w", err) + } + if err := os.RemoveAll(path); err != nil { + return "", err + } + } + return "removed artifact dirs", nil + default: + return "", fmt.Errorf("unknown teardown operation kind %d", op.kind) + } + }) + indexed.results = append(indexed.results, result) + indexed.err = errors.Join(indexed.err, transitionErr) + } + resultsCh <- indexed + }(i, group) } - collected := make([][]pipeline.StepResult, len(worktrees)) - for range worktrees { + collected := make([]indexedResults, len(groups)) + for range groups { ir := <-resultsCh - collected[ir.index] = ir.results + collected[ir.index] = ir } - var allResults []pipeline.StepResult - for _, r := range collected { - allResults = append(allResults, r...) + var allResults []progress.StepResult + var runErr error + for _, indexed := range collected { + allResults = append(allResults, indexed.results...) + runErr = errors.Join(runErr, indexed.err) } - return teardownCompleteMsg{results: allResults} + return teardownCompleteMsg{results: allResults, err: runErr} } } diff --git a/internal/tui/confirm_test.go b/internal/tui/confirm_test.go index 444f1a1..5c48e23 100644 --- a/internal/tui/confirm_test.go +++ b/internal/tui/confirm_test.go @@ -1,6 +1,8 @@ package tui import ( + "errors" + "fmt" "os" "os/exec" "path/filepath" @@ -11,13 +13,19 @@ import ( "github.com/abiswas97/sentei/internal/git" "github.com/abiswas97/sentei/internal/integration" - "github.com/abiswas97/sentei/internal/pipeline" "github.com/abiswas97/sentei/internal/playground" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/testtmp" "github.com/abiswas97/sentei/internal/testutil/mock" "github.com/abiswas97/sentei/internal/worktree" ) +type teardownShellFunc func(dir, command string) (string, error) + +func (fn teardownShellFunc) RunShell(dir, command string) (string, error) { + return fn(dir, command) +} + func TestViewConfirm_CleanWorktrees(t *testing.T) { m := NewModel([]git.Worktree{ {Path: "/work/a", Branch: "refs/heads/feature-a"}, @@ -100,6 +108,7 @@ func TestConfirmDeletion_UnlocksLockedWorktrees(t *testing.T) { run := func(dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) + cmd.Env = testtmp.HermeticGitEnv() cmd.Dir = dir out, err := cmd.CombinedOutput() if err != nil { @@ -147,7 +156,9 @@ func TestConfirmDeletion_UnlocksLockedWorktrees(t *testing.T) { } // Verify: git worktree list should not show the locked worktree - out, _ := exec.Command("git", "-C", repoPath, "worktree", "list", "--porcelain").CombinedOutput() + wtList := exec.Command("git", "-C", repoPath, "worktree", "list", "--porcelain") + wtList.Env = testtmp.HermeticGitEnv() + out, _ := wtList.CombinedOutput() if strings.Contains(string(out), "locked-branch") { t.Error("locked worktree should not appear in git worktree list after deletion and prune") } @@ -228,7 +239,15 @@ func TestRunTeardownPhase_FallsBackToRemovingArtifactDirs(t *testing.T) { }} worktrees := []git.Worktree{{Path: withArtifacts}, {Path: clean}} - msg := m.runTeardownPhase(worktrees, integrations)() + prepared, err := prepareRemoval(worktrees, integrations) + if err != nil { + t.Fatal(err) + } + execution, err := progress.Start(prepared.plan, nil) + if err != nil { + t.Fatal(err) + } + msg := m.runFrozenTeardownPhase(prepared.teardownOps, execution)() done, ok := msg.(teardownCompleteMsg) if !ok { @@ -237,7 +256,7 @@ func TestRunTeardownPhase_FallsBackToRemovingArtifactDirs(t *testing.T) { if len(done.results) != 1 { t.Fatalf("results = %d, want 1 (only the worktree with artifacts)", len(done.results)) } - if done.results[0].Status != pipeline.StepDone { + if done.results[0].Status != progress.StepDone { t.Errorf("teardown status = %v, want StepDone", done.results[0].Status) } if _, err := os.Stat(artifactDir); !os.IsNotExist(err) { @@ -245,7 +264,7 @@ func TestRunTeardownPhase_FallsBackToRemovingArtifactDirs(t *testing.T) { } } -func TestRunTeardownPhase_TeardownCommandHandlesRemoval(t *testing.T) { +func TestRunTeardownPhase_CommandAndArtifactRemovalAreIndependent(t *testing.T) { wtPath := t.TempDir() artifactDir := filepath.Join(wtPath, ".fake-artifact") if err := os.Mkdir(artifactDir, 0o755); err != nil { @@ -254,21 +273,174 @@ func TestRunTeardownPhase_TeardownCommandHandlesRemoval(t *testing.T) { m := NewModel(nil, nil, "/repo") m.shell = &mock.Runner{Responses: map[string]mock.Response{ - wtPath + ":shell[fake clean]": {Output: "cleaned"}, + wtPath + ":shell[fake clean]": {Err: errors.New("clean failed")}, }} integrations := []integration.Integration{{ Name: "fake", Teardown: integration.TeardownSpec{Command: "fake clean", Dirs: []string{".fake-artifact/"}}, }} - msg := m.runTeardownPhase([]git.Worktree{{Path: wtPath}}, integrations)() + prepared, err := prepareRemoval([]git.Worktree{{Path: wtPath}}, integrations) + if err != nil { + t.Fatal(err) + } + execution, err := progress.Start(prepared.plan, nil) + if err != nil { + t.Fatal(err) + } + msg := m.runFrozenTeardownPhase(prepared.teardownOps, execution)() done := msg.(teardownCompleteMsg) - if len(done.results) != 1 || done.results[0].Status != pipeline.StepDone { - t.Fatalf("results = %+v, want one successful teardown", done.results) + if len(done.results) != 2 { + t.Fatalf("results = %+v, want command and artifact-removal results", done.results) + } + if done.results[0].Status != progress.StepFailed || done.results[1].Status != progress.StepDone { + t.Fatalf("results = %+v, want failed command followed by successful artifact removal", done.results) + } + if _, err := os.Stat(artifactDir); !os.IsNotExist(err) { + t.Error("artifact removal must still run after a failed teardown command") + } +} + +func TestRunTeardownPhase_ArtifactRemovalFailureIsReportedIndependently(t *testing.T) { + parent := t.TempDir() + wtPath := filepath.Join(parent, "worktree") + artifactDir := filepath.Join(wtPath, ".fake-artifact") + if err := os.MkdirAll(artifactDir, 0o755); err != nil { + t.Fatal(err) + } + + m := NewModel(nil, nil, "/repo") + m.shell = &mock.Runner{Responses: map[string]mock.Response{ + wtPath + ":shell[fake clean]": {Output: "cleaned"}, + }} + integrations := []integration.Integration{{ + Name: "fake", + Teardown: integration.TeardownSpec{Command: "fake clean", Dirs: []string{".fake-artifact/"}}, + }} + + prepared, err := prepareRemoval([]git.Worktree{{Path: wtPath}}, integrations) + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(wtPath); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(wtPath, []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + execution, err := progress.Start(prepared.plan, nil) + if err != nil { + t.Fatal(err) + } + + done := m.runFrozenTeardownPhase(prepared.teardownOps, execution)().(teardownCompleteMsg) + if len(done.results) != 2 { + t.Fatalf("results = %+v, want command and artifact-removal results", done.results) + } + if done.results[0].Status != progress.StepDone || done.results[1].Status != progress.StepFailed { + t.Fatalf("results = %+v, want successful command followed by failed artifact removal", done.results) + } +} + +func TestRunTeardownPhase_EachArtifactRemovalRunsAfterEarlierFailure(t *testing.T) { + wtPath := t.TempDir() + first := filepath.Join(wtPath, "blocked", "first") + second := filepath.Join(wtPath, "second") + if err := os.MkdirAll(first, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(second, 0o755); err != nil { + t.Fatal(err) + } + + m := NewModel(nil, nil, "/repo") + m.shell = &mock.Runner{Responses: map[string]mock.Response{ + wtPath + ":shell[fake clean]": {Output: "cleaned"}, + }} + integrations := []integration.Integration{{ + Name: "fake", + Teardown: integration.TeardownSpec{ + Command: "fake clean", + Dirs: []string{"second", "blocked/first"}, + }, + }} + prepared, err := prepareRemoval([]git.Worktree{{Path: wtPath}}, integrations) + if err != nil { + t.Fatal(err) + } + if len(prepared.teardownOps) != 3 { + t.Fatalf("teardown operations = %d, want command plus two removals", len(prepared.teardownOps)) + } + if err := os.RemoveAll(filepath.Join(wtPath, "blocked")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wtPath, "blocked"), []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + execution, err := progress.Start(prepared.plan, nil) + if err != nil { + t.Fatal(err) + } + + done := m.runFrozenTeardownPhase(prepared.teardownOps, execution)().(teardownCompleteMsg) + if len(done.results) != 3 { + t.Fatalf("results = %+v, want command plus two removal results", done.results) + } + wantStatuses := []progress.StepStatus{progress.StepDone, progress.StepFailed, progress.StepDone} + for i, want := range wantStatuses { + if done.results[i].Status != want { + t.Fatalf("result %d = %+v, want status %v", i, done.results[i], want) + } + } + if _, err := os.Stat(second); !os.IsNotExist(err) { + t.Fatalf("second artifact removal did not run after first failed: %v", err) + } +} + +func TestRunTeardownPhase_RevalidatesArtifactPathAfterCommand(t *testing.T) { + wtPath := t.TempDir() + outside := t.TempDir() + managedParent := filepath.Join(wtPath, "managed") + outsideArtifact := filepath.Join(outside, "artifact") + if err := os.MkdirAll(filepath.Join(managedParent, "artifact"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(outsideArtifact, 0o755); err != nil { + t.Fatal(err) + } + + m := NewModel(nil, nil, "/repo") + m.shell = teardownShellFunc(func(dir, command string) (string, error) { + if dir != wtPath || command != "fake clean" { + return "", fmt.Errorf("unexpected shell call: %s %s", dir, command) + } + if err := os.RemoveAll(managedParent); err != nil { + return "", err + } + return "", os.Symlink(outside, managedParent) + }) + integrations := []integration.Integration{{ + Name: "fake", Teardown: integration.TeardownSpec{Command: "fake clean", Dirs: []string{"managed/artifact"}}, + }} + prepared, err := prepareRemoval([]git.Worktree{{Path: wtPath}}, integrations) + if err != nil { + t.Fatal(err) + } + execution, err := progress.Start(prepared.plan, nil) + if err != nil { + t.Fatal(err) + } + + done := m.runFrozenTeardownPhase(prepared.teardownOps, execution)().(teardownCompleteMsg) + if len(done.results) != 2 || done.results[0].Status != progress.StepDone || done.results[1].Status != progress.StepFailed { + t.Fatalf("results = %+v, want successful command and visible removal failure", done.results) + } + if done.results[1].Error == nil || !strings.Contains(done.results[1].Error.Error(), "symlink") { + t.Fatalf("removal failure = %v, want symlink error", done.results[1].Error) } - if _, err := os.Stat(artifactDir); err != nil { - t.Error("a successful teardown command must not trigger the dir-removal fallback") + if _, err := os.Stat(outsideArtifact); err != nil { + t.Fatalf("outside artifact was touched: %v", err) } } diff --git a/internal/tui/constants.go b/internal/tui/constants.go index 70da0cf..6a51d36 100644 --- a/internal/tui/constants.go +++ b/internal/tui/constants.go @@ -38,11 +38,16 @@ func overallBarWidth(viewWidth int) int { // the footer area. const viewChromeRows = 6 -// progressSettleFloor is the minimum time a held progress view stays up -// after its final event, so the bar's spring visibly finishes at 100% -// instead of cutting away mid-glide. Part of the hold mechanism, applied -// only when holds are enabled (minProgressDuration > 0). -const progressSettleFloor = time.Second +// progressSettleBeat is how long a flow's final progress frame stays visibly +// settled (displayed fill at its spring target) before advancing to the +// summary. State-relative: the beat starts when the spring arrives, not when +// the final event fires, so endings show the truth in every run mode. +const progressSettleBeat = 600 * time.Millisecond + +// progressSettleTimeout hard-bounds the settle wait from the final event +// (covering the ~1.2s spring glide plus the beat, with margin) so the view +// can never wedge if the displayed fill cannot reach its target. +const progressSettleTimeout = 3 * time.Second // confirmNameWidthCap bounds the confirm screen's name column so one long // branch cannot push the risk notes off to the right. diff --git a/internal/tui/copy.go b/internal/tui/copy.go index 12c5534..73ef25d 100644 --- a/internal/tui/copy.go +++ b/internal/tui/copy.go @@ -28,6 +28,7 @@ const ( titleSetUpIntegrations = "Set up integrations" titleApplyingChanges = "Applying integration changes" titleApplyComplete = "Apply complete" + titleApplyErrors = "Apply finished with errors" titleCleanupPreview = "Cleanup preview" titleConfirmCleanup = "Confirm cleanup" titleRunningCleanup = "Running cleanup" @@ -37,6 +38,7 @@ const ( portalApplyDetails = "Apply details" portalIntegrationDetails = "Integration details" portalAggressiveDetails = "Aggressive cleanup details" + portalProgressDetails = "Progress details" ) // whisperMilestone is the dim celebration line on the removal summary when diff --git a/internal/tui/create_options.go b/internal/tui/create_options.go index 4af37a7..da17a91 100644 --- a/internal/tui/create_options.go +++ b/internal/tui/create_options.go @@ -11,7 +11,7 @@ import ( "github.com/abiswas97/sentei/internal/config" "github.com/abiswas97/sentei/internal/creator" "github.com/abiswas97/sentei/internal/integration" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/state" ) @@ -153,13 +153,13 @@ func (m *Model) startCreation() { Integrations: enabledInts, } - ch := make(chan pipeline.Event, 50) + ch := make(chan progress.Event, 50) resultCh := make(chan creator.Result, 1) m.create.eventCh = ch m.create.resultCh = resultCh go func() { - result := creator.Run(m.runner, m.shell, opts, func(e pipeline.Event) { + result := creator.Run(m.runner, m.shell, opts, func(e progress.Event) { ch <- e }) close(ch) @@ -179,7 +179,7 @@ func (m Model) waitForCreateEvent() tea.Cmd { } type createEventMsg struct { - Event pipeline.Event + Event progress.Event } type createCompleteMsg struct { Result creator.Result diff --git a/internal/tui/create_options_test.go b/internal/tui/create_options_test.go index a80603e..b2f27ac 100644 --- a/internal/tui/create_options_test.go +++ b/internal/tui/create_options_test.go @@ -8,7 +8,7 @@ import ( "github.com/abiswas97/sentei/internal/config" "github.com/abiswas97/sentei/internal/creator" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" ) @@ -139,9 +139,9 @@ func TestValidateBranchName(t *testing.T) { // drainCreatePipeline executes wait commands until the creation goroutine // reports completion, returning the final result and the events seen. -func drainCreatePipeline(t *testing.T, m Model) (creator.Result, []pipeline.Event) { +func drainCreatePipeline(t *testing.T, m Model) (creator.Result, []progress.Event) { t.Helper() - var events []pipeline.Event + var events []progress.Event for range 50 { msg := m.waitForCreateEvent()() switch msg := msg.(type) { @@ -270,10 +270,10 @@ func TestStartCreation_OnlyEnabledEcosystemsPassed(t *testing.T) { func TestWaitForCreateEvent_DeliversEventThenCompletion(t *testing.T) { m := createOptionsModel() - m.create.eventCh = make(chan pipeline.Event, 1) + m.create.eventCh = make(chan progress.Event, 1) m.create.resultCh = make(chan creator.Result, 1) - ev := pipeline.Event{Phase: "Setup", Step: "Create worktree", Status: pipeline.StepRunning} + ev := progress.Event{Phase: "Setup", Step: "Create worktree", Status: progress.StepRunning} m.create.eventCh <- ev msg := m.waitForCreateEvent()() got, ok := msg.(createEventMsg) diff --git a/internal/tui/create_progress.go b/internal/tui/create_progress.go index c072b91..ae59629 100644 --- a/internal/tui/create_progress.go +++ b/internal/tui/create_progress.go @@ -5,6 +5,8 @@ import ( "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" + + "github.com/abiswas97/sentei/internal/progress" ) func (m Model) updateCreateProgress(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -30,15 +32,15 @@ func (m Model) updateCreateProgress(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) createLayout() ProgressLayout { - return ProgressLayout{ + return m.withProgressDetails(ProgressLayout{ Title: titleCreatingWorktree, Completed: m.create.result != nil, Subtitle: fmt.Sprintf("%s \u2192 from %s", m.create.branchInput.Value(), m.create.baseInput.Value()), - Phases: withPendingPhases(buildPhaseDisplays(m.create.events), "Setup", "Dependencies", "Integrations"), + Phases: progress.Snapshot(m.create.events), Width: m.width, - Height: m.height, + Height: m.progressHeight(), Hints: progressFooter, - } + }) } func (m Model) viewCreateProgress() string { diff --git a/internal/tui/create_progress_test.go b/internal/tui/create_progress_test.go index 16d40f5..4211a28 100644 --- a/internal/tui/create_progress_test.go +++ b/internal/tui/create_progress_test.go @@ -8,7 +8,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/abiswas97/sentei/internal/creator" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" ) func createProgressModel() Model { @@ -53,9 +53,9 @@ func TestUpdateCreateProgress_WindowSize(t *testing.T) { func TestUpdateCreateProgress_EventAppendsAndWaitsForNext(t *testing.T) { m := createProgressModel() - m.create.eventCh = make(chan pipeline.Event, 1) + m.create.eventCh = make(chan progress.Event, 1) - ev := pipeline.Event{Phase: "Setup", Step: "Create worktree", Status: pipeline.StepRunning} + ev := progress.Event{Phase: "Setup", Step: "Create worktree", Status: progress.StepRunning} updated, cmd := m.updateCreateProgress(createEventMsg{Event: ev}) model := updated.(Model) @@ -75,6 +75,7 @@ func TestUpdateCreateProgress_CompleteAdvancesToSummary(t *testing.T) { updated, cmd := m.updateCreateProgress(createCompleteMsg{Result: result}) model := updated.(Model) + model = settleNow(t, model) if model.view != createSummaryView { t.Errorf("view = %d, want createSummaryView", model.view) } @@ -89,16 +90,16 @@ func TestUpdateCreateProgress_CompleteAdvancesToSummary(t *testing.T) { } } -func TestViewCreateProgress_ShowsTitleSubtitleAndPendingPhases(t *testing.T) { +func TestViewCreateProgress_ShowsDeclaredPhases(t *testing.T) { m := createProgressModel() - m.create.events = []pipeline.Event{ - {Phase: "Setup", Step: "Create worktree", Status: pipeline.StepDone}, - {Phase: "Dependencies", Step: "npm install", Status: pipeline.StepFailed, Error: errors.New("boom")}, + m.create.events = []progress.Event{ + {Phase: "Setup", Step: "Create worktree", Status: progress.StepDone}, + {Phase: "Dependencies", Step: "npm install", Status: progress.StepFailed, Error: errors.New("boom")}, } view := stripANSI(m.viewCreateProgress()) - for _, want := range []string{"Creating worktree", "feature/x", "from main", "Setup", "Dependencies", "Integrations", "q quit"} { + for _, want := range []string{"Creating worktree", "feature/x", "from main", "Setup", "Dependencies", "q quit"} { if !strings.Contains(view, want) { t.Errorf("view missing %q:\n%s", want, view) } diff --git a/internal/tui/create_reset_test.go b/internal/tui/create_reset_test.go index 70d1a2e..e3f46a7 100644 --- a/internal/tui/create_reset_test.go +++ b/internal/tui/create_reset_test.go @@ -6,7 +6,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/abiswas97/sentei/internal/creator" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" ) @@ -24,7 +24,7 @@ func TestMenuEntry_CreateWorktree_ResetsFlowState(t *testing.T) { m.create.mergeBase = false m.create.copyEnvFiles = false m.create.optionsCursor = 3 - m.create.events = []pipeline.Event{{Phase: "Setup", Step: "old"}} + m.create.events = []progress.Event{{Phase: "Setup", Step: "old"}} m.create.result = &creator.Result{} updated, _ := m.updateMenu(tea.KeyPressMsg{Code: tea.KeyEnter}) diff --git a/internal/tui/create_summary.go b/internal/tui/create_summary.go index 97d8572..3958b18 100644 --- a/internal/tui/create_summary.go +++ b/internal/tui/create_summary.go @@ -10,7 +10,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" ) func (m Model) updateCreateSummary(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -43,13 +43,21 @@ func (m Model) viewCreateSummary() string { } hasFailures := result != nil && result.HasFailures() + hasContractError := result != nil && result.Err != nil b.WriteString(viewTitle(titleWorktreeCreated)) b.WriteString("\n\n") b.WriteString(viewSeparator(m.width)) b.WriteString("\n\n") - if hasFailures { + if hasContractError { + fmt.Fprintf(&b, " %s %s creation failed\n\n", + styleIndicatorFailed.Render(indicatorFailed), branch) + for _, line := range errorPeekLines(result.Err.Error(), max(m.width-8, 20)) { + fmt.Fprintf(&b, " %s\n", styleError.Render(line)) + } + b.WriteString("\n") + } else if hasFailures { fmt.Fprintf(&b, " %s %s created with issues\n\n", styleIndicatorWarning.Render(indicatorWarning), branch) } else { @@ -73,11 +81,11 @@ func (m Model) viewCreateSummary() string { } for _, step := range phase.Steps { status := styleIndicatorDone.Render(indicatorDone) - if step.Status == pipeline.StepFailed { + if step.Status == progress.StepFailed { status = styleIndicatorFailed.Render(indicatorFailed) } fmt.Fprintf(&b, " %-10s %s %s\n", styleDim.Render(label), step.Name, status) - if step.Status == pipeline.StepFailed && step.Error != nil { + if step.Status == progress.StepFailed && step.Error != nil { peek := errorPeekLines(step.Error.Error(), max(m.width-8, 20)) for i, line := range peek { style := styleDim diff --git a/internal/tui/create_summary_test.go b/internal/tui/create_summary_test.go index 0ed2a51..1287f50 100644 --- a/internal/tui/create_summary_test.go +++ b/internal/tui/create_summary_test.go @@ -8,9 +8,20 @@ import ( tea "charm.land/bubbletea/v2" "github.com/abiswas97/sentei/internal/creator" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" ) +func TestCreateSummaryShowsCreatorContractError(t *testing.T) { + m := createOptionsModel() + m.width = 100 + m.create.result = &creator.Result{Err: errors.New("progress delivery failed")} + + view := stripANSI(m.viewCreateSummary()) + if !strings.Contains(view, "progress delivery failed") || strings.Contains(view, " ready") { + t.Fatalf("summary did not propagate creator error:\n%s", view) + } +} + func TestUpdateCreateSummary_EnterReturnsToMenuWhenMenuLaunched(t *testing.T) { m := createOptionsModel() m.view = createSummaryView @@ -84,13 +95,13 @@ func TestViewCreateSummary_FailuresShowDepsAndIndexSteps(t *testing.T) { m := createOptionsModel() m.create.result = &creator.Result{ WorktreePath: "/repo/feature-x", - Phases: []pipeline.Phase{ - {Name: "Setup", Steps: []pipeline.StepResult{{Name: "Create worktree", Status: pipeline.StepDone}}}, - {Name: "Dependencies", Steps: []pipeline.StepResult{ - {Name: "npm install", Status: pipeline.StepFailed, Error: errors.New("npm exploded")}, + Phases: []progress.Phase{ + {Name: "Setup", Steps: []progress.StepResult{{Name: "Create worktree", Status: progress.StepDone}}}, + {Name: "Dependencies", Steps: []progress.StepResult{ + {Name: "npm install", Status: progress.StepFailed, Error: errors.New("npm exploded")}, }}, - {Name: "Integrations", Steps: []pipeline.StepResult{ - {Name: "Index code-review-graph", Status: pipeline.StepDone}, + {Name: "Integrations", Steps: []progress.StepResult{ + {Name: "Index code-review-graph", Status: progress.StepDone}, }}, }, } diff --git a/internal/tui/errpeek_test.go b/internal/tui/errpeek_test.go index 5b7a9c1..fbc03dc 100644 --- a/internal/tui/errpeek_test.go +++ b/internal/tui/errpeek_test.go @@ -7,7 +7,7 @@ import ( "testing" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/integration" + "github.com/abiswas97/sentei/internal/progress" ) func TestErrorPeekLines_Bounds(t *testing.T) { @@ -60,8 +60,8 @@ func TestRenderIntegrationOutcomes_FailureStaysBounded(t *testing.T) { groups := []integrationWorktreeOutcomes{{ worktree: "/wt/feature-wip", steps: []integrationStepOutcome{ - {step: "Setup code-review-graph", ev: integration.ManagerEvent{Status: integration.StatusDone}}, - {step: "Install cocoindex-code", ev: integration.ManagerEvent{Status: integration.StatusFailed, Error: errors.New(dump)}}, + {step: "Setup code-review-graph", ev: progress.Event{Status: progress.StepDone}}, + {step: "Install cocoindex-code", ev: progress.Event{Status: progress.StepFailed, Error: errors.New(dump)}}, }, }} @@ -88,8 +88,8 @@ func TestRenderIntegrationOutcomes_FailureStaysBounded(t *testing.T) { func TestApplySummary_FailureUnlocksDetailPortal(t *testing.T) { m := NewModel([]git.Worktree{}, nil, "/repo") m.width = 100 - m.integ.events = []integration.ManagerEvent{ - {Worktree: "/wt/a", Step: "Install x", Status: integration.StatusFailed, Error: errors.New("line1\nline2\nerror: boom")}, + m.integ.events = []progress.Event{ + {Phase: "/wt/a", Step: "Install x", Status: progress.StepFailed, Error: errors.New("line1\nline2\nerror: boom")}, } title, content := m.integrationSummaryDetailContent() if title == "" || !strings.Contains(stripAnsi(content), "error: boom") { diff --git a/internal/tui/help.go b/internal/tui/help.go index 6d90d23..c682b61 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -1,10 +1,15 @@ package tui import ( + "errors" "fmt" "strings" + "github.com/charmbracelet/x/ansi" + "github.com/abiswas97/sentei/internal/git" + "github.com/abiswas97/sentei/internal/progress" + "github.com/abiswas97/sentei/internal/repo" ) // renderHelpSections formats key bindings as an aligned two-column table @@ -96,12 +101,22 @@ func (m Model) helpForView() (string, []keySection) { // when the view has no contextual details (the key then falls through to the // view's own handling, e.g. the integration info card). func (m Model) detailContent() (string, string) { + if m.determinateProgressActive() { + layout, ok := m.activeProgressLayout() + if !ok || !progressNeedsDetails(layout, m.progressTopLevelError()) { + return "", "" + } + return portalProgressDetails, renderProgressDetails(layout, m.progressTopLevelError(), m.portal.contentWidth()) + } if m.view == cleanupPreviewView { return m.cleanupDetailContent() } if m.view == integrationSummaryView { return m.integrationSummaryDetailContent() } + if m.view == summaryView { + return m.removalSummaryDetailContent() + } if m.view == integrationListView || m.view == migrateIntegrationsView { if len(m.integ.integrations) == 0 { return "", "" @@ -136,6 +151,152 @@ func (m Model) detailContent() (string, string) { return portalWorktreeDetails, b.String() } +func (m Model) progressTopLevelError() error { + switch m.view { + case progressView: + return m.remove.run.result.Err + case createProgressView: + if m.create.result != nil { + return m.create.result.Err + } + case repoProgressView, migrateProgressView: + switch result := m.repo.result.(type) { + case repo.CreateResult: + return result.Err + case repo.CloneResult: + return result.Err + case repo.MigrateResult: + return result.Err + } + case integrationProgressView: + return errors.Join(m.integ.prepareErr, m.integ.executionErr, m.integ.saveErr) + } + return nil +} + +func progressNeedsDetails(layout ProgressLayout, topErr error) bool { + if topErr != nil { + return true + } + viewport := BuildProgressViewport(layout.Phases, layout.Height, layout.Completed) + if viewport.HistoryOmitted > 0 || viewport.Queued > 0 { + return true + } + if viewport.Focus != nil && WindowSteps(viewport.Focus.Steps, max(viewport.DetailRows-1, 0)).Windowed { + return true + } + for _, phase := range layout.Phases { + if phase.Failed > 0 { + return true + } + for _, step := range phase.Steps { + if step.Error != nil || step.Status == progress.StepFailed { + return true + } + } + } + return false +} + +func renderProgressDetails(layout ProgressLayout, topErr error, width int) string { + var b strings.Builder + if topErr != nil { + writeProgressDetailValue(&b, "", styleError.Render("Operation error"), width) + writeProgressDetailValue(&b, " ", topErr.Error(), width) + b.WriteString("\n") + } + for phaseIndex, phase := range layout.Phases { + if phaseIndex > 0 { + b.WriteString("\n") + } + writeProgressDetailValue(&b, "", styleTitle.Render(phase.Name), width) + writeProgressDetailValue(&b, " "+styleDim.Render("Phase ID")+" ", string(phase.ID), width) + writeProgressDetailValue(&b, " "+styleDim.Render("Status")+" ", progressPhaseStatus(phase), width) + for _, step := range phase.Steps { + b.WriteString("\n") + writeProgressDetailValue(&b, " ", step.Name, width) + writeProgressDetailValue(&b, " "+styleDim.Render("Step ID")+" ", string(step.ID), width) + writeProgressDetailValue(&b, " "+styleDim.Render("Status")+" ", progressStepStatus(step.Status), width) + if step.Message != "" { + label := "Message" + if step.Status == progress.StepSkipped { + label = "Skip reason" + } + writeProgressDetailValue(&b, " "+styleDim.Render(label)+" ", step.Message, width) + } + if step.Error != nil { + writeProgressDetailValue(&b, " "+styleError.Render("Error")+" ", step.Error.Error(), width) + } + } + } + return strings.TrimRight(b.String(), "\n") +} + +func writeProgressDetailValue(b *strings.Builder, prefix, value string, width int) { + if width <= 0 { + return + } + prefixWidth := ansi.StringWidth(prefix) + const minInlineValueWidth = 4 + if prefix != "" && width-prefixWidth < minInlineValueWidth { + label := strings.TrimRight(prefix, " ") + b.WriteString(fitProgressLine(label, width)) + b.WriteString("\n") + + leading := len(prefix) - len(strings.TrimLeft(prefix, " ")) + indentWidth := min(leading+2, max(width-minInlineValueWidth, 0)) + indent := strings.Repeat(" ", indentWidth) + wrapped := ansi.Hardwrap(value, max(width-indentWidth, 1), true) + for _, line := range strings.Split(wrapped, "\n") { + b.WriteString(indent) + b.WriteString(line) + b.WriteString("\n") + } + return + } + wrapped := ansi.Hardwrap(value, max(width-prefixWidth, 1), true) + continuation := strings.Repeat(" ", prefixWidth) + for i, line := range strings.Split(wrapped, "\n") { + if i == 0 { + b.WriteString(prefix) + } else { + b.WriteString(continuation) + } + b.WriteString(line) + b.WriteString("\n") + } +} + +func progressPhaseStatus(phase progress.PhaseState) string { + switch { + case phase.Failed > 0: + return "failed" + case phase.Settled(): + return "done" + case phaseHasStatus(phase, progress.StepRunning): + return "running" + case phase.Total == 0: + return "empty" + default: + return "pending" + } +} + +func progressStepStatus(status progress.StepStatus) string { + switch status { + case progress.StepRunning: + return "running" + case progress.StepDone: + return "done" + case progress.StepFailed: + return "failed" + case progress.StepSkipped: + return "skipped" + default: + return "pending" + } +} + func formatCommitDate(wt git.Worktree) string { if wt.LastCommitDate.IsZero() { return "" diff --git a/internal/tui/helpers_test.go b/internal/tui/helpers_test.go index e63f61c..e3d45ce 100644 --- a/internal/tui/helpers_test.go +++ b/internal/tui/helpers_test.go @@ -2,8 +2,10 @@ package tui import ( "path/filepath" + "testing" + "time" - "charm.land/bubbles/v2/progress" + progressbar "charm.land/bubbles/v2/progress" "charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/stopwatch" tea "charm.land/bubbletea/v2" @@ -57,7 +59,7 @@ func pumpCmds(model tea.Model, cmd tea.Cmd) tea.Model { continue } switch msg.(type) { - case progress.FrameMsg, stopwatch.StartStopMsg, spinner.TickMsg, motionTickMsg: + case progressbar.FrameMsg, stopwatch.StartStopMsg, spinner.TickMsg, motionTickMsg: continue } var next tea.Cmd @@ -68,3 +70,19 @@ func pumpCmds(model tea.Model, cmd tea.Cmd) tea.Model { } return model } + +// settleNow fast-forwards the completion settle in tests: backdates the +// settling clock past the hard timeout and runs the observation, returning +// the advanced model. Fails the test if the flow was not settling. +func settleNow(t *testing.T, m Model) Model { + t.Helper() + if !m.progressSettling { + t.Fatal("flow is not in the completion settle") + } + m.progressSettlingSince = time.Now().Add(-progressSettleTimeout - time.Millisecond) + model, advanced := m.observeSettle(time.Now()) + if !advanced { + t.Fatal("settle observation did not advance the view") + } + return model.completeProgressTransition() +} diff --git a/internal/tui/integration_list.go b/internal/tui/integration_list.go index ada388b..70474a2 100644 --- a/internal/tui/integration_list.go +++ b/internal/tui/integration_list.go @@ -1,6 +1,7 @@ package tui import ( + "errors" "fmt" "path/filepath" "strings" @@ -10,7 +11,9 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/abiswas97/sentei/internal/git" "github.com/abiswas97/sentei/internal/integration" + "github.com/abiswas97/sentei/internal/progress" ) type integrationStateLoadedMsg struct { @@ -22,10 +25,21 @@ type integrationStateLoadedMsg struct { } type integrationEventMsg struct { - Event integration.ManagerEvent + Event progress.Event } -type integrationApplyDoneMsg struct{} +type integrationApplyResult struct { + phases []progress.Phase + empty bool + err error +} + +type integrationApplyDoneMsg struct{ result integrationApplyResult } + +type integrationPreparedMsg struct { + prepared integration.PreparedApply + err error +} func (m Model) loadIntegrationState() tea.Cmd { return func() tea.Msg { @@ -57,12 +71,21 @@ func (m Model) loadIntegrationState() tea.Cmd { } } -func waitForIntegrationEvent(ch <-chan integration.ManagerEvent, doneCh <-chan struct{}) tea.Cmd { +func waitForIntegrationEvent(ch <-chan progress.Event, resultCh <-chan integrationApplyResult) tea.Cmd { return func() tea.Msg { ev, ok := <-ch if !ok { - <-doneCh - return integrationApplyDoneMsg{} + select { + case result, ok := <-resultCh: + if !ok { + result.err = errors.New("integration apply events closed without a terminal result") + } + return integrationApplyDoneMsg{result: result} + default: + return integrationApplyDoneMsg{result: integrationApplyResult{ + err: errors.New("integration apply events closed without a terminal result"), + }} + } } return integrationEventMsg{Event: ev} } @@ -104,49 +127,48 @@ func (m Model) startIntegrationApply() (Model, tea.Cmd) { wtPaths = append(wtPaths, wt.Path) } - // Calculate total steps upfront for accurate progress bar. - // Enable: setup is always 1 per worktree. Deps/install are conditional - // but we count them as maximum so the bar doesn't exceed total. - totalSteps := 0 - for _, integ := range toEnable { - stepsPerWT := 1 // setup (always runs) - stepsPerWT += len(integ.Dependencies) - stepsPerWT++ // install - totalSteps += stepsPerWT * len(wtPaths) - } - for _, integ := range toDisable { - stepsPerWT := 0 - if integ.Teardown.Command != "" { - stepsPerWT++ // teardown - } - stepsPerWT += len(integ.Teardown.Dirs) // dir removals - totalSteps += stepsPerWT * len(wtPaths) - } - m.integ.totalSteps = totalSteps m.integ.targetWorktrees = wtPaths - - ch := make(chan integration.ManagerEvent, 50) - doneCh := make(chan struct{}, 1) - m.integ.eventCh = ch - m.integ.doneCh = doneCh + m.integ.lifecycle = integrationPreparing + m.integ.prepareErr = nil + m.integ.executionErr = nil + m.integ.saveErr = nil repoPath := m.repoPath shell := m.shell mainWT := m.findSourceWorktree() + return m, func() tea.Msg { + prepared, err := integration.PrepareApply(shell, repoPath, mainWT, toEnable, toDisable, wtPaths) + return integrationPreparedMsg{prepared: prepared, err: err} + } +} - go func() { - emit := func(e integration.ManagerEvent) { ch <- e } - for _, integ := range toEnable { - integration.EnableIntegration(shell, repoPath, mainWT, wtPaths, integ, emit) - } - for _, integ := range toDisable { - integration.DisableIntegration(shell, wtPaths, integ, emit) +func (m Model) startPreparedIntegrationApply(prepared integration.PreparedApply) (Model, tea.Cmd) { + ch := make(chan progress.Event, 50) + resultCh := make(chan integrationApplyResult, 1) + m.integ.eventCh = ch + m.integ.resultCh = resultCh + m.integ.lifecycle = integrationExecuting + shell := m.shell + go runIntegrationApplyWorker(prepared, shell, ch, resultCh) + return m, waitForIntegrationEvent(ch, resultCh) +} + +func runIntegrationApplyWorker( + prepared integration.PreparedApply, + shell git.ShellRunner, + events chan<- progress.Event, + results chan<- integrationApplyResult, +) { + result := integrationApplyResult{empty: prepared.Empty()} + defer func() { + if recovered := recover(); recovered != nil { + result.err = fmt.Errorf("integration apply worker panicked: %v", recovered) } - close(ch) - doneCh <- struct{}{} + results <- result + close(results) + close(events) }() - - return m, waitForIntegrationEvent(ch, doneCh) + result.phases, result.err = prepared.Run(shell, func(event progress.Event) { events <- event }) } func (m Model) updateIntegrationList(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -195,7 +217,7 @@ func (m Model) updateIntegrationList(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, keys.Confirm): if m.integrationHasPendingChanges() { m.integ.events = nil - m.integ.finalized = false + m.integ.lifecycle = integrationIdle m.integ.returnView = integrationListView m.progressStartedAt = time.Now() m.progressToken++ diff --git a/internal/tui/integration_list_test.go b/internal/tui/integration_list_test.go index d13dfcf..d0e4611 100644 --- a/internal/tui/integration_list_test.go +++ b/internal/tui/integration_list_test.go @@ -4,14 +4,115 @@ import ( "fmt" "strings" "testing" + "time" tea "charm.land/bubbletea/v2" "github.com/abiswas97/sentei/internal/git" "github.com/abiswas97/sentei/internal/integration" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/testutil/mock" ) +type panicShell struct { + delegate *mock.Runner + panics bool +} + +func (s *panicShell) RunShell(dir, command string) (string, error) { + if s.panics { + panic("shell exploded") + } + return s.delegate.RunShell(dir, command) +} + +func TestIntegrationApplyWorkerBuffersOneTerminalResultAndRecoversPanic(t *testing.T) { + shell := &panicShell{delegate: &mock.Runner{Responses: map[string]mock.Response{ + "/wt/a:shell[tool detect]": {Output: "installed"}, + }}} + prepared, err := integration.PrepareApply(shell, "/repo", "/wt/a", []integration.Integration{{ + Name: "tool", Detect: integration.DetectSpec{Command: "tool detect"}, + Setup: integration.SetupSpec{Command: "tool setup", WorkingDir: "worktree"}, + }}, nil, []string{"/wt/a"}) + if err != nil { + t.Fatal(err) + } + shell.panics = true + + events := make(chan progress.Event, 4) + results := make(chan integrationApplyResult, 1) + go runIntegrationApplyWorker(prepared, shell, events, results) + + for range events { + } + result, ok := <-results + if !ok || result.err == nil || !strings.Contains(result.err.Error(), "worker panicked") { + t.Fatalf("terminal result = %#v, ok=%v, want recovered panic error", result, ok) + } + if cap(results) != 1 { + t.Fatalf("terminal result channel capacity = %d, want 1", cap(results)) + } + if _, ok := <-results; ok { + t.Fatal("terminal result channel must contain exactly one result") + } +} + +func TestStartPreparedIntegrationApplyUsesBufferedTerminalChannel(t *testing.T) { + m := makeIntegrationModel() + prepared, err := integration.PrepareApply(&mock.Runner{}, "/repo", "", nil, nil, nil) + if err != nil { + t.Fatal(err) + } + + m, cmd := m.startPreparedIntegrationApply(prepared) + if cap(m.integ.resultCh) != 1 { + t.Fatalf("terminal result channel capacity = %d, want 1", cap(m.integ.resultCh)) + } + if m.integ.lifecycle != integrationExecuting || cmd == nil { + t.Fatalf("lifecycle=%v cmd=%v, want executing with wait command", m.integ.lifecycle, cmd) + } +} + +func TestWaitForIntegrationEventDrainsEventsBeforeTerminalResult(t *testing.T) { + events := make(chan progress.Event, 2) + results := make(chan integrationApplyResult, 1) + events <- progress.Event{Step: "first"} + events <- progress.Event{Step: "second"} + close(events) + results <- integrationApplyResult{} + close(results) + + for _, want := range []string{"first", "second"} { + msg := waitForIntegrationEvent(events, results)() + event, ok := msg.(integrationEventMsg) + if !ok || event.Event.Step != want { + t.Fatalf("message = %#v, want event %q", msg, want) + } + } + if _, ok := waitForIntegrationEvent(events, results)().(integrationApplyDoneMsg); !ok { + t.Fatal("terminal result arrived before queued events were drained") + } +} + +func TestWaitForIntegrationEventClosedWithoutResultIsInternalError(t *testing.T) { + events := make(chan progress.Event) + results := make(chan integrationApplyResult, 1) + close(events) + + messages := make(chan tea.Msg, 1) + go func() { messages <- waitForIntegrationEvent(events, results)() }() + var msg tea.Msg + select { + case msg = <-messages: + case <-time.After(200 * time.Millisecond): + t.Fatal("closed event stream wedged waiting for a missing terminal result") + } + done := msg.(integrationApplyDoneMsg) + if done.result.err == nil || !strings.Contains(done.result.err.Error(), "without a terminal result") { + t.Fatalf("terminal error = %v, want internal missing-result error", done.result.err) + } +} + func TestUpdateIntegrationList_LoadedMsg_SetsState(t *testing.T) { m := makeIntegrationModel() all := integration.All() @@ -113,6 +214,20 @@ func TestUpdateIntegrationList_Toggle(t *testing.T) { } } +func TestUpdateIntegrationList_ConfirmWithoutChangesStaysIdle(t *testing.T) { + m := makeIntegrationModel() + m.view = integrationListView + + updated, cmd := m.updateIntegrationList(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(Model) + if m.view != integrationListView || cmd != nil { + t.Fatalf("view=%v cmd=%v, want unchanged list with no preparation command", m.view, cmd) + } + if m.integ.lifecycle != integrationIdle || m.integ.eventCh != nil { + t.Fatalf("lifecycle=%v eventCh=%v, want idle without channels", m.integ.lifecycle, m.integ.eventCh) + } +} + func TestUpdateIntegrationList_Navigate(t *testing.T) { m := makeIntegrationModel() m.integ.cursor = 0 @@ -305,30 +420,30 @@ func TestStartIntegrationApply_ComputesPlanAndAppliesChanges(t *testing.T) { m.integ.staged = map[string]bool{"enable-me": true, "disable-me": false} m.shell = &mock.Runner{Responses: map[string]mock.Response{ wtPath + ":shell[enable-me --version]": {Output: "1.0"}, // already installed + wtPath + ":shell[dep1 --version]": {Output: "1.0"}, wtPath + ":shell[enable-me init]": {Output: "ok"}, wtPath + ":shell[disable-me clean]": {Output: "ok"}, }} updated, cmd := m.startIntegrationApply() - // enable: 1 setup + 1 dep + 1 install; disable: 1 teardown + 2 dir removals. - if updated.integ.totalSteps != 6 { - t.Errorf("totalSteps = %d, want 6", updated.integ.totalSteps) - } if len(updated.integ.targetWorktrees) != 1 || updated.integ.targetWorktrees[0] != wtPath { t.Errorf("targetWorktrees = %v, want [%s]", updated.integ.targetWorktrees, wtPath) } if cmd == nil { - t.Fatal("expected a wait command") + t.Fatal("expected a preparation command") } + preparedMsg := cmd() + preparedModel, _ := updated.updateIntegrationProgress(preparedMsg) + updated = preparedModel.(Model) events := drainIntegrationApply(t, updated) var sawSetup, sawTeardown bool for _, ev := range events { - if ev.Step == "Setup enable-me" && ev.Status == integration.StatusDone { + if ev.StepLabel == "Setup enable-me" && ev.Status == progress.StepDone { sawSetup = true } - if ev.Step == "Teardown disable-me" && ev.Status == integration.StatusDone { + if ev.StepLabel == "Teardown disable-me" && ev.Status == progress.StepDone { sawTeardown = true } } diff --git a/internal/tui/integration_plan_test.go b/internal/tui/integration_plan_test.go new file mode 100644 index 0000000..61bef05 --- /dev/null +++ b/internal/tui/integration_plan_test.go @@ -0,0 +1,67 @@ +package tui + +import ( + "errors" + "testing" + + "github.com/abiswas97/sentei/internal/integration" + "github.com/abiswas97/sentei/internal/progress" + "github.com/abiswas97/sentei/internal/testutil/mock" +) + +func TestIntegrationPreparedApplyUsesStrictFixedStream(t *testing.T) { + worktrees := []string{"/wt/feat-1", "/wt/feat-2"} + alpha := integration.Integration{ + Name: "alpha", Detect: integration.DetectSpec{Command: "alpha detect"}, + Install: integration.InstallSpec{Command: "alpha install"}, + Setup: integration.SetupSpec{Command: "alpha setup {path}", WorkingDir: "worktree"}, + } + beta := integration.Integration{ + Name: "beta", Detect: integration.DetectSpec{Command: "beta detect"}, + Install: integration.InstallSpec{Command: "beta install"}, + Setup: integration.SetupSpec{Command: "beta setup {path}", WorkingDir: "worktree"}, + } + shell := &mock.Runner{Responses: map[string]mock.Response{ + "/wt/feat-1:shell[alpha detect]": {Output: "installed"}, + "/wt/feat-1:shell[beta detect]": {Err: errors.New("missing")}, + "/wt/feat-1:shell[beta install]": {Output: "installed"}, + "/wt/feat-1:shell[alpha setup '/wt/feat-1']": {Output: "ok"}, + "/wt/feat-2:shell[alpha setup '/wt/feat-2']": {Output: "ok"}, + "/wt/feat-1:shell[beta setup '/wt/feat-1']": {Output: "ok"}, + "/wt/feat-2:shell[beta setup '/wt/feat-2']": {Output: "ok"}, + }} + prepared, err := integration.PrepareApply(shell, "/repo", worktrees[0], []integration.Integration{alpha, beta}, nil, worktrees) + if err != nil { + t.Fatal(err) + } + var events []progress.Event + if _, err := prepared.Run(shell, func(event progress.Event) { events = append(events, event) }); err != nil { + t.Fatal(err) + } + if err := progress.ValidateStream(events); err != nil { + t.Fatalf("strict validation failed: %v", err) + } + + declarationEnd := 0 + for i, event := range events { + if event.Close { + declarationEnd = i + 1 + } + } + _, fixedTotal := progress.CheckpointProgress(progress.Snapshot(events[:declarationEnd])) + for prefix := declarationEnd; prefix <= len(events); prefix++ { + _, total := progress.CheckpointProgress(progress.Snapshot(events[:prefix])) + if total != fixedTotal { + t.Fatalf("prefix %d total = %d, want %d", prefix, total, fixedTotal) + } + } + + m := NewModel(nil, nil, "/repo") + m.integ.events = events + phases := m.buildIntegrationPhases() + for _, phase := range phases { + if phase.Total > 0 && !phase.Settled() { + t.Fatalf("phase did not settle: %#v", phase) + } + } +} diff --git a/internal/tui/integration_progress.go b/internal/tui/integration_progress.go index 3d8a706..cec1a75 100644 --- a/internal/tui/integration_progress.go +++ b/internal/tui/integration_progress.go @@ -1,15 +1,16 @@ package tui import ( + "errors" "maps" "path/filepath" + "strings" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/integration" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" "github.com/abiswas97/sentei/internal/state" ) @@ -18,8 +19,28 @@ type integrationFinalizedMsg struct { err error } +var errIncompleteIntegrationResult = errors.New("integration apply contract violation: incomplete terminal result") + +type integrationExecutionOutcome uint8 + +const ( + integrationExecutionCompleted integrationExecutionOutcome = iota + integrationExecutionEmpty + integrationExecutionDomainFailed + integrationExecutionMalformed +) + func (m Model) updateIntegrationProgress(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case integrationPreparedMsg: + if msg.err != nil { + m.integ.prepareErr = msg.err + m.integ.lifecycle = integrationSettling + updated, holdCmd := m.holdOrAdvance(integrationSummaryView) + return updated, holdCmd + } + return m.startPreparedIntegrationApply(msg.prepared) + case tea.KeyPressMsg: if key.Matches(msg, keys.Quit) { return m, tea.Quit @@ -28,23 +49,42 @@ func (m Model) updateIntegrationProgress(msg tea.Msg) (tea.Model, tea.Cmd) { case integrationEventMsg: m.integ.events = append(m.integ.events, msg.Event) - return m, tea.Batch(m.syncProgressBar(), waitForIntegrationEvent(m.integ.eventCh, m.integ.doneCh)) + return m, tea.Batch(m.syncProgressBar(), waitForIntegrationEvent(m.integ.eventCh, m.integ.resultCh)) case integrationApplyDoneMsg: - return m, m.finalizeIntegrationApply() + if msg.result.err != nil { + m.integ.executionErr = msg.result.err + m.integ.lifecycle = integrationSettling + finalSync := m.syncProgressBar() + updated, holdCmd := m.holdOrAdvance(integrationSummaryView) + return updated, tea.Batch(finalSync, holdCmd) + } + switch classifyIntegrationExecution(msg.result) { + case integrationExecutionCompleted, integrationExecutionEmpty: + m.integ.lifecycle = integrationSaving + return m, m.finalizeIntegrationApply() + case integrationExecutionMalformed: + m.integ.executionErr = errIncompleteIntegrationResult + } + // Domain failures are truthful progress outcomes, while malformed + // terminal results are execution-contract errors. Neither may persist. + m.integ.lifecycle = integrationSettling + updated, holdCmd := m.holdOrAdvance(integrationSummaryView) + return updated, tea.Batch(m.syncProgressBar(), holdCmd) case integrationFinalizedMsg: - m.integ.finalized = true + m.integ.lifecycle = integrationSettling + m.integ.saveErr = msg.err finalSync := m.syncProgressBar() - // The migrate flow has its own summary; hand off unchanged. - if m.integ.returnView == migrateNextView { + // Clean migration applies hand off directly. Errors use the integration + // summary first so their failure cannot disappear between flows. + if m.integ.returnView == migrateNextView && msg.err == nil { updated, holdCmd := m.holdOrAdvance(migrateNextView) return updated, tea.Batch(finalSync, holdCmd) } // In-memory current/staged are never mutated here: dismissing the // summary reloads them from persisted state, so the list always // matches disk whether the save succeeded or failed. - m.integ.saveErr = msg.err if msg.err == nil { m.worktreeGeneration++ updated, holdCmd := m.holdOrAdvance(integrationSummaryView) @@ -83,94 +123,111 @@ func (m Model) finalizeIntegrationApply() tea.Cmd { enabled = append(enabled, integ.Name) } } - err = state.Save(bareDir, &state.State{Integrations: enabled}) + persisted, err := state.Load(bareDir) + if err != nil { + return integrationFinalizedMsg{err: err} + } + persisted.Integrations = enabled + err = state.Save(bareDir, persisted) return integrationFinalizedMsg{err: err} } } func (m Model) integrationLayout() ProgressLayout { - done, total := m.integrationOverallProgress() - return ProgressLayout{ - Title: titleApplyingChanges, - Phases: m.buildIntegrationPhases(), - Width: m.width, - Height: m.height, - Hints: progressFooter, - OverallDone: done, - OverallTotal: total, - Completed: m.integ.finalized, - } + return m.withProgressDetails(ProgressLayout{ + Title: titleApplyingChanges, + Phases: m.buildIntegrationPhases(), + Width: m.width, + Height: m.progressHeight(), + Hints: progressFooter, + Completed: m.integ.lifecycle == integrationSettling, + }) } func (m Model) viewIntegrationProgress() string { + if m.integ.lifecycle == integrationPreparing { + return m.viewIntegrationPreparing() + } return m.renderProgressLayout(m.integrationLayout()) } -// buildIntegrationPhases maps apply events onto the shared phase shape, one -// phase per worktree, with every target worktree visible as pending before -// its events arrive. Failed step errors are baked into the step label. -func (m Model) buildIntegrationPhases() []phaseDisplay { - var phases []phaseDisplay - seen := make(map[string]bool) - - for _, g := range groupIntegrationEvents(m.integ.events) { - pd := phaseDisplay{name: filepath.Base(g.worktree)} - seen[g.worktree] = true - for _, s := range g.steps { - if s.ev.Status == integration.StatusSkipped { - continue // skipped steps stay hidden, as before - } - label := s.step - if s.ev.Error != nil { - // One-line rows law: live progress shows only the error's - // final line; the summary's peek and portal carry the rest. - label += " " + errorPeekLast(s.ev.Error.Error(), max(m.width-10, 20)) - } - var status pipeline.StepStatus - switch s.ev.Status { - case integration.StatusDone: - status = pipeline.StepDone - pd.done++ - case integration.StatusRunning: - status = pipeline.StepRunning - case integration.StatusFailed: - status = pipeline.StepFailed - pd.failed++ - pd.done++ - } - pd.steps = append(pd.steps, stepDisplay{name: label, status: status}) - } - pd.total = len(pd.steps) - phases = append(phases, pd) +func (m Model) viewIntegrationPreparing() string { + height := max(m.progressHeight(), 0) + if height == 0 { + return "" } - - for _, path := range m.integ.targetWorktrees { - if !seen[path] { - phases = append(phases, phaseDisplay{name: filepath.Base(path)}) + width := max(m.width, 1) + title := fitProgressLine(viewTitle(titleApplyingChanges), width) + wait := fitProgressLine(" "+shimmerLine(starFrame(m.motionTick)+" Preparing plan...", rampAccent, m.motionTick), width) + separator := fitProgressLine(viewSeparator(width), width) + + var lines []string + switch progressTier(height) { + case progressViewportEmergency: + if height == 1 { + lines = []string{wait} + } else { + lines = []string{title, wait} } + case progressViewportMinimal: + lines = []string{title, separator, wait} + default: + lines = []string{title, "", separator, "", wait} + } + if len(lines) > height { + lines = lines[:height] } - return phases + return strings.Join(lines, "\n") } -// integrationOverallProgress counts resolved unique steps against the -// upfront step total so the bar reflects the whole apply, not just the -// worktrees that have emitted events. -func (m Model) integrationOverallProgress() (done, total int) { - total = m.integ.totalSteps - resolved := make(map[string]bool) - for _, ev := range m.integ.events { - key := ev.Worktree + ":" + ev.Step - if resolved[key] { - continue +func classifyIntegrationExecution(result integrationApplyResult) integrationExecutionOutcome { + if result.empty { + if len(result.phases) == 0 { + return integrationExecutionEmpty } - if ev.Status == integration.StatusDone || ev.Status == integration.StatusFailed || ev.Status == integration.StatusSkipped { - resolved[key] = true - done++ + return integrationExecutionMalformed + } + if len(result.phases) == 0 { + return integrationExecutionMalformed + } + sawFailure := false + sawSkip := false + for _, phase := range result.phases { + if len(phase.Steps) == 0 { + return integrationExecutionMalformed } + for _, step := range phase.Steps { + switch step.Status { + case progress.StepDone: + case progress.StepFailed: + sawFailure = true + case progress.StepSkipped: + sawSkip = true + default: + return integrationExecutionMalformed + } + } + } + if sawFailure { + return integrationExecutionDomainFailed + } + if sawSkip { + return integrationExecutionMalformed } - if total == 0 { - total = done + return integrationExecutionCompleted +} + +func (m Model) buildIntegrationPhases() []progress.PhaseState { + states := progress.Snapshot(m.integ.events) + for phaseIndex := range states { + states[phaseIndex].Name = filepath.Base(states[phaseIndex].Name) + for stepIndex := range states[phaseIndex].Steps { + step := &states[phaseIndex].Steps[stepIndex] + if step.Error != nil { + step.Name += " " + errorPeekLast(step.Error.Error(), max(m.width-10, 20)) + } + } } - return done, total + return states } diff --git a/internal/tui/integration_progress_test.go b/internal/tui/integration_progress_test.go index a4adef7..397f4e1 100644 --- a/internal/tui/integration_progress_test.go +++ b/internal/tui/integration_progress_test.go @@ -7,12 +7,38 @@ import ( "strings" "testing" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/abiswas97/sentei/internal/config" "github.com/abiswas97/sentei/internal/integration" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" "github.com/abiswas97/sentei/internal/state" ) +func TestIntegrationPreparingFrameFitsEveryResponsiveTier(t *testing.T) { + for _, width := range []int{20, 40, 50, 80, 120} { + for height := 1; height <= 40; height++ { + m := NewModel(nil, nil, "/repo") + m.view = integrationProgressView + m.integ.lifecycle = integrationPreparing + m.width, m.windowHeight = width, height + + view := m.viewIntegrationProgress() + lines := strings.Split(view, "\n") + if len(lines) > height { + t.Fatalf("%dx%d preparing frame has %d rows:\n%s", width, height, len(lines), stripANSI(view)) + } + for row, line := range lines { + if got := lipgloss.Width(line); got > width { + t.Fatalf("%dx%d row %d width=%d:\n%s", width, height, row+1, got, stripANSI(view)) + } + } + } + } +} + func TestUpdateIntegrationProgress_FinalizedMsg_SaveError_DoesNotApply(t *testing.T) { m := makeIntegrationModel() m.view = integrationProgressView @@ -47,18 +73,37 @@ func makeIntegrationModel() Model { return m } +func TestIntegrationLifecycleTransitionsKeepErrorsSeparate(t *testing.T) { + m := makeIntegrationModel() + if m.integ.lifecycle != integrationIdle { + t.Fatalf("initial lifecycle = %v, want idle", m.integ.lifecycle) + } + + m.view = integrationProgressView + m.integ.lifecycle = integrationPreparing + prepareErr := errors.New("prepare failed") + updated, _ := m.updateIntegrationProgress(integrationPreparedMsg{err: prepareErr}) + m = updated.(Model) + if m.integ.lifecycle != integrationSettling || !errors.Is(m.integ.prepareErr, prepareErr) { + t.Fatalf("after preparation failure: lifecycle=%v prepareErr=%v", m.integ.lifecycle, m.integ.prepareErr) + } + if m.integ.executionErr != nil || m.integ.saveErr != nil { + t.Fatalf("preparation failure polluted later errors: execution=%v save=%v", m.integ.executionErr, m.integ.saveErr) + } +} + func TestUpdateIntegrationProgress_EventMsg(t *testing.T) { m := makeIntegrationModel() m.view = integrationProgressView - ch := make(chan integration.ManagerEvent, 1) - doneCh := make(chan struct{}, 1) + ch := make(chan progress.Event, 1) + resultCh := make(chan integrationApplyResult, 1) m.integ.eventCh = ch - m.integ.doneCh = doneCh + m.integ.resultCh = resultCh - ev := integration.ManagerEvent{ - Worktree: "/repo/main", - Step: "Install code-review-graph", - Status: integration.StatusRunning, + ev := progress.Event{ + Phase: "/repo/main", + Step: "Install code-review-graph", + Status: progress.StepRunning, } updated, _ := m.updateIntegrationProgress(integrationEventMsg{Event: ev}) m = updated.(Model) @@ -79,6 +124,7 @@ func TestUpdateIntegrationProgress_FinalizedMsg_DoesNotMutateInMemory(t *testing updated, _ := m.updateIntegrationProgress(integrationFinalizedMsg{err: nil}) m = updated.(Model) + m = settleNow(t, m) if m.view != integrationSummaryView { t.Errorf("expected integrationSummaryView, got %d", m.view) } @@ -106,21 +152,44 @@ func TestUpdateIntegrationProgress_FinalizedMsg_Migration(t *testing.T) { updated, _ := m.updateIntegrationProgress(integrationFinalizedMsg{err: nil}) m = updated.(Model) + m = settleNow(t, m) if m.view != migrateNextView { t.Errorf("expected migrateNextView, got %d", m.view) } + if m.integ.lifecycle != integrationIdle { + t.Fatalf("lifecycle=%v, want idle after clean migration hand-off", m.integ.lifecycle) + } // current should NOT be updated for migration flow if m.integ.current["cocoindex-code"] { t.Error("cocoindex-code should not be updated in current for migration flow") } } +func TestUpdateIntegrationProgress_MigrationSaveErrorShowsSummaryThenReturns(t *testing.T) { + m := makeIntegrationModel() + m.view = integrationProgressView + m.integ.returnView = migrateNextView + saveErr := errors.New("disk full") + + updated, _ := m.updateIntegrationProgress(integrationFinalizedMsg{err: saveErr}) + m = settleNow(t, updated.(Model)) + if m.view != integrationSummaryView || !errors.Is(m.integ.saveErr, saveErr) { + t.Fatalf("view=%v saveErr=%v, want migration integration error summary", m.view, m.integ.saveErr) + } + for _, key := range []tea.KeyPressMsg{{Code: tea.KeyEnter}, {Code: tea.KeyEsc}} { + returned, _ := m.updateIntegrationSummary(key) + if returned.(Model).view != migrateNextView { + t.Fatalf("key %v did not return to migrateNext", key.Code) + } + } +} + func TestViewIntegrationProgress_GroupsByWorktree(t *testing.T) { m := makeIntegrationModel() m.view = integrationProgressView - m.integ.events = []integration.ManagerEvent{ - {Worktree: "/repo/main", Step: "Install step", Status: integration.StatusDone}, - {Worktree: "/repo/feature", Step: "Install step", Status: integration.StatusRunning}, + m.integ.events = []progress.Event{ + {Phase: "/repo/main", Step: "Install step", Status: progress.StepDone}, + {Phase: "/repo/feature", Step: "Install step", Status: progress.StepRunning}, } output := stripAnsi(m.viewIntegrationProgress()) @@ -136,13 +205,12 @@ func TestViewIntegrationProgress_GroupsByWorktree(t *testing.T) { func TestViewIntegrationProgress_ShowsProgressBar(t *testing.T) { m := makeIntegrationModel() m.view = integrationProgressView - m.integ.totalSteps = 3 // Known upfront. - m.integ.events = []integration.ManagerEvent{ - {Worktree: "/repo/main", Step: "step1", Status: integration.StatusRunning}, - {Worktree: "/repo/main", Step: "step1", Status: integration.StatusDone}, - {Worktree: "/repo/main", Step: "step2", Status: integration.StatusRunning}, - {Worktree: "/repo/main", Step: "step2", Status: integration.StatusDone}, - {Worktree: "/repo/main", Step: "step3", Status: integration.StatusRunning}, + m.integ.events = []progress.Event{ + {Phase: "/repo/main", Step: "step1", Status: progress.StepRunning}, + {Phase: "/repo/main", Step: "step1", Status: progress.StepDone}, + {Phase: "/repo/main", Step: "step2", Status: progress.StepRunning}, + {Phase: "/repo/main", Step: "step2", Status: progress.StepDone}, + {Phase: "/repo/main", Step: "step3", Status: progress.StepRunning}, } output := stripAnsi(m.viewIntegrationProgress()) @@ -156,14 +224,13 @@ func TestViewIntegrationProgress_ShowsProgressBar(t *testing.T) { func TestViewIntegrationProgress_ProgressCountsUniqueSteps(t *testing.T) { m := makeIntegrationModel() m.view = integrationProgressView - m.integ.totalSteps = 3 - m.integ.events = []integration.ManagerEvent{ - {Worktree: "/repo/main", Step: "Setup code-review-graph", Status: integration.StatusRunning}, - {Worktree: "/repo/main", Step: "Setup code-review-graph", Status: integration.StatusDone}, - {Worktree: "/repo/main", Step: "Install cocoindex-code", Status: integration.StatusRunning}, - {Worktree: "/repo/main", Step: "Install cocoindex-code", Status: integration.StatusDone}, - {Worktree: "/repo/main", Step: "Setup cocoindex-code", Status: integration.StatusRunning}, - {Worktree: "/repo/main", Step: "Setup cocoindex-code", Status: integration.StatusDone}, + m.integ.events = []progress.Event{ + {Phase: "/repo/main", Step: "Setup code-review-graph", Status: progress.StepRunning}, + {Phase: "/repo/main", Step: "Setup code-review-graph", Status: progress.StepDone}, + {Phase: "/repo/main", Step: "Install cocoindex-code", Status: progress.StepRunning}, + {Phase: "/repo/main", Step: "Install cocoindex-code", Status: progress.StepDone}, + {Phase: "/repo/main", Step: "Setup cocoindex-code", Status: progress.StepRunning}, + {Phase: "/repo/main", Step: "Setup cocoindex-code", Status: progress.StepDone}, } output := stripAnsi(m.viewIntegrationProgress()) @@ -177,16 +244,23 @@ func TestViewIntegrationProgress_ProgressCountsUniqueSteps(t *testing.T) { func TestViewIntegrationProgress_TotalKnownUpfront(t *testing.T) { m := makeIntegrationModel() m.view = integrationProgressView - m.integ.totalSteps = 9 - m.integ.events = []integration.ManagerEvent{ - {Worktree: "/repo/main", Step: "Setup crg", Status: integration.StatusDone}, - {Worktree: "/repo/main", Step: "Install ccc", Status: integration.StatusRunning}, - } - - // 1 done out of 9: the upfront total is the spring target's denominator. + // The declared plan establishes the denominator before work starts: nine + // pending steps across three worktrees, one resolved so far. + m.integ.events = []progress.Event{} + for _, wt := range []string{"/repo/main", "/repo/feat-1", "/repo/feat-2"} { + for _, step := range []string{"Setup crg", "Setup ccc", "Setup third"} { + m.integ.events = append(m.integ.events, progress.Event{Phase: wt, Step: step, Status: progress.StepPending, Of: 1}) + } + } + m.integ.events = append(m.integ.events, + progress.Event{Phase: "/repo/main", Step: "Setup crg", Status: progress.StepDone}, + progress.Event{Phase: "/repo/main", Step: "Setup ccc", Status: progress.StepRunning}, + ) + + // 1 done out of 9: the declared total is the spring target's denominator. done, total := m.integrationLayout().overall() if done != 1 || total != 9 { - t.Errorf("overall() = %d/%d, want 1/9 (upfront total)", done, total) + t.Errorf("overall() = %d/%d, want 1/9 (declared total)", done, total) } if cmd := m.syncProgressBar(); cmd == nil { t.Error("expected a spring target command from the upfront total") @@ -208,6 +282,220 @@ func TestViewIntegrationProgress_Loading(t *testing.T) { } } +func TestViewIntegrationProgress_PreparingPlanIsIndeterminate(t *testing.T) { + m := makeIntegrationModel() + m.view = integrationProgressView + m.integ.lifecycle = integrationPreparing + + output := stripAnsi(m.viewIntegrationProgress()) + if !strings.Contains(output, "Preparing plan...") { + t.Fatalf("preparation copy missing:\n%s", output) + } + bar := m.terminalProgress() + if bar == nil || bar.State != tea.ProgressBarIndeterminate { + t.Fatalf("terminal progress = %#v, want indeterminate", bar) + } +} + +func TestUpdateIntegrationProgress_PreparationErrorIsReported(t *testing.T) { + m := makeIntegrationModel() + m.view = integrationProgressView + m.integ.lifecycle = integrationPreparing + m.integ.returnView = integrationListView + prepareErr := errors.New("no target worktree") + + updated, _ := m.updateIntegrationProgress(integrationPreparedMsg{err: prepareErr}) + m = updated.(Model) + if m.integ.lifecycle != integrationSettling || !errors.Is(m.integ.prepareErr, prepareErr) { + t.Fatalf("lifecycle=%v prepareErr=%v", m.integ.lifecycle, m.integ.prepareErr) + } + if m.integ.eventCh != nil { + t.Fatal("execution channel created after preparation failure") + } +} + +func TestUpdateIntegrationProgress_ExecutionErrorDoesNotFinalizeState(t *testing.T) { + m := makeIntegrationModel() + m.view = integrationProgressView + m.integ.returnView = integrationListView + runErr := errors.New("progress sink failed") + + updated, _ := m.updateIntegrationProgress(integrationApplyDoneMsg{result: integrationApplyResult{err: runErr}}) + m = updated.(Model) + if !errors.Is(m.integ.executionErr, runErr) { + t.Fatalf("executionErr = %v, want %v", m.integ.executionErr, runErr) + } + if m.integ.lifecycle != integrationSettling { + t.Fatal("execution error must finalize into the error summary") + } +} + +func TestClassifyIntegrationExecution(t *testing.T) { + one := func(status progress.StepStatus) []progress.Phase { + return []progress.Phase{{Steps: []progress.StepResult{{Status: status}}}} + } + tests := []struct { + name string + phases []progress.Phase + empty bool + want integrationExecutionOutcome + }{ + {name: "valid empty plan", empty: true, want: integrationExecutionEmpty}, + {name: "missing result is not an empty plan", want: integrationExecutionMalformed}, + {name: "all done", phases: one(progress.StepDone), want: integrationExecutionCompleted}, + {name: "ordinary failure", phases: one(progress.StepFailed), want: integrationExecutionDomainFailed}, + {name: "skip without failure", phases: one(progress.StepSkipped), want: integrationExecutionMalformed}, + {name: "unresolved", phases: one(progress.StepPending), want: integrationExecutionMalformed}, + {name: "failure with blocked skip", phases: []progress.Phase{{Steps: []progress.StepResult{{Status: progress.StepFailed}, {Status: progress.StepSkipped}}}}, want: integrationExecutionDomainFailed}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := classifyIntegrationExecution(integrationApplyResult{phases: tt.phases, empty: tt.empty}); got != tt.want { + t.Fatalf("classifyIntegrationExecution() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestUpdateIntegrationProgress_MalformedResultIsContractErrorAndPreservesState(t *testing.T) { + tests := []struct { + name string + phases []progress.Phase + events []progress.Event + }{ + {name: "missing phases"}, + {name: "pending", phases: []progress.Phase{{Name: "/repo/main", Steps: []progress.StepResult{{Name: "setup", Status: progress.StepPending}}}}, events: []progress.Event{{Phase: "/repo/main", Step: "setup", Status: progress.StepPending}}}, + {name: "running", phases: []progress.Phase{{Name: "/repo/main", Steps: []progress.StepResult{{Name: "setup", Status: progress.StepRunning}}}}, events: []progress.Event{{Phase: "/repo/main", Step: "setup", Status: progress.StepRunning}}}, + {name: "zero status", phases: []progress.Phase{{Name: "/repo/main", Steps: []progress.StepResult{{Name: "setup"}}}}}, + {name: "skip only", phases: []progress.Phase{{Name: "/repo/main", Steps: []progress.StepResult{{Name: "setup", Status: progress.StepSkipped}}}}, events: []progress.Event{{Phase: "/repo/main", Step: "setup", Status: progress.StepSkipped}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmp := t.TempDir() + bareDir := filepath.Join(tmp, ".bare") + if err := os.Mkdir(bareDir, 0o755); err != nil { + t.Fatal(err) + } + if err := state.Save(bareDir, &state.State{Integrations: []string{"code-review-graph"}, LifetimeRemoved: 4}); err != nil { + t.Fatal(err) + } + m := makeIntegrationModel() + m.view = integrationProgressView + m.repoPath = tmp + m.runner = bareDirRunner(tmp) + m.integ.returnView = integrationListView + m.integ.staged = map[string]bool{"cocoindex-code": true} + m.integ.events = tt.events + + updated, _ := m.updateIntegrationProgress(integrationApplyDoneMsg{result: integrationApplyResult{ + phases: tt.phases, + }}) + m = updated.(Model) + if m.integ.lifecycle != integrationSettling || m.integ.executionErr == nil || !strings.Contains(m.integ.executionErr.Error(), "incomplete terminal result") { + t.Fatalf("lifecycle=%v executionErr=%v, want incomplete-result contract error", m.integ.lifecycle, m.integ.executionErr) + } + persisted, err := state.Load(bareDir) + if err != nil { + t.Fatal(err) + } + if len(persisted.Integrations) != 1 || persisted.Integrations[0] != "code-review-graph" || persisted.LifetimeRemoved != 4 { + t.Fatalf("state changed after malformed result: %#v", persisted) + } + m = settleNow(t, m) + if m.view != integrationSummaryView { + t.Fatalf("view=%v, want integration error summary", m.view) + } + view := stripAnsi(m.viewIntegrationSummary()) + if strings.Count(view, "Integration execution failed:") != 1 { + t.Fatalf("expected exactly one execution-error verdict:\n%s", view) + } + for _, forbidden := range []string{"No integration work was needed", "steps applied", "step applied"} { + if strings.Contains(view, forbidden) { + t.Fatalf("malformed result rendered success %q:\n%s", forbidden, view) + } + } + }) + } +} + +func TestUpdateIntegrationProgress_DomainFailureWithBlockedSkipIsNotContractError(t *testing.T) { + tmp := t.TempDir() + bareDir := filepath.Join(tmp, ".bare") + if err := os.Mkdir(bareDir, 0o755); err != nil { + t.Fatal(err) + } + if err := state.Save(bareDir, &state.State{Integrations: []string{"code-review-graph"}, LifetimeRemoved: 4}); err != nil { + t.Fatal(err) + } + m := makeIntegrationModel() + m.view = integrationProgressView + m.repoPath = tmp + m.runner = bareDirRunner(tmp) + m.integ.returnView = integrationListView + m.integ.staged = map[string]bool{"cocoindex-code": true} + m.integ.events = []progress.Event{ + {Phase: "/repo/main", Step: "install", Status: progress.StepFailed, Error: errors.New("install failed")}, + {Phase: "/repo/main", Step: "setup", Status: progress.StepSkipped, Message: "blocked by install"}, + } + result := integrationApplyResult{phases: []progress.Phase{{Name: "/repo/main", Steps: []progress.StepResult{ + {Name: "install", Status: progress.StepFailed, Error: errors.New("install failed")}, + {Name: "setup", Status: progress.StepSkipped, Message: "blocked by install"}, + }}}} + + updated, _ := m.updateIntegrationProgress(integrationApplyDoneMsg{result: result}) + m = updated.(Model) + if m.integ.executionErr != nil || m.integ.lifecycle != integrationSettling { + t.Fatalf("executionErr=%v lifecycle=%v, want ordinary failed outcome", m.integ.executionErr, m.integ.lifecycle) + } + persisted, err := state.Load(bareDir) + if err != nil { + t.Fatal(err) + } + if len(persisted.Integrations) != 1 || persisted.Integrations[0] != "code-review-graph" || persisted.LifetimeRemoved != 4 { + t.Fatalf("state changed after domain failure: %#v", persisted) + } + m = settleNow(t, m) + view := stripAnsi(m.viewIntegrationSummary()) + if strings.Contains(view, "Integration execution failed:") || strings.Count(view, "1 failed") != 1 { + t.Fatalf("domain failure rendered as contract error:\n%s", view) + } +} + +func TestUpdateIntegrationProgress_EmptyExecutionSavesDesiredState(t *testing.T) { + tmp := t.TempDir() + bareDir := filepath.Join(tmp, ".bare") + if err := os.Mkdir(bareDir, 0o755); err != nil { + t.Fatal(err) + } + if err := state.Save(bareDir, &state.State{Integrations: []string{"code-review-graph"}, LifetimeRemoved: 6}); err != nil { + t.Fatal(err) + } + m := makeIntegrationModel() + m.view = integrationProgressView + m.repoPath = tmp + m.runner = bareDirRunner(tmp) + m.integ.returnView = integrationListView + m.integ.staged = map[string]bool{} + + updated, saveCmd := m.updateIntegrationProgress(integrationApplyDoneMsg{result: integrationApplyResult{phases: nil, empty: true}}) + m = updated.(Model) + if m.integ.lifecycle != integrationSaving || saveCmd == nil { + t.Fatalf("lifecycle=%v saveCmd=%v, want saving", m.integ.lifecycle, saveCmd) + } + updated, _ = m.updateIntegrationProgress(saveCmd()) + m = updated.(Model) + if m.integ.lifecycle != integrationSettling || m.integ.saveErr != nil { + t.Fatalf("lifecycle=%v saveErr=%v, want clean settling", m.integ.lifecycle, m.integ.saveErr) + } + persisted, err := state.Load(bareDir) + if err != nil { + t.Fatal(err) + } + if len(persisted.Integrations) != 0 || persisted.LifetimeRemoved != 6 { + t.Fatalf("persisted state = %#v, want empty integrations and preserved lifetime", persisted) + } +} + func TestFinalizeIntegrationApply_SavesStagedIntegrations(t *testing.T) { tmp := t.TempDir() if err := os.Mkdir(filepath.Join(tmp, ".bare"), 0o755); err != nil { @@ -238,6 +526,60 @@ func TestFinalizeIntegrationApply_SavesStagedIntegrations(t *testing.T) { } } +func TestFinalizeIntegrationApply_PreservesLifetimeRemoved(t *testing.T) { + tmp := t.TempDir() + bareDir := filepath.Join(tmp, ".bare") + if err := os.Mkdir(bareDir, 0o755); err != nil { + t.Fatal(err) + } + if err := state.Save(bareDir, &state.State{Integrations: []string{"old"}, LifetimeRemoved: 17}); err != nil { + t.Fatal(err) + } + m := makeIntegrationModel() + m.repoPath = tmp + m.runner = bareDirRunner(tmp) + m.integ.staged = map[string]bool{"code-review-graph": true} + + if err := m.finalizeIntegrationApply()().(integrationFinalizedMsg).err; err != nil { + t.Fatal(err) + } + got, err := state.Load(bareDir) + if err != nil { + t.Fatal(err) + } + if got.LifetimeRemoved != 17 { + t.Fatalf("LifetimeRemoved = %d, want 17", got.LifetimeRemoved) + } +} + +func TestFinalizeIntegrationApply_InvalidJSONFailsWithoutOverwrite(t *testing.T) { + tmp := t.TempDir() + bareDir := filepath.Join(tmp, ".bare") + if err := os.Mkdir(bareDir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(bareDir, "sentei.json") + original := []byte("{ definitely not json\n") + if err := os.WriteFile(path, original, 0o644); err != nil { + t.Fatal(err) + } + m := makeIntegrationModel() + m.repoPath = tmp + m.runner = bareDirRunner(tmp) + m.integ.staged = map[string]bool{"code-review-graph": true} + + if err := m.finalizeIntegrationApply()().(integrationFinalizedMsg).err; err == nil { + t.Fatal("invalid existing state must fail the save") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(original) { + t.Fatalf("invalid state was overwritten: %q", got) + } +} + func TestFinalizeIntegrationApply_MigrateSavesUnderBareRoot(t *testing.T) { bareRoot := t.TempDir() if err := os.Mkdir(filepath.Join(bareRoot, ".bare"), 0o755); err != nil { diff --git a/internal/tui/integration_summary.go b/internal/tui/integration_summary.go index 7320a56..730e2b6 100644 --- a/internal/tui/integration_summary.go +++ b/internal/tui/integration_summary.go @@ -8,41 +8,34 @@ import ( "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" - "github.com/abiswas97/sentei/internal/integration" + "github.com/abiswas97/sentei/internal/progress" ) type integrationStepOutcome struct { step string - ev integration.ManagerEvent + ev progress.Event } type integrationWorktreeOutcomes struct { worktree string steps []integrationStepOutcome + closed bool } -// groupIntegrationEvents folds an apply's event stream into per-worktree step -// outcomes: groups in first-seen order, one entry per step holding its latest -// event. Shared by the progress and summary views. -func groupIntegrationEvents(events []integration.ManagerEvent) []integrationWorktreeOutcomes { - var groups []integrationWorktreeOutcomes - groupIndex := make(map[string]int) - stepIndex := make(map[string]map[string]int) - - for _, ev := range events { - gi, exists := groupIndex[ev.Worktree] - if !exists { - gi = len(groups) - groupIndex[ev.Worktree] = gi - groups = append(groups, integrationWorktreeOutcomes{worktree: ev.Worktree}) - stepIndex[ev.Worktree] = make(map[string]int) - } - if si, exists := stepIndex[ev.Worktree][ev.Step]; exists { - groups[gi].steps[si].ev = ev - } else { - stepIndex[ev.Worktree][ev.Step] = len(groups[gi].steps) - groups[gi].steps = append(groups[gi].steps, integrationStepOutcome{step: ev.Step, ev: ev}) +// groupIntegrationEvents projects the canonical progress snapshot into the +// summary's presentation shape without recomputing status, counts, or closure. +func groupIntegrationEvents(events []progress.Event) []integrationWorktreeOutcomes { + states := progress.Snapshot(events) + groups := make([]integrationWorktreeOutcomes, 0, len(states)) + for _, phase := range states { + group := integrationWorktreeOutcomes{worktree: phase.Name, closed: phase.Closed} + for _, step := range phase.Steps { + group.steps = append(group.steps, integrationStepOutcome{ + step: step.Name, + ev: progress.Event{Status: step.Status, Message: step.Message, Error: step.Error}, + }) } + groups = append(groups, group) } return groups } @@ -52,6 +45,11 @@ func (m Model) updateIntegrationSummary(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: switch { case key.Matches(msg, keys.Confirm), key.Matches(msg, keys.Back): + m.integ.lifecycle = integrationIdle + if m.integ.returnView == migrateNextView { + m.view = migrateNextView + return m, nil + } // Reload from disk so the list's active/staged markers always // match persisted state, on success and save-failure alike. m.view = integrationListView @@ -72,9 +70,9 @@ func countIntegrationOutcomes(groups []integrationWorktreeOutcomes) (applied, fa for _, g := range groups { for _, s := range g.steps { switch s.ev.Status { - case integration.StatusDone: + case progress.StepDone: applied++ - case integration.StatusFailed: + case progress.StepFailed: failed++ } } @@ -84,7 +82,7 @@ func countIntegrationOutcomes(groups []integrationWorktreeOutcomes) (applied, fa func groupHasFailure(g integrationWorktreeOutcomes) bool { for _, s := range g.steps { - if s.ev.Status == integration.StatusFailed { + if s.ev.Status == progress.StepFailed { return true } } @@ -119,9 +117,15 @@ func renderIntegrationOutcomes(b *strings.Builder, groups []integrationWorktreeO fmt.Fprintf(b, " %s\n", filepath.Base(g.worktree)) for _, s := range g.steps { switch s.ev.Status { - case integration.StatusDone: + case progress.StepDone: fmt.Fprintf(b, " %s %s\n", styleIndicatorDone.Render(indicatorDone), s.step) - case integration.StatusFailed: + case progress.StepSkipped: + reason := "" + if s.ev.Message != "" { + reason = " (" + s.ev.Message + ")" + } + fmt.Fprintf(b, " %s\n", styleDim.Render("– "+s.step+" – skipped"+reason)) + case progress.StepFailed: fmt.Fprintf(b, " %s %s\n", styleIndicatorFailed.Render(indicatorFailed), s.step) if s.ev.Error == nil { continue @@ -151,64 +155,193 @@ func renderIntegrationOutcomes(b *strings.Builder, groups []integrationWorktreeO // failure carries error output (the inline peek promises "? for full output"). func (m Model) integrationSummaryDetailContent() (string, string) { groups := orderOutcomesFailuresFirst(groupIntegrationEvents(m.integ.events)) - if len(groups) <= inlineSummaryPreview && !outcomesHaveErrorOutput(groups) { + var b strings.Builder + for _, stage := range []struct { + label string + err error + }{ + {"Preparation", m.integ.prepareErr}, + {"Execution", m.integ.executionErr}, + {"Saving state", m.integ.saveErr}, + } { + if stage.err == nil { + continue + } + writeProgressDetailValue(&b, "", styleError.Render(stage.label+" failed:"), m.portal.contentWidth()) + writeProgressDetailValue(&b, " ", stage.err.Error(), m.portal.contentWidth()) + b.WriteString("\n") + } + if b.Len() == 0 && len(groups) <= inlineSummaryPreview && !outcomesHaveErrorOutput(groups) { return "", "" } - var b strings.Builder - renderIntegrationOutcomes(&b, groups, 0) + var outcomes strings.Builder + renderIntegrationOutcomes(&outcomes, groups, 0) + for _, line := range strings.Split(strings.TrimRight(outcomes.String(), "\n"), "\n") { + writeProgressDetailValue(&b, "", line, m.portal.contentWidth()) + } return portalApplyDetails, strings.TrimRight(b.String(), "\n") } func (m Model) viewIntegrationSummary() string { - var b strings.Builder - - b.WriteString(viewTitle(titleApplyComplete)) - b.WriteString("\n\n") - b.WriteString(viewSeparator(m.width)) - b.WriteString("\n\n") - groups := orderOutcomesFailuresFirst(groupIntegrationEvents(m.integ.events)) applied, failed := countIntegrationOutcomes(groups) + // "Complete" would oversell a run with failures: the title states the + // outcome, and the headline leads with the count that matters. + title := titleApplyComplete + if failed > 0 || m.integ.prepareErr != nil || m.integ.executionErr != nil || m.integ.saveErr != nil { + title = titleApplyErrors + } + header := []string{viewTitle(title), "", viewSeparator(m.width), ""} + if m.integ.saveErr != nil { - b.WriteString(styleError.Render(truncateWithEllipsis( + header = append(header, styleError.Render(truncateWithEllipsis( fmt.Sprintf(" %s Integration state was not saved: %s", indicatorFailed, m.integ.saveErr), - max(m.width, 40)))) - b.WriteString("\n") - b.WriteString(styleDim.Render(" The list will show what is actually on disk.")) - b.WriteString("\n\n") + max(m.width, 40))), styleDim.Render(" The list will show what is actually on disk."), "") } - - switch failed { - case 0: - b.WriteString(styleSuccess.Render(fmt.Sprintf(" %s %d %s applied", indicatorDone, applied, pluralize(applied, "step", "steps")))) - default: - fmt.Fprintf(&b, " %s, %s", - styleSuccess.Render(fmt.Sprintf("%d %s applied", applied, pluralize(applied, "step", "steps"))), - styleError.Render(fmt.Sprintf("%d failed", failed)), - ) + if m.integ.prepareErr != nil { + header = append(header, styleError.Render(truncateWithEllipsis( + fmt.Sprintf(" %s Integration preparation failed: %s", indicatorFailed, m.integ.prepareErr), + max(m.width, 40))), "") + } + if m.integ.executionErr != nil { + header = append(header, styleError.Render(truncateWithEllipsis( + fmt.Sprintf(" %s Integration execution failed: %s", indicatorFailed, m.integ.executionErr), + max(m.width, 40))), "") } - b.WriteString("\n\n") - shown := min(len(groups), inlineSummaryPreview) - renderIntegrationOutcomes(&b, groups[:shown], m.width) - if rest := len(groups) - shown; rest > 0 { - b.WriteString(styleDim.Render(fmt.Sprintf(" and %d more %s — ? for details", - rest, pluralize(rest, "worktree", "worktrees")))) - b.WriteString("\n\n") + if m.integ.prepareErr == nil && m.integ.executionErr == nil && m.integ.saveErr == nil { + var verdict strings.Builder + switch { + case failed > 0: + verdict.WriteString(" ") + verdict.WriteString(styleError.Render(fmt.Sprintf("%s %d failed", indicatorFailed, failed))) + if applied > 0 { + verdict.WriteString(", ") + verdict.WriteString(styleSuccess.Render(fmt.Sprintf("%d %s applied", applied, pluralize(applied, "step", "steps")))) + } + case applied == 0: + verdict.WriteString(styleDim.Render(" No integration work was needed")) + default: + verdict.WriteString(styleSuccess.Render(fmt.Sprintf(" %s %d %s applied", indicatorDone, applied, pluralize(applied, "step", "steps")))) + } + header = append(header, verdict.String(), "") } - b.WriteString(viewSeparator(m.width)) - b.WriteString("\n\n") hints := []key.Binding{integrationsOpenHint} - if len(groups) > shown || outcomesHaveErrorOutput(groups) { + if _, detail := m.integrationSummaryDetailContent(); detail != "" { hints = append(hints, detailsHint) } hints = append(hints, keys.Quit) - b.WriteString(viewFooter(m.width, hints)) - b.WriteString("\n") + footer := []string{viewSeparator(m.width), "", viewFooter(m.width, hints)} + height := m.windowHeight + if height <= 0 { + height = m.height + } + header, footer = compactIntegrationSummaryChrome(header, footer, height) + + lines := append([]string(nil), header...) + available := max(height-len(header)-len(footer), 0) + shown := 0 + limit := min(len(groups), inlineSummaryPreview) + for shown < limit { + block := inlineIntegrationGroupLines(groups[shown], m.width, false) + reserve := 0 + if shown+1 < len(groups) { + reserve = 2 + } + if len(block)+reserve > available { + break + } + lines = append(lines, block...) + available -= len(block) + shown++ + } + if shown == 0 && len(groups) > 0 && groupHasFailure(groups[0]) { + block := inlineIntegrationGroupLines(groups[0], m.width, true) + if len(block) <= available { + lines = append(lines, block...) + available -= len(block) + shown = 1 + } + } + if rest := len(groups) - shown; rest > 0 && available >= 2 { + lines = append(lines, styleDim.Render(fmt.Sprintf(" and %d more %s — ? for details", + rest, pluralize(rest, "worktree", "worktrees"))), "") + } + lines = append(lines, footer...) + for i := range lines { + lines[i] = fitProgressLine(lines[i], m.width) + } + return strings.Join(lines, "\n") +} + +func compactIntegrationSummaryChrome(header, footer []string, height int) ([]string, []string) { + for len(header)+len(footer) > height { + removed := false + for i := len(header) - 1; i >= 0; i-- { + if header[i] == "" { + header = append(header[:i], header[i+1:]...) + removed = true + break + } + } + if removed { + continue + } + for i := len(footer) - 1; i >= 0; i-- { + if footer[i] == "" { + footer = append(footer[:i], footer[i+1:]...) + removed = true + break + } + } + if removed { + continue + } + // The top separator is decorative; the fixed footer separator remains. + if len(header) > 1 { + header = append(header[:1], header[2:]...) + continue + } + // In emergency tiers, keep the title and action footer before optional + // error prose, then keep the title alone at one row. + if len(header) > 1 { + header = header[:len(header)-1] + continue + } + if len(footer) > 1 { + footer = footer[1:] + continue + } + if len(footer) == 1 { + footer = nil + continue + } + break + } + return header, footer +} - return b.String() +func inlineIntegrationGroupLines(group integrationWorktreeOutcomes, width int, concise bool) []string { + var b strings.Builder + if !concise { + renderIntegrationOutcomes(&b, []integrationWorktreeOutcomes{group}, width) + } else { + fmt.Fprintf(&b, " %s\n", filepath.Base(group.worktree)) + for _, step := range group.steps { + if step.ev.Status == progress.StepFailed { + fmt.Fprintf(&b, " %s %s\n", styleIndicatorFailed.Render(indicatorFailed), step.step) + } + } + b.WriteString("\n") + } + text := strings.TrimSuffix(b.String(), "\n") + lines := strings.Split(text, "\n") + for i := range lines { + lines[i] = fitProgressLine(lines[i], width) + } + return lines } // outcomesHaveErrorOutput reports whether any failed step carries error text @@ -216,7 +349,7 @@ func (m Model) viewIntegrationSummary() string { func outcomesHaveErrorOutput(groups []integrationWorktreeOutcomes) bool { for _, g := range groups { for _, s := range g.steps { - if s.ev.Status == integration.StatusFailed && s.ev.Error != nil { + if s.ev.Status == progress.StepFailed && s.ev.Error != nil { return true } } diff --git a/internal/tui/integration_summary_test.go b/internal/tui/integration_summary_test.go index 5b22644..404b3c4 100644 --- a/internal/tui/integration_summary_test.go +++ b/internal/tui/integration_summary_test.go @@ -7,17 +7,144 @@ import ( "testing" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" - "github.com/abiswas97/sentei/internal/integration" + "github.com/abiswas97/sentei/internal/progress" ) -func doneEventsForWorktrees(n int) []integration.ManagerEvent { - var events []integration.ManagerEvent +func demoIntegrationFailureEvents() []progress.Event { + var events []progress.Event + for _, worktree := range []string{"/repo/worktrees/alpha", "/repo/worktrees/beta", "/repo/worktrees/gamma"} { + events = append(events, + progress.Event{Phase: worktree, PhaseLabel: worktree, Step: "setup", StepLabel: "Setup cocoindex-code", Status: progress.StepPending, Of: 1}, + progress.Event{Phase: worktree, PhaseLabel: worktree, Step: "gitignore", StepLabel: "Update .gitignore for cocoindex-code", Status: progress.StepPending, Of: 1}, + progress.Event{Phase: worktree, PhaseLabel: worktree, Close: true}, + progress.Event{Phase: worktree, Step: "setup", Status: progress.StepFailed, Error: errors.New("ccc init && ccc index: deterministic index failure\nexit status 17")}, + progress.Event{Phase: worktree, Step: "gitignore", Status: progress.StepSkipped, Message: "blocked by Setup cocoindex-code"}, + ) + } + return events +} + +func TestViewIntegrationSummary_DemoFailuresFitEveryTerminal(t *testing.T) { + for _, width := range []int{20, 40, 80, 120} { + for height := 1; height <= 40; height++ { + t.Run(fmt.Sprintf("%dx%d", width, height), func(t *testing.T) { + m := makeIntegrationModel() + m.view = integrationSummaryView + m.width, m.windowHeight, m.height = width, height, max(height-viewChromeRows, 5) + m.integ.events = demoIntegrationFailureEvents() + + view := m.viewIntegrationSummary() + lines := strings.Split(strings.TrimSuffix(view, "\n"), "\n") + if got := lipgloss.Height(view); got > height { + t.Fatalf("summary has %d rows, terminal has %d:\n%s", got, height, stripAnsi(view)) + } + for row, line := range lines { + if got := ansi.StringWidth(line); got > width { + t.Fatalf("row %d width=%d exceeds %d: %q", row+1, got, width, stripAnsi(line)) + } + } + }) + } + } +} + +func TestViewIntegrationSummary_TopLevelErrorRemainsActionableWhenCompact(t *testing.T) { + longError := "manifest is malformed: " + strings.Repeat("dependency-resolution-context-", 8) + for _, height := range []int{3, 4} { + m := makeIntegrationModel() + m.view = integrationSummaryView + m.width, m.windowHeight = 80, height + m.portal = m.portal.SetSize(80, 24) + m.integ.prepareErr = errors.New(longError) + + view := stripAnsi(m.viewIntegrationSummary()) + if !strings.Contains(view, "manifest is malformed") && !strings.Contains(view, "details") { + t.Fatalf("height %d hid the top-level error without a details affordance:\n%s", height, view) + } + _, detail := m.integrationSummaryDetailContent() + if !strings.Contains(stripAnsi(detail), "manifest is malformed") { + t.Fatalf("height %d detail portal lost top-level error: %q", height, stripAnsi(detail)) + } + for row, line := range strings.Split(detail, "\n") { + if got := ansi.StringWidth(line); got > m.portal.contentWidth() { + t.Fatalf("height %d detail row %d width=%d exceeds portal width=%d: %q", height, row+1, got, m.portal.contentWidth(), stripAnsi(line)) + } + } + } +} + +func TestIntegrationSummaryDetail_WrapsLongStepErrorsToPortalWidth(t *testing.T) { + longError := strings.Repeat("index dependency resolution failed ", 12) + m := makeIntegrationModel() + m.view = integrationSummaryView + m.width, m.windowHeight = 40, 24 + m.portal = m.portal.SetSize(40, 24) + m.integ.events = []progress.Event{ + {Phase: "worktree", PhaseLabel: "worktree", Step: "index", StepLabel: "Index", Status: progress.StepPending, Of: 1}, + {Phase: "worktree", PhaseLabel: "worktree", Close: true}, + {Phase: "worktree", Step: "index", Status: progress.StepFailed, Error: errors.New(longError)}, + } + + _, detail := m.integrationSummaryDetailContent() + if !strings.Contains(compactProgressDetailText(stripAnsi(detail)), compactProgressDetailText(longError)) { + t.Fatalf("wrapped detail lost step error:\n%s", stripAnsi(detail)) + } + for row, line := range strings.Split(detail, "\n") { + if got := ansi.StringWidth(line); got > m.portal.contentWidth() { + t.Fatalf("detail row %d width=%d exceeds portal width=%d: %q", row+1, got, m.portal.contentWidth(), stripAnsi(line)) + } + } +} + +func TestViewIntegrationSummary_DemoFailuresRemainCompleteInDetails(t *testing.T) { + m := makeIntegrationModel() + m.view = integrationSummaryView + m.width, m.windowHeight = 80, 24 + m.integ.events = demoIntegrationFailureEvents() + + view := stripAnsi(m.viewIntegrationSummary()) + if !strings.Contains(view, "3 failed") || !strings.Contains(view, "details") { + t.Fatalf("bounded summary lost verdict or details hint:\n%s", view) + } + _, detail := m.integrationSummaryDetailContent() + plain := stripAnsi(detail) + for _, want := range []string{"alpha", "beta", "gamma", "deterministic index failure", "exit status 17", "blocked by Setup cocoindex-code"} { + if !strings.Contains(compactProgressDetailText(plain), compactProgressDetailText(want)) { + t.Errorf("detail portal missing %q:\n%s", want, plain) + } + } +} + +func TestIntegrationProgressToSummary_DropsOutgoingBarContent(t *testing.T) { + t.Setenv("SENTEI_MOTION", "off") + m := makeIntegrationModel() + m.width, m.windowHeight, m.height = 80, 24, 18 + m.view = integrationProgressView + m.integ.lifecycle = integrationExecuting + m.integ.events = demoIntegrationFailureEvents() + progressView := stripAnsi(m.viewIntegrationProgress()) + if !strings.Contains(progressView, "%") { + t.Fatal("progress precondition missing bar percentage") + } + + m.view = integrationSummaryView + m.integ.lifecycle = integrationSettling + summary := stripAnsi(m.viewIntegrationSummary()) + if strings.Contains(summary, "%") || strings.Contains(summary, "█") || strings.Contains(summary, "░") { + t.Fatalf("summary content retained outgoing progress bar text:\n%s", summary) + } +} + +func doneEventsForWorktrees(n int) []progress.Event { + var events []progress.Event for i := 0; i < n; i++ { - events = append(events, integration.ManagerEvent{ - Worktree: fmt.Sprintf("/repo/feature-%d", i), - Step: "Setup code-review-graph", - Status: integration.StatusDone, + events = append(events, progress.Event{ + Phase: fmt.Sprintf("/repo/feature-%d", i), + Step: "Setup code-review-graph", + Status: progress.StepDone, }) } return events @@ -32,6 +159,7 @@ func TestUpdateIntegrationProgress_Finalized_TransitionsToSummary(t *testing.T) updated, cmd := m.updateIntegrationProgress(integrationFinalizedMsg{err: nil}) m = updated.(Model) + m = settleNow(t, m) if m.view != integrationSummaryView { t.Errorf("expected integrationSummaryView, got %d", m.view) } @@ -56,6 +184,7 @@ func TestUpdateIntegrationProgress_Finalized_SaveError_TransitionsToSummary(t *t updated, _ := m.updateIntegrationProgress(integrationFinalizedMsg{err: saveErr}) m = updated.(Model) + m = settleNow(t, m) if m.view != integrationSummaryView { t.Errorf("expected integrationSummaryView on save failure, got %d", m.view) } @@ -73,6 +202,7 @@ func TestUpdateIntegrationProgress_Finalized_SaveError_TransitionsToSummary(t *t func TestUpdateIntegrationSummary_Enter_ReturnsToListAndReloads(t *testing.T) { m := makeIntegrationModel() m.view = integrationSummaryView + m.integ.lifecycle = integrationSettling updated, cmd := m.updateIntegrationSummary(tea.KeyPressMsg{Code: tea.KeyEnter}) model := updated.(Model) @@ -83,15 +213,18 @@ func TestUpdateIntegrationSummary_Enter_ReturnsToListAndReloads(t *testing.T) { if cmd == nil { t.Error("expected loadIntegrationState Cmd so staged markers reconcile from disk") } + if model.integ.lifecycle != integrationIdle { + t.Fatalf("lifecycle=%v, want idle after dismissing summary", model.integ.lifecycle) + } } func TestViewIntegrationSummary_AllSucceeded(t *testing.T) { m := makeIntegrationModel() m.view = integrationSummaryView - m.integ.events = []integration.ManagerEvent{ - {Worktree: "/repo/feature-a", Step: "Setup code-review-graph", Status: integration.StatusRunning}, - {Worktree: "/repo/feature-a", Step: "Setup code-review-graph", Status: integration.StatusDone}, - {Worktree: "/repo/feature-b", Step: "Setup code-review-graph", Status: integration.StatusDone}, + m.integ.events = []progress.Event{ + {Phase: "/repo/feature-a", Step: "Setup code-review-graph", Status: progress.StepRunning}, + {Phase: "/repo/feature-a", Step: "Setup code-review-graph", Status: progress.StepDone}, + {Phase: "/repo/feature-b", Step: "Setup code-review-graph", Status: progress.StepDone}, } view := stripAnsi(m.viewIntegrationSummary()) @@ -105,12 +238,26 @@ func TestViewIntegrationSummary_AllSucceeded(t *testing.T) { } } +func TestViewIntegrationSummary_EmptyPlanShowsNoWorkVerdict(t *testing.T) { + m := makeIntegrationModel() + m.view = integrationSummaryView + m.integ.events = nil + + view := stripAnsi(m.viewIntegrationSummary()) + if !strings.Contains(view, "No integration work was needed") { + t.Fatalf("explicit no-work verdict missing:\n%s", view) + } + if strings.Contains(view, "0 steps applied") { + t.Fatalf("empty plan must not render a green zero-step verdict:\n%s", view) + } +} + func TestViewIntegrationSummary_PartialFailure(t *testing.T) { m := makeIntegrationModel() m.view = integrationSummaryView - m.integ.events = []integration.ManagerEvent{ - {Worktree: "/repo/feature-a", Step: "Setup code-review-graph", Status: integration.StatusDone}, - {Worktree: "/repo/feature-b", Step: "Install dependency pipx", Status: integration.StatusFailed, Error: errors.New("brew install pipx: exit 1")}, + m.integ.events = []progress.Event{ + {Phase: "/repo/feature-a", Step: "Setup code-review-graph", Status: progress.StepDone}, + {Phase: "/repo/feature-b", Step: "Install dependency pipx", Status: progress.StepFailed, Error: errors.New("brew install pipx: exit 1")}, } view := m.viewIntegrationSummary() @@ -124,8 +271,8 @@ func TestViewIntegrationSummary_PartialFailure(t *testing.T) { func TestViewIntegrationSummary_SaveError(t *testing.T) { m := makeIntegrationModel() m.view = integrationSummaryView - m.integ.events = []integration.ManagerEvent{ - {Worktree: "/repo/feature-a", Step: "Setup code-review-graph", Status: integration.StatusDone}, + m.integ.events = []progress.Event{ + {Phase: "/repo/feature-a", Step: "Setup code-review-graph", Status: progress.StepDone}, } m.integ.saveErr = errors.New("disk full") @@ -138,6 +285,39 @@ func TestViewIntegrationSummary_SaveError(t *testing.T) { } } +func TestViewIntegrationSummary_PreparationErrorUsesErrorVerdict(t *testing.T) { + m := makeIntegrationModel() + m.view = integrationSummaryView + m.integ.prepareErr = errors.New("no target worktree") + + view := stripAnsi(m.viewIntegrationSummary()) + if !strings.Contains(view, titleApplyErrors) { + t.Fatalf("preparation error must use error title, view:\n%s", view) + } + if !strings.Contains(view, "Integration preparation failed: no target worktree") { + t.Fatalf("preparation error headline missing, view:\n%s", view) + } + if strings.Contains(view, "0 steps applied") { + t.Fatalf("preparation error must not render a green zero-success verdict, view:\n%s", view) + } +} + +func TestViewIntegrationSummary_OnlyFailuresHasOneErrorVerdictAndNoZeroSuccess(t *testing.T) { + m := makeIntegrationModel() + m.view = integrationSummaryView + m.integ.events = []progress.Event{{ + Phase: "/repo/main", Step: "Setup tool", Status: progress.StepFailed, Error: errors.New("boom"), + }} + + view := stripAnsi(m.viewIntegrationSummary()) + if strings.Count(view, "1 failed") != 1 { + t.Fatalf("expected exactly one failure verdict:\n%s", view) + } + if strings.Contains(view, "0 steps applied") || strings.Contains(view, "0 step applied") { + t.Fatalf("error summary must not render a green zero-success verdict:\n%s", view) + } +} + func TestViewIntegrationSummary_OverflowPeeksAndOffersDetails(t *testing.T) { m := makeIntegrationModel() m.view = integrationSummaryView diff --git a/internal/tui/migrate_integrations.go b/internal/tui/migrate_integrations.go index b7d92f9..8c5fa19 100644 --- a/internal/tui/migrate_integrations.go +++ b/internal/tui/migrate_integrations.go @@ -1,14 +1,15 @@ package tui import ( + "errors" "fmt" - "path/filepath" "strings" "time" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" + "github.com/abiswas97/sentei/internal/git" "github.com/abiswas97/sentei/internal/integration" "github.com/abiswas97/sentei/internal/repo" ) @@ -69,7 +70,7 @@ func (m Model) updateMigrateIntegrations(msg tea.Msg) (tea.Model, tea.Cmd) { } if hasStagedSelections { m.integ.events = nil - m.integ.finalized = false + m.integ.lifecycle = integrationIdle m.integ.returnView = migrateNextView m.progressStartedAt = time.Now() m.progressToken++ @@ -89,9 +90,15 @@ func (m Model) updateMigrateIntegrations(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) startMigrateIntegrationApply() (Model, tea.Cmd) { + m.integ.prepareErr = nil + m.integ.executionErr = nil + m.integ.saveErr = nil result, ok := m.repo.result.(repo.MigrateResult) if !ok { - return m, nil + m.integ.prepareErr = errors.New("preparing migration integrations: missing migration result") + m.integ.lifecycle = integrationSettling + updated, cmd := m.holdOrAdvance(integrationSummaryView) + return updated.(Model), cmd } var toEnable []integration.Integration @@ -101,30 +108,16 @@ func (m Model) startMigrateIntegrationApply() (Model, tea.Cmd) { } } - wtPath := result.WorktreePath - if wtPath == "" { - wtPath = filepath.Join(result.BareRoot, result.Branch) - } + wtPath := m.migrateWorktreePath(result) m.integ.targetWorktrees = []string{wtPath} - - ch := make(chan integration.ManagerEvent, 50) - doneCh := make(chan struct{}, 1) - m.integ.eventCh = ch - m.integ.doneCh = doneCh + m.integ.lifecycle = integrationPreparing repoPath := result.BareRoot shell := m.shell - - go func() { - emit := func(e integration.ManagerEvent) { ch <- e } - for _, integ := range toEnable { - integration.EnableIntegration(shell, repoPath, wtPath, []string{wtPath}, integ, emit) - } - close(ch) - doneCh <- struct{}{} - }() - - return m, waitForIntegrationEvent(ch, doneCh) + return m, func() tea.Msg { + prepared, err := integration.PrepareApply(shell, repoPath, wtPath, toEnable, nil, []string{wtPath}) + return integrationPreparedMsg{prepared: prepared, err: err} + } } func (m Model) viewMigrateIntegrations() string { @@ -193,5 +186,5 @@ func (m Model) migrateWorktreePath(result repo.MigrateResult) string { if result.WorktreePath != "" { return result.WorktreePath } - return filepath.Join(result.BareRoot, result.Branch) + return git.WorktreePath(result.BareRoot, result.Branch) } diff --git a/internal/tui/migrate_integrations_test.go b/internal/tui/migrate_integrations_test.go index d1198a8..04588e3 100644 --- a/internal/tui/migrate_integrations_test.go +++ b/internal/tui/migrate_integrations_test.go @@ -1,6 +1,7 @@ package tui import ( + "errors" "os" "path/filepath" "strings" @@ -9,6 +10,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/abiswas97/sentei/internal/integration" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" "github.com/abiswas97/sentei/internal/testutil/mock" ) @@ -114,11 +116,11 @@ func TestViewMigrateIntegrations_ShowsIntroText(t *testing.T) { // drainIntegrationApply executes wait commands until the apply goroutine // reports completion, returning the events seen. -func drainIntegrationApply(t *testing.T, m Model) []integration.ManagerEvent { +func drainIntegrationApply(t *testing.T, m Model) []progress.Event { t.Helper() - var events []integration.ManagerEvent + var events []progress.Event for range 100 { - msg := waitForIntegrationEvent(m.integ.eventCh, m.integ.doneCh)() + msg := waitForIntegrationEvent(m.integ.eventCh, m.integ.resultCh)() switch msg := msg.(type) { case integrationEventMsg: events = append(events, msg.Event) @@ -167,24 +169,42 @@ func TestMigrateWorktreePath(t *testing.T) { t.Errorf("explicit path = %q, want /bare/main", got) } - derived := repo.MigrateResult{BareRoot: "/bare", Branch: "dev"} - if got := m.migrateWorktreePath(derived); got != filepath.Join("/bare", "dev") { - t.Errorf("derived path = %q, want /bare/dev", got) + derived := repo.MigrateResult{BareRoot: "/bare", Branch: "feature/dev"} + if got := m.migrateWorktreePath(derived); got != filepath.Join("/bare", "feature-dev") { + t.Errorf("derived path = %q, want /bare/feature-dev", got) } } -func TestStartMigrateIntegrationApply_NonMigrateResultIsNoop(t *testing.T) { +func TestStartMigrateIntegrationApply_MissingResultShowsPreparationError(t *testing.T) { m := makeIntegrationModel() + m.view = integrationProgressView + m.integ.returnView = migrateNextView + m.integ.executionErr = errors.New("stale execution") + m.integ.saveErr = errors.New("stale save") m.repo.result = nil updated, cmd := m.startMigrateIntegrationApply() - if cmd != nil { - t.Error("expected no command without a migrate result") + if cmd == nil { + t.Fatal("missing migration result must settle into a visible summary") } if updated.integ.eventCh != nil { t.Error("channels must not be wired without a migrate result") } + if updated.integ.lifecycle != integrationSettling || updated.integ.prepareErr == nil { + t.Fatalf("lifecycle=%v prepareErr=%v", updated.integ.lifecycle, updated.integ.prepareErr) + } + if updated.integ.executionErr != nil || updated.integ.saveErr != nil { + t.Fatalf("stale errors survived: execution=%v save=%v", updated.integ.executionErr, updated.integ.saveErr) + } + updated = settleNow(t, updated) + if updated.view != integrationSummaryView { + t.Fatalf("view = %v, want integration summary", updated.view) + } + returned, _ := updated.updateIntegrationSummary(tea.KeyPressMsg{Code: tea.KeyEnter}) + if returned.(Model).view != migrateNextView { + t.Fatal("enter from migration integration error must return to migrateNext") + } } func TestStartMigrateIntegrationApply_NoStagedCompletesImmediately(t *testing.T) { @@ -196,13 +216,15 @@ func TestStartMigrateIntegrationApply_NoStagedCompletesImmediately(t *testing.T) updated, cmd := m.startMigrateIntegrationApply() if cmd == nil { - t.Fatal("expected a wait command") + t.Fatal("expected a preparation command") } wantWT := filepath.Join(bareRoot, "main") if len(updated.integ.targetWorktrees) != 1 || updated.integ.targetWorktrees[0] != wantWT { t.Errorf("targetWorktrees = %v, want [%s] (derived from BareRoot/Branch)", updated.integ.targetWorktrees, wantWT) } + preparedModel, _ := updated.updateIntegrationProgress(cmd()) + updated = preparedModel.(Model) events := drainIntegrationApply(t, updated) if len(events) != 0 { t.Errorf("expected no events with nothing staged, got %v", events) @@ -227,13 +249,15 @@ func TestStartMigrateIntegrationApply_EnablesStagedIntegrations(t *testing.T) { updated, cmd := m.startMigrateIntegrationApply() if cmd == nil { - t.Fatal("expected a wait command") + t.Fatal("expected a preparation command") } + preparedModel, _ := updated.updateIntegrationProgress(cmd()) + updated = preparedModel.(Model) events := drainIntegrationApply(t, updated) var sawSetupDone bool for _, ev := range events { - if ev.Step == "Setup fake-tool" && ev.Status == integration.StatusDone { + if ev.StepLabel == "Setup fake-tool" && ev.Status == progress.StepDone { sawSetupDone = true } } diff --git a/internal/tui/migrate_summary.go b/internal/tui/migrate_summary.go index 302bfe9..017a2ab 100644 --- a/internal/tui/migrate_summary.go +++ b/internal/tui/migrate_summary.go @@ -9,7 +9,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" ) @@ -66,7 +66,9 @@ func (m Model) viewMigrateSummary() string { hasCriticalFailure := result.HasFailures() var failErr error if hasCriticalFailure { - if _, step, ok := pipeline.FirstFailure(result.Phases); ok { + if result.Err != nil { + failErr = result.Err + } else if _, step, ok := progress.FirstFailure(result.Phases); ok { failErr = step.Error } } diff --git a/internal/tui/migrate_summary_test.go b/internal/tui/migrate_summary_test.go index c83811d..f587a01 100644 --- a/internal/tui/migrate_summary_test.go +++ b/internal/tui/migrate_summary_test.go @@ -8,10 +8,18 @@ import ( tea "charm.land/bubbletea/v2" "github.com/abiswas97/sentei/internal/config" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" ) +func TestMigrateSummaryShowsContractError(t *testing.T) { + m := makeMigrateSummaryModel(repo.MigrateResult{Err: errors.New("delivery failed")}) + view := stripANSI(m.viewMigrateSummary()) + if !strings.Contains(view, "delivery failed") { + t.Fatalf("view = %s", view) + } +} + func makeMigrateSummaryModel(result repo.MigrateResult) Model { m := NewMenuModel(nil, nil, "/repo", &config.Config{}, repo.ContextNoRepo) m.repo.result = result @@ -25,9 +33,9 @@ func TestViewMigrateSummary_BackupFailure_NoDestructiveRestore(t *testing.T) { // restore command must NOT render against the still-intact repo. result := repo.MigrateResult{ BareRoot: "/repo/proj", - Phases: []pipeline.Phase{ - {Name: "Validate", Steps: []pipeline.StepResult{{Name: "Detect current branch", Status: pipeline.StepDone}}}, - {Name: "Backup", Steps: []pipeline.StepResult{{Name: "Copy repository to backup", Status: pipeline.StepFailed, Error: errors.New("no space left")}}}, + Phases: []progress.Phase{ + {Name: "Validate", Steps: []progress.StepResult{{Name: "Detect current branch", Status: progress.StepDone}}}, + {Name: "Backup", Steps: []progress.StepResult{{Name: "Copy repository to backup", Status: progress.StepFailed, Error: errors.New("no space left")}}}, }, // BackupPath intentionally empty } @@ -44,9 +52,9 @@ func TestViewMigrateSummary_MigrateFailure_ShowsRestore(t *testing.T) { result := repo.MigrateResult{ BareRoot: "/repo/proj", BackupPath: "/repo/proj_backup_1", - Phases: []pipeline.Phase{ - {Name: "Backup", Steps: []pipeline.StepResult{{Name: "Copy repository to backup", Status: pipeline.StepDone}}}, - {Name: "Migrate", Steps: []pipeline.StepResult{{Name: "Create bare repository", Status: pipeline.StepFailed, Error: errors.New("boom")}}}, + Phases: []progress.Phase{ + {Name: "Backup", Steps: []progress.StepResult{{Name: "Copy repository to backup", Status: progress.StepDone}}}, + {Name: "Migrate", Steps: []progress.StepResult{{Name: "Create bare repository", Status: progress.StepFailed, Error: errors.New("boom")}}}, }, } out := stripAnsi(makeMigrateSummaryModel(result).viewMigrateSummary()) @@ -63,8 +71,8 @@ func TestViewMigrateSummary_Success_OffersDeleteBackup(t *testing.T) { BareRoot: "/repo/proj", BackupPath: "/repo/proj_backup_1", Branch: "main", - Phases: []pipeline.Phase{ - {Name: "Migrate", Steps: []pipeline.StepResult{{Name: "Create worktree", Status: pipeline.StepDone}}}, + Phases: []progress.Phase{ + {Name: "Migrate", Steps: []progress.StepResult{{Name: "Create worktree", Status: progress.StepDone}}}, }, } out := stripAnsi(makeMigrateSummaryModel(result).viewMigrateSummary()) @@ -79,8 +87,8 @@ func TestViewMigrateSummary_Success_OffersDeleteBackup(t *testing.T) { func TestUpdateMigrateSummary_Failure_YIsInert(t *testing.T) { result := repo.MigrateResult{ BareRoot: "/repo/proj", - Phases: []pipeline.Phase{ - {Name: "Backup", Steps: []pipeline.StepResult{{Name: "x", Status: pipeline.StepFailed, Error: errors.New("no space")}}}, + Phases: []progress.Phase{ + {Name: "Backup", Steps: []progress.StepResult{{Name: "x", Status: progress.StepFailed, Error: errors.New("no space")}}}, }, } m := makeMigrateSummaryModel(result) diff --git a/internal/tui/model.go b/internal/tui/model.go index d991bf7..20fe221 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2,13 +2,14 @@ package tui import ( "fmt" + "os" "path/filepath" "sort" "strings" "time" "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/progress" + progressbar "charm.land/bubbles/v2/progress" "charm.land/bubbles/v2/stopwatch" "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" @@ -18,13 +19,17 @@ import ( "github.com/abiswas97/sentei/internal/creator" "github.com/abiswas97/sentei/internal/git" "github.com/abiswas97/sentei/internal/integration" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" ) -// progressHoldExpiredMsg fires when the minimum progress view duration has elapsed. +// progressSettleProbeMsg is the completion settle's hard-timeout wake-up: +// settle progress is normally observed on motion ticks and spring frames, +// and this probe guarantees the check runs even if both go quiet. // The token must match model.progressToken to guard against stale messages. -type progressHoldExpiredMsg struct{ token int } +type progressSettleProbeMsg struct{ token int } + +type progressTransitionMsg struct{ token int } type viewState int @@ -143,9 +148,9 @@ type createState struct { copyEnvFiles bool optionsCursor int - eventCh chan pipeline.Event + eventCh chan progress.Event resultCh chan creator.Result - events []pipeline.Event + events []progress.Event result *creator.Result } @@ -181,13 +186,23 @@ type repoState struct { migrateInfo MigrateInfo // Shared progress/summary - eventCh chan pipeline.Event + eventCh chan progress.Event resultCh chan interface{} // receives CreateResult, CloneResult, or MigrateResult - events []pipeline.Event + events []progress.Event result interface{} opType string // "create", "clone", "migrate" } +type integrationLifecycle uint8 + +const ( + integrationIdle integrationLifecycle = iota + integrationPreparing + integrationExecuting + integrationSaving + integrationSettling +) + // integrationState holds all state for the integration management flow. type integrationState struct { integrations []integration.Integration //nolint:unused @@ -199,18 +214,19 @@ type integrationState struct { colCursor int // 0-based column index for future expansion //nolint:unused // Progress - events []integration.ManagerEvent //nolint:unused - finalized bool // apply result arrived; the hold is showing - totalSteps int // known upfront for progress bar - targetWorktrees []string // all apply targets, pre-populated as pending phases - eventCh chan integration.ManagerEvent //nolint:unused - doneCh chan struct{} //nolint:unused + events []progress.Event //nolint:unused + lifecycle integrationLifecycle + targetWorktrees []string // all apply targets, pre-populated as pending phases + eventCh chan progress.Event //nolint:unused + resultCh chan integrationApplyResult //nolint:unused // Context: where to return after progress completes returnView viewState //nolint:unused - // Apply outcome: persistence error from the last apply, shown in the summary - saveErr error + // Apply outcome stages remain distinct so summaries identify the failed contract. + prepareErr error + executionErr error + saveErr error } type Model struct { @@ -222,6 +238,9 @@ type Model struct { context repo.RepoContext width int height int + // windowHeight is the raw terminal height. height remains the shared + // chrome-budgeted body height used by non-progress views. + windowHeight int menuItems []menuItem menuCursor int @@ -250,19 +269,30 @@ type Model struct { // motionTick is the one animation clock: star frames and shimmer band // positions derive from it as pure functions. The tick chain runs only // while a working surface is visible (motionActive). - motionTick int + motionTick int + motionPreference MotionPreference // bar springs the overall progress toward each completion target and // watch counts elapsed time; both animate only in determinate progress // views and reset between flows in holdOrAdvance. - bar progress.Model + bar progressbar.Model watch stopwatch.Model // Progress hold state — used to enforce minimum visible duration for progress views. - minProgressDuration time.Duration // 0 = no hold; set via WithMinProgressDuration - progressStartedAt time.Time // set when entering any progress view - progressToken int // bumped on each entry; guards stale timers - progressTargetView viewState // where to transition when hold expires + minProgressDuration time.Duration // 0 = no entry hold; set via WithMinProgressDuration + progressStartedAt time.Time // set when entering any progress view + progressToken int // bumped on each entry; guards stale timers + progressTargetView viewState // where to transition once settled + progressTransitionPending bool + progressTarget float64 // exact logical progress, separate from the spring's displayed fill + + // Completion settle state: after a flow's final event the view holds + // until the displayed bar fill reaches its target and stays there for + // progressSettleBeat, bounded by progressSettleTimeout. Checked on the + // motion clock and spring frames; applies in every run mode. + progressSettling bool + progressSettlingSince time.Time // when the final event arrived + progressSettledAt time.Time // when the displayed fill first arrived; zero while gliding } // ModelOption configures a Model at construction time. @@ -292,9 +322,11 @@ func NewModel(worktrees []git.Worktree, runner git.CommandRunner, repoPath strin sortAscending: true, filterInput: ti, }, - height: 20, - bar: newOverallBar(), - watch: stopwatch.New(), + width: 80, + height: 20, + bar: newOverallBar(), + watch: stopwatch.New(), + motionPreference: motionPreference(os.Getenv), } m.reindex() return m @@ -369,11 +401,13 @@ func NewMenuModel(runner git.CommandRunner, shell git.ShellRunner, repoPath stri repoPath: repoPath, cfg: cfg, context: context, + width: 80, height: 20, menuItems: items, worktreeGeneration: initGeneration, bar: newOverallBar(), watch: stopwatch.New(), + motionPreference: motionPreference(os.Getenv), remove: removeState{ selected: make(map[string]bool), sortField: SortByAge, @@ -409,29 +443,94 @@ func NewMenuModel(runner git.CommandRunner, shell git.ShellRunner, repoPath stri return m } -// holdOrAdvance either transitions to targetView immediately (if minProgressDuration -// is zero or has already elapsed) or schedules a tea.Tick for the remaining time. -// The caller must store all result state into the model before calling. +// holdOrAdvance begins the completion settle: the view may not transition to +// targetView until the displayed bar fill has reached its target and stayed +// there for progressSettleBeat (plus, in playground mode, until the entry +// hold has elapsed), hard-bounded by progressSettleTimeout. The caller must +// store all result state into the model before calling. Settle progress is +// observed on the motion clock and spring frames; a timeout tick guarantees +// a wake-up even if both go quiet. func (m Model) holdOrAdvance(targetView viewState) (tea.Model, tea.Cmd) { + now := time.Now() m.progressTargetView = targetView - if m.minProgressDuration == 0 { - m.view = targetView - // Leaving the progress view: discard the spring state so the next - // flow starts from zero, not easing down from 100%. - m.bar = newOverallBar() - m.bar.SetWidth(overallBarWidth(m.width)) - return m, nil - } - // The hold is measured from view entry, so a flow that outlives it - // would otherwise cut away mid-glide; the settle floor guarantees the - // spring a beat to visibly finish at 100%. - remaining := max(m.minProgressDuration-time.Since(m.progressStartedAt), progressSettleFloor) + m.progressSettling = true + m.progressSettlingSince = now + m.progressSettledAt = time.Time{} token := m.progressToken - return m, tea.Tick(remaining, func(time.Time) tea.Msg { - return progressHoldExpiredMsg{token: token} + if m.motionPreference == MotionOff { + if settled, advanced := m.observeSettle(now); advanced { + return settled, settledProgressTransitionCmd(token) + } else { + m = settled + } + remaining := m.minProgressDuration - now.Sub(m.progressStartedAt) + return m, tea.Tick(max(remaining, 0), func(time.Time) tea.Msg { + return progressSettleProbeMsg{token: token} + }) + } + return m, tea.Tick(progressSettleTimeout, func(time.Time) tea.Msg { + return progressSettleProbeMsg{token: token} }) } +// settleAdvanceReady reports whether the settled view may advance: the entry +// hold (if any) has elapsed AND the displayed fill has been settled for the +// beat, or the hard timeout has expired. +func (m Model) settleAdvanceReady(now time.Time) bool { + holdDone := m.minProgressDuration == 0 || now.Sub(m.progressStartedAt) >= m.minProgressDuration + if m.motionPreference == MotionOff { + return holdDone + } + beatDone := !m.progressSettledAt.IsZero() && now.Sub(m.progressSettledAt) >= progressSettleBeat + timedOut := now.Sub(m.progressSettlingSince) >= progressSettleTimeout + return (holdDone && beatDone) || timedOut +} + +// observeSettle records settle state transitions and advances the view once +// settleAdvanceReady holds. Called from the motion clock, spring frames, and +// the timeout probe so settling can never stall for lack of a wake-up. +func (m Model) observeSettle(now time.Time) (Model, bool) { + if !m.progressSettling { + return m, false + } + if m.motionPreference == MotionFull && m.bar.IsAnimating() { + // Still gliding (or a new spring target arrived): not settled. + m.progressSettledAt = time.Time{} + } else if m.progressSettledAt.IsZero() { + m.progressSettledAt = now + } + if !m.settleAdvanceReady(now) { + return m, false + } + m.progressSettling = false + m.progressTransitionPending = true + return m, true +} + +func settledProgressTransitionCmd(token int) tea.Cmd { + return tea.Sequence(tea.ClearScreen, func() tea.Msg { + return progressTransitionMsg{token: token} + }) +} + +func (m Model) completeProgressTransition() Model { + leavingIntegrationProgress := m.view == integrationProgressView + leavingProgress := m.determinateProgressActive() + m.progressTransitionPending = false + m.view = m.progressTargetView + if leavingProgress && m.portal.trigger == portalDetails { + m.portal = m.portal.Close() + } + if leavingIntegrationProgress && m.view != integrationSummaryView { + m.integ.lifecycle = integrationIdle + } + // Leaving the progress view: discard the spring state so the next + // flow starts from zero, not easing down from 100%. + m.bar = newOverallBar() + m.bar.SetWidth(overallBarWidth(m.width)) + return m +} + // SetCleanupOpts sets the cleanup options and starts at the cleanup confirmation view. func (m *Model) SetCleanupOpts(opts *cleanup.Options) { m.cleanupOpts = opts @@ -467,6 +566,9 @@ func (m *Model) SetMigrateOpts(opts *MigrateOpts) { func (m Model) Init() tea.Cmd { if m.view == menuView && m.context == repo.ContextBareRepo { + if m.motionPreference == MotionOff { + return tea.Batch(tea.RequestBackgroundColor, loadWorktreeContext(m.runner, m.repoPath, m.worktreeGeneration)) + } return tea.Batch(tea.RequestBackgroundColor, motionTickCmd(), loadWorktreeContext(m.runner, m.repoPath, m.worktreeGeneration)) } return tea.RequestBackgroundColor @@ -475,6 +577,9 @@ func (m Model) Init() tea.Cmd { // indeterminateWaitActive reports whether a spinner-bearing wait is visible: // the cleanup scan or the menu worktree-context load. func (m Model) indeterminateWaitActive() bool { + if m.view == integrationProgressView && m.integ.lifecycle == integrationPreparing { + return true + } if m.view == cleanupPreviewView && m.cleanupScan == nil { return true } @@ -489,13 +594,20 @@ func (m Model) indeterminateWaitActive() bool { } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if holdMsg, ok := msg.(progressHoldExpiredMsg); ok { - if holdMsg.token == m.progressToken { - m.view = m.progressTargetView - // Leaving the progress view: discard the spring state so the - // next flow starts from zero, not easing down from 100%. - m.bar = newOverallBar() - m.bar.SetWidth(overallBarWidth(m.width)) + if transition, ok := msg.(progressTransitionMsg); ok { + if transition.token == m.progressToken && m.progressTransitionPending { + m = m.completeProgressTransition() + } + return m, nil + } + + if probeMsg, ok := msg.(progressSettleProbeMsg); ok { + if probeMsg.token == m.progressToken { + var advanced bool + m, advanced = m.observeSettle(time.Now()) + if advanced { + return m, settledProgressTransitionCmd(m.progressToken) + } } return m, nil } @@ -522,17 +634,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // share the chrome-budgeted body height. No view sizes itself. m.portal = m.portal.SetSize(size.Width, size.Height) m.width = size.Width + m.windowHeight = max(size.Height, 0) m.height = max(size.Height-viewChromeRows, 5) m.bar.SetWidth(overallBarWidth(size.Width)) return m, nil } - if frame, ok := msg.(progress.FrameMsg); ok { - if !m.determinateProgressActive() { + if frame, ok := msg.(progressbar.FrameMsg); ok { + if m.motionPreference == MotionOff || !m.determinateProgressActive() { return m, nil } var cmd tea.Cmd m.bar, cmd = m.bar.Update(frame) + var advanced bool + m, advanced = m.observeSettle(time.Now()) + if advanced { + return m, tea.Batch(cmd, settledProgressTransitionCmd(m.progressToken)) + } return m, cmd } @@ -557,7 +675,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.motionActive() { return m, nil } + previousView := m.view m.motionTick++ + var advanced bool + m, advanced = m.observeSettle(time.Now()) + if advanced { + return m, settledProgressTransitionCmd(m.progressToken) + } + if m.view != previousView || !m.motionActive() { + return m, nil + } return m, motionTickCmd() } @@ -599,16 +726,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // starts the motion clock without each entry site knowing about it. wasMoving := m.motionActive() updated, cmd := m.dispatchByView(msg) - if model, ok := updated.(Model); ok && !wasMoving && model.motionActive() { - return model, tea.Batch(cmd, motionTickCmd()) + if model, ok := updated.(Model); ok { + if model.portal.trigger == portalDetails && model.determinateProgressActive() { + if title, content := model.detailContent(); content != "" { + model.portal = model.portal.Refresh(title, content) + } else { + model.portal = model.portal.Close() + } + } + if !wasMoving && model.motionActive() { + return model, tea.Batch(cmd, motionTickCmd()) + } + return model, cmd } return updated, cmd } +func (m Model) progressHeight() int { + if m.windowHeight > 0 { + return m.windowHeight + } + return m.height +} + // motionActive reports whether any working surface is on screen: an // indeterminate wait, a determinate progress view, or the cleanup result's // running line. The one gate for the one motion clock. func (m Model) motionActive() bool { + if m.motionPreference == MotionOff { + return false + } return m.indeterminateWaitActive() || m.determinateProgressActive() || (m.view == cleanupResultView && m.cleanupResult == nil) @@ -879,7 +1026,7 @@ func (m Model) terminalProgress() *tea.ProgressBar { } state := tea.ProgressBarDefault for _, p := range l.Phases { - if p.failed > 0 { + if p.Failed > 0 { state = tea.ProgressBarError } } diff --git a/internal/tui/motion.go b/internal/tui/motion.go index f869e6a..a2da8dc 100644 --- a/internal/tui/motion.go +++ b/internal/tui/motion.go @@ -33,6 +33,20 @@ const ( // and collapsing back. Every frame is one cell; done is the crystallized ✦. var starFrames = []string{"·", "✢", "✳", "✻", "✽", "✻", "✳", "✢"} +type MotionPreference uint8 + +const ( + MotionFull MotionPreference = iota + MotionOff +) + +func motionPreference(getenv func(string) string) MotionPreference { + if strings.EqualFold(getenv("SENTEI_MOTION"), "off") || strings.EqualFold(getenv("TERM"), "dumb") { + return MotionOff + } + return MotionFull +} + // motionTickMsg advances the motion clock. type motionTickMsg struct{} @@ -56,22 +70,6 @@ func starFrame(tick int) string { return starFrames[(tick/ticksPerFrame)%len(starFrames)] } -// starGlyph returns the styled standalone star for contexts outside a -// shimmer band (the stat line): frame size drives brightness, small=dim -// peak=bright, so the twinkle glows as it grows. -func starGlyph(ramp shimmerRamp, tick int) string { - ticksPerFrame := int(starInterval / motionResolution) - i := (tick / ticksPerFrame) % len(starFrames) - // Frame index distance from the peak frame (index 4): 0 at peak. - dist := i - len(starFrames)/2 - if dist < 0 { - dist = -dist - } - intensity := 1 - float64(dist)/float64(len(starFrames)/2) - color := lerpHex(ramp.base, ramp.peak, intensity) - return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(starFrame(tick)) -} - // shimmerLine renders text with a gradient band sweeping across it: each // rune's color blends from ramp.base toward ramp.peak by its distance to // the moving band center. Output is bold; stripped text equals the input. diff --git a/internal/tui/motion_preference_test.go b/internal/tui/motion_preference_test.go new file mode 100644 index 0000000..84fc501 --- /dev/null +++ b/internal/tui/motion_preference_test.go @@ -0,0 +1,249 @@ +package tui + +import ( + "errors" + "reflect" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/abiswas97/sentei/internal/config" + "github.com/abiswas97/sentei/internal/git" + "github.com/abiswas97/sentei/internal/progress" + "github.com/abiswas97/sentei/internal/repo" +) + +func TestMotionPreference_EnvironmentResolution(t *testing.T) { + tests := []struct { + name string + motion string + term string + want MotionPreference + }{ + {name: "default", term: "xterm-256color", want: MotionFull}, + {name: "explicit off case insensitive", motion: "OFF", term: "xterm-256color", want: MotionOff}, + {name: "dumb terminal case insensitive", motion: "full", term: "DuMb", want: MotionOff}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + env := map[string]string{"SENTEI_MOTION": tc.motion, "TERM": tc.term} + if got := motionPreference(func(key string) string { return env[key] }); got != tc.want { + t.Fatalf("motionPreference = %v, want %v", got, tc.want) + } + }) + } +} + +func TestMotionOff_InitSchedulesNoDecorativeTick(t *testing.T) { + t.Setenv("TERM", "dumb") + m := NewMenuModel(nil, nil, "/repo", &config.Config{}, repo.ContextBareRepo) + batch, ok := m.Init()().(tea.BatchMsg) + if !ok { + t.Fatalf("Init command = %T, want batch", m.Init()()) + } + if len(batch) != 2 { + t.Fatalf("static Init scheduled %d commands, want background detection and loading only", len(batch)) + } +} + +func TestMotionPreference_ResolvedOnceAtConstruction(t *testing.T) { + t.Setenv("TERM", "xterm-256color") + t.Setenv("SENTEI_MOTION", "off") + m := NewModel(nil, nil, "/repo") + t.Setenv("SENTEI_MOTION", "full") + if m.motionPreference != MotionOff { + t.Fatalf("constructed preference changed with environment: %v", m.motionPreference) + } +} + +func TestMotionOff_GatesDecorativeCommandsButKeepsStopwatch(t *testing.T) { + t.Setenv("TERM", "xterm-256color") + t.Setenv("SENTEI_MOTION", "off") + m := NewModel([]git.Worktree{}, nil, "/repo") + m.view = progressView + m.remove.run.events = []progress.Event{ + {Phase: "p", PhaseLabel: "P", Step: "a", StepLabel: "A", Status: progress.StepPending, Of: 2}, + {Phase: "p", Close: true}, + {Phase: "p", Step: "a", Status: progress.StepRunning, Checkpoint: 1, Of: 2}, + } + + if m.motionActive() { + t.Fatal("decorative motion remains active") + } + if updated, cmd := m.Update(motionTickMsg{}); cmd != nil || updated.(Model).motionTick != 0 { + t.Fatal("motion-off accepted or rescheduled a decorative tick") + } + cmd := m.syncProgressBar() + if cmd == nil { + t.Fatal("motion-off must still start the stopwatch") + } + if m.progressTarget != 0.5 { + t.Fatalf("exact target = %v, want 0.5", m.progressTarget) + } + if m.bar.IsAnimating() { + t.Fatal("motion-off scheduled spring frames") + } + if got := stripANSI(m.renderProgressLayout(m.removalLayout())); !strings.Contains(got, "50%") { + t.Fatalf("static bar did not render exact target:\n%s", got) + } +} + +func TestMotionOff_CompletionRespectsNonDecorativeMinimumDuration(t *testing.T) { + t.Setenv("SENTEI_MOTION", "off") + m := NewModel(nil, nil, "/repo") + m.view = progressView + m.minProgressDuration = time.Hour + m.progressStartedAt = time.Now() + + updated, cmd := m.holdOrAdvance(summaryView) + if cmd == nil || updated.(Model).view != progressView { + t.Fatal("static completion must retain the configured minimum visible duration") + } +} + +func TestProgressMotion_OnlyFocusedRunningHeadlineAnimates(t *testing.T) { + t.Setenv("TERM", "xterm-256color") + t.Setenv("SENTEI_MOTION", "full") + m := NewModel(nil, nil, "/repo") + m.view = progressView + m.motionTick = 2 + layout := ProgressLayout{Title: "T", Width: 80, Height: 24, Phases: []progress.PhaseState{ + {ID: "history", Name: "Resolved history", Total: 1, Done: 1, Closed: true, Steps: []progress.StepState{{Name: "historic step", Status: progress.StepDone}}}, + {ID: "focus", Name: "Focused running", Total: 3, Done: 1, Closed: true, Steps: []progress.StepState{ + {Name: "resolved step", Status: progress.StepDone}, + {Name: "running step", Status: progress.StepRunning}, + {Name: "pending step", Status: progress.StepPending}, + }}, + {ID: "queued", Name: "Queued pending", Total: 1, Closed: true, Steps: []progress.StepState{{Name: "queued step", Status: progress.StepPending}}}, + }} + out := m.renderProgressLayout(layout) + lineFor := func(text string) string { + for _, line := range strings.Split(out, "\n") { + if strings.Contains(stripANSI(line), text) { + return line + } + } + return "" + } + animatedHeadline := m.motion().Accent(starFrame(m.motionTick) + " Focused running") + if line := lineFor("Focused running"); line == "" || !strings.Contains(line, animatedHeadline) { + t.Fatalf("focused running headline is not shimmered: %q", line) + } + for _, static := range []string{"Resolved history", "running step", "pending step", "phase waiting"} { + if line := lineFor(static); line == "" || strings.Contains(line, starFrame(m.motionTick)) { + t.Errorf("%q must remain static: %q", static, line) + } + } + if got := strings.Count(stripANSI(out), starFrame(m.motionTick)); got != 1 { + t.Fatalf("animated frame appears %d times, want exactly one focused headline:\n%s", got, stripANSI(out)) + } +} + +func TestCompletedFailureBarNeverUsesSuccessPalette(t *testing.T) { + t.Setenv("SENTEI_MOTION", "off") + m := NewModel(nil, nil, "/repo") + m.view = progressView + m.progressTarget = 1 + layout := ProgressLayout{Title: "T", Width: 80, Height: 24, Completed: true, Phases: []progress.PhaseState{{ + Name: "Failed", Total: 1, Done: 1, Failed: 1, Closed: true, Steps: []progress.StepState{{Name: "failure", Status: progress.StepFailed}}, + }}} + if out := m.renderProgressLayout(layout); strings.Contains(out, "0;135;95") { + t.Fatalf("failure completion used success palette: %q", out) + } +} + +func TestMotionOff_SuccessCrystallizesOnceWithGreenBar(t *testing.T) { + t.Setenv("SENTEI_MOTION", "off") + m := NewModel(nil, nil, "/repo") + m.view = progressView + m.progressTarget = 1 + layout := ProgressLayout{Title: "T", Width: 80, Height: 24, Completed: true, Phases: []progress.PhaseState{{ + Name: "Complete", Total: 1, Done: 1, Closed: true, + }}} + out := m.renderProgressLayout(layout) + if !strings.Contains(out, "0;135;95") { + t.Fatalf("failure-free completion did not use success palette: %q", out) + } + if got := strings.Count(stripANSI(out), indicatorDone); got != 1 { + t.Fatalf("completion crystallized %d times, want one:\n%s", got, stripANSI(out)) + } +} + +func TestMotionTick_DoesNotRescheduleAfterSettleTransition(t *testing.T) { + m := NewModel(nil, nil, "/repo") + m.view = progressView + m.progressTargetView = summaryView + m.progressSettling = true + m.progressSettlingSince = time.Now() + m.progressSettledAt = time.Now().Add(-progressSettleBeat) + + updated, cmd := m.Update(motionTickMsg{}) + model := updated.(Model) + if model.view != progressView || !model.progressTransitionPending { + t.Fatalf("settle tick must schedule transition, view=%v pending=%v", model.view, model.progressTransitionPending) + } + if cmd == nil { + t.Fatal("settle tick must schedule the refresh-then-transition sequence") + } + if _, rescheduledMotion := cmd().(motionTickMsg); rescheduledMotion { + t.Fatal("settled motion tick rescheduled decorative motion instead of the transition") + } +} + +func TestSettledProgressTransition_DoesNotReplayCapturedWindowSize(t *testing.T) { + cmd := settledProgressTransitionCmd(7) + sequence := reflect.ValueOf(cmd()) + if sequence.Kind() != reflect.Slice { + t.Fatalf("transition command emitted %T, want ordered command sequence", cmd()) + } + + var messages []tea.Msg + for i := 0; i < sequence.Len(); i++ { + step, ok := sequence.Index(i).Interface().(tea.Cmd) + if !ok { + t.Fatalf("sequence step %d has type %T, want tea.Cmd", i, sequence.Index(i).Interface()) + } + messages = append(messages, step()) + } + for _, msg := range messages { + if size, ok := msg.(tea.WindowSizeMsg); ok { + t.Fatalf("transition replayed stale terminal size %dx%d", size.Width, size.Height) + } + } + if len(messages) != 2 || reflect.TypeOf(messages[0]) != reflect.TypeOf(tea.ClearScreen()) { + t.Fatalf("transition messages = %T, %T; want clear then private transition", messages[0], messages[1]) + } + if transition, ok := messages[1].(progressTransitionMsg); !ok || transition.token != 7 { + t.Fatalf("final transition message = %#v, want live token", messages[1]) + } +} + +func TestCompletedTopLevelErrorBarNeverUsesSuccessPalette(t *testing.T) { + t.Setenv("SENTEI_MOTION", "off") + m := NewModel(nil, nil, "/repo") + m.view = progressView + m.progressTarget = 1 + m.remove.run.result.Err = errors.New("progress delivery failed") + layout := ProgressLayout{Title: "T", Width: 80, Height: 24, Completed: true, Phases: []progress.PhaseState{{ + Name: "Complete", Total: 1, Done: 1, Closed: true, + }}} + if out := m.renderProgressLayout(layout); strings.Contains(out, "0;135;95") { + t.Fatalf("top-level progress error used success palette: %q", out) + } +} + +func TestMotionOff_CompletionAdvancesWithoutDecorativeDelay(t *testing.T) { + t.Setenv("TERM", "dumb") + m := NewModel(nil, nil, "/repo") + m.view = progressView + m.progressStartedAt = time.Now() + m.progressTarget = 1 + + updated, cmd := m.holdOrAdvance(summaryView) + model := updated.(Model) + if cmd == nil || model.view != progressView || !model.progressTransitionPending { + t.Fatalf("static completion = view %v pending=%v cmd=%v, want immediate refresh-then-transition", model.view, model.progressTransitionPending, cmd != nil) + } +} diff --git a/internal/tui/motion_test.go b/internal/tui/motion_test.go index d8ece89..8c5a6bf 100644 --- a/internal/tui/motion_test.go +++ b/internal/tui/motion_test.go @@ -7,18 +7,18 @@ import ( tea "charm.land/bubbletea/v2" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/testutil/mock" ) func runningLayout() ProgressLayout { return ProgressLayout{ Title: "T", Width: 80, Height: 30, - Phases: []phaseDisplay{ - {name: "Removing worktrees", total: 3, done: 1, steps: []stepDisplay{ - {name: "done-step", status: pipeline.StepDone}, - {name: "active-step", status: pipeline.StepRunning}, - {name: "pending-step", status: pipeline.StepPending}, + Phases: []progress.PhaseState{ + {Name: "Removing worktrees", Total: 3, Done: 1, Steps: []progress.StepState{ + {Name: "done-step", Status: progress.StepDone}, + {Name: "active-step", Status: progress.StepRunning}, + {Name: "pending-step", Status: progress.StepPending}, }}, }, } @@ -144,28 +144,13 @@ func TestMotionClock_StartsOnFlowEntry(t *testing.T) { if model.view != progressView { t.Fatalf("expected progressView after confirm, got %d", model.view) } - if got := countMotionTicks(cmd); got != 1 { - t.Errorf("entering a progress view must start exactly one tick chain, got %d", got) + batch, ok := cmd().(tea.BatchMsg) + if !ok || len(batch) != 2 { + t.Fatalf("entering a progress view must append one motion command, got %T with %d commands", batch, len(batch)) } -} - -// countMotionTicks walks a command tree counting motion tick messages, -// expanding batches. -func countMotionTicks(cmd tea.Cmd) int { - if cmd == nil { - return 0 - } - switch msg := cmd().(type) { - case motionTickMsg: - return 1 - case tea.BatchMsg: - n := 0 - for _, sub := range msg { - n += countMotionTicks(sub) - } - return n + if msg := batch[len(batch)-1](); msg != (motionTickMsg{}) { + t.Errorf("appended command = %T, want motionTickMsg", msg) } - return 0 } func TestCleanupRunningLine_Shimmers(t *testing.T) { @@ -238,8 +223,8 @@ func TestCompletedBar_SettlesGreen(t *testing.T) { working := runningLayout() done := runningLayout() - done.Phases[0].done = done.Phases[0].total - done.Phases[0].steps = nil + done.Phases[0].Done = done.Phases[0].Total + done.Phases[0].Steps = nil done.Completed = true wOut := m.renderProgressLayout(working) diff --git a/internal/tui/phase_display.go b/internal/tui/phase_display.go deleted file mode 100644 index e42db8b..0000000 --- a/internal/tui/phase_display.go +++ /dev/null @@ -1,90 +0,0 @@ -package tui - -import "github.com/abiswas97/sentei/internal/pipeline" - -type phaseDisplay struct { - name string - steps []stepDisplay - total int - done int - failed int -} - -type stepDisplay struct { - name string - status pipeline.StepStatus -} - -// buildPhaseDisplays folds a pipeline event stream into per-phase display -// state, preserving the order phases first appeared in. -func buildPhaseDisplays(events []pipeline.Event) []phaseDisplay { - phases := map[string]*phaseDisplay{} - var order []string - - for _, ev := range events { - pd, exists := phases[ev.Phase] - if !exists { - pd = &phaseDisplay{name: ev.Phase} - phases[ev.Phase] = pd - order = append(order, ev.Phase) - } - - found := false - for i := range pd.steps { - if pd.steps[i].name == ev.Step { - pd.steps[i].status = ev.Status - found = true - break - } - } - if !found { - pd.steps = append(pd.steps, stepDisplay{name: ev.Step, status: ev.Status}) - } - } - - var result []phaseDisplay - for _, name := range order { - pd := phases[name] - pd.total = len(pd.steps) - for _, s := range pd.steps { - switch s.status { - case pipeline.StepDone, pipeline.StepSkipped: - // A skipped step is resolved (non-failing); count it as done so a - // phase with a best-effort skip still reaches 100%. - pd.done++ - case pipeline.StepFailed: - pd.failed++ - pd.done++ - } - } - result = append(result, *pd) - } - return result -} - -// withPendingPhases returns displays reordered onto the canonical phase -// sequence, inserting an empty (pending) phaseDisplay for any canonical -// phase that has not emitted events yet. Phases outside the canonical list -// keep their discovery order at the end. -func withPendingPhases(displays []phaseDisplay, names ...string) []phaseDisplay { - byName := make(map[string]phaseDisplay, len(displays)) - for _, pd := range displays { - byName[pd.name] = pd - } - result := make([]phaseDisplay, 0, len(names)+len(displays)) - canonical := make(map[string]bool, len(names)) - for _, name := range names { - canonical[name] = true - if pd, ok := byName[name]; ok { - result = append(result, pd) - } else { - result = append(result, phaseDisplay{name: name}) - } - } - for _, pd := range displays { - if !canonical[pd.name] { - result = append(result, pd) - } - } - return result -} diff --git a/internal/tui/phase_display_test.go b/internal/tui/phase_display_test.go deleted file mode 100644 index 7b50090..0000000 --- a/internal/tui/phase_display_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package tui - -import ( - "testing" - - "github.com/abiswas97/sentei/internal/pipeline" -) - -func TestBuildPhaseDisplays_EmptyEvents(t *testing.T) { - if got := buildPhaseDisplays(nil); got != nil { - t.Errorf("buildPhaseDisplays(nil) = %v, want nil", got) - } -} - -func TestBuildPhaseDisplays_FoldsEventsIntoPhases(t *testing.T) { - events := []pipeline.Event{ - {Phase: "Setup", Step: "Create worktree", Status: pipeline.StepRunning}, - {Phase: "Setup", Step: "Create worktree", Status: pipeline.StepDone}, - {Phase: "Setup", Step: "Merge base", Status: pipeline.StepSkipped}, - {Phase: "Deps", Step: "npm install", Status: pipeline.StepRunning}, - {Phase: "Deps", Step: "npm install", Status: pipeline.StepFailed}, - } - - got := buildPhaseDisplays(events) - - if len(got) != 2 { - t.Fatalf("expected 2 phases, got %d: %+v", len(got), got) - } - if got[0].name != "Setup" || got[1].name != "Deps" { - t.Errorf("phase order = [%s %s], want [Setup Deps]", got[0].name, got[1].name) - } - - setup := got[0] - if setup.total != 2 || setup.done != 2 || setup.failed != 0 { - t.Errorf("Setup counts = total %d done %d failed %d, want 2/2/0 (skipped counts as done)", - setup.total, setup.done, setup.failed) - } - if setup.steps[0].status != pipeline.StepDone { - t.Error("a later event for the same step must overwrite its status") - } - - deps := got[1] - if deps.total != 1 || deps.done != 1 || deps.failed != 1 { - t.Errorf("Deps counts = total %d done %d failed %d, want 1/1/1 (failed counts as done)", - deps.total, deps.done, deps.failed) - } -} - -func TestBuildPhaseDisplays_RunningStepNotCounted(t *testing.T) { - got := buildPhaseDisplays([]pipeline.Event{ - {Phase: "Setup", Step: "Create worktree", Status: pipeline.StepRunning}, - }) - - if got[0].done != 0 || got[0].failed != 0 || got[0].total != 1 { - t.Errorf("running step counts = total %d done %d failed %d, want 1/0/0", - got[0].total, got[0].done, got[0].failed) - } -} - -func TestWithPendingPhases_InsertsMissingCanonicalPhases(t *testing.T) { - displays := []phaseDisplay{{name: "Integrations", total: 1, done: 1}} - - got := withPendingPhases(displays, "Setup", "Dependencies", "Integrations") - - if len(got) != 3 { - t.Fatalf("expected 3 phases, got %d", len(got)) - } - wantNames := []string{"Setup", "Dependencies", "Integrations"} - for i, want := range wantNames { - if got[i].name != want { - t.Errorf("phase[%d] = %q, want %q", i, got[i].name, want) - } - } - if got[0].total != 0 { - t.Error("inserted Setup phase should be empty (pending)") - } - if got[2].done != 1 { - t.Error("existing Integrations display should be carried over") - } -} - -func TestWithPendingPhases_NonCanonicalPhasesKeptAtEnd(t *testing.T) { - displays := []phaseDisplay{ - {name: "Extra"}, - {name: "Setup", done: 1, total: 1}, - } - - got := withPendingPhases(displays, "Setup") - - if len(got) != 2 { - t.Fatalf("expected 2 phases, got %d", len(got)) - } - if got[0].name != "Setup" || got[1].name != "Extra" { - t.Errorf("order = [%s %s], want canonical first, extras last", got[0].name, got[1].name) - } -} diff --git a/internal/tui/portal.go b/internal/tui/portal.go index 896c6b6..ac6a081 100644 --- a/internal/tui/portal.go +++ b/internal/tui/portal.go @@ -53,6 +53,20 @@ func (p DetailPortal) Open(trigger portalTrigger, title, content string) DetailP return p } +// Refresh replaces live detail content without disorienting a user who is +// already reading it. SetYOffset clamps the previous position when the new +// content is shorter. +func (p DetailPortal) Refresh(title, content string) DetailPortal { + offset := p.viewport.YOffset() + p.title = title + p.contentLines = strings.Count(content, "\n") + 1 + p.viewport.SetWidth(p.contentWidth()) + p.viewport.SetHeight(p.fitHeight()) + p.viewport.SetContent(content) + p.viewport.SetYOffset(offset) + return p +} + // fitHeight is the viewport height: the content's own height, capped by the // terminal budget. func (p DetailPortal) fitHeight() int { @@ -75,13 +89,19 @@ func (p DetailPortal) SetSize(width, height int) DetailPortal { // contentWidth is the viewport width inside margins, border, and padding. func (p DetailPortal) contentWidth() int { - return max(p.width-2*portalMargin-4, 20) + if p.width <= 0 { + return 20 + } + return max(p.width-2*portalMargin-4, 1) } // contentHeight is the viewport height inside margins, border, title, // separator, and hint lines. func (p DetailPortal) contentHeight() int { - return max(p.height-2*portalMargin-2-3, 3) + if p.height <= 0 { + return 3 + } + return max(p.height-2*portalMargin-2-3, 1) } // Update routes scroll keys to the viewport. Dismiss and quit are handled @@ -94,13 +114,26 @@ func (p DetailPortal) Update(msg tea.Msg) (DetailPortal, tea.Cmd) { // View renders the portal box composited centered over the background view. func (p DetailPortal) View(background string) string { + if p.width <= 0 || p.height <= 0 { + return background + } + if p.height < 7 { + lines := []string{fitProgressLine(" "+styleTitle.Render(p.title), p.width)} + if p.height > 1 { + lines = append(lines, fitProgressLine(p.viewport.View(), p.width)) + } + if p.height > 2 { + lines = append(lines, fitProgressLine(viewFooter(p.width, portalFooterStatic), p.width)) + } + return compositeOverlay(strings.Join(lines[:min(len(lines), p.height)], "\n"), background) + } var b strings.Builder // The brand is already on screen behind the box; the portal carries // just its own name. - b.WriteString(" " + styleTitle.Render(p.title)) + b.WriteString(fitProgressLine(" "+styleTitle.Render(p.title), p.contentWidth())) b.WriteString("\n") - b.WriteString(viewSeparator(p.contentWidth() + 2)) + b.WriteString(viewSeparator(p.contentWidth())) b.WriteString("\n") b.WriteString(p.viewport.View()) b.WriteString("\n") @@ -111,11 +144,11 @@ func (p DetailPortal) View(background string) string { if p.viewport.TotalLineCount() > p.viewport.VisibleLineCount() { footer = portalFooter } - hintLine := viewFooter(p.contentWidth()+2, footer) + hintLine := viewFooter(p.contentWidth(), footer) if !p.viewport.AtBottom() { hintLine += styleDim.Render(" ↓ more") } - b.WriteString(hintLine) + b.WriteString(fitProgressLine(hintLine, p.contentWidth())) // lipgloss v2 Width spans the whole block including border and padding, // so the box adds its full frame (2 border + 2 padding) to the content. diff --git a/internal/tui/portal_test.go b/internal/tui/portal_test.go index 669c533..7b093ba 100644 --- a/internal/tui/portal_test.go +++ b/internal/tui/portal_test.go @@ -5,11 +5,32 @@ import ( "testing" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/abiswas97/sentei/internal/git" "github.com/abiswas97/sentei/internal/repo" ) +func TestPortal_FitsNarrowAndEmergencyTerminals(t *testing.T) { + for _, width := range []int{20, 32} { + for height := 1; height <= 11; height++ { + var p DetailPortal + p = p.SetSize(width, height) + p = p.Open(portalDetails, "界 details with a long title", strings.Repeat("long 界🙂 detail line\n", 30)) + background := strings.TrimSuffix(strings.Repeat(strings.Repeat(" ", width)+"\n", height), "\n") + view := p.View(background) + if got := lipgloss.Height(view); got > height { + t.Fatalf("%dx%d portal height=%d:\n%s", width, height, got, stripANSI(view)) + } + for row, line := range strings.Split(view, "\n") { + if got := lipgloss.Width(line); got > width { + t.Fatalf("%dx%d portal row %d width=%d:\n%s", width, height, row+1, got, stripANSI(view)) + } + } + } + } +} + func portalTestModel() Model { m := NewMenuModel(nil, nil, "/repo", nil, repo.ContextBareRepo) m.width, m.height = 100, 26 @@ -56,6 +77,24 @@ func TestPortal_ScrollResetsOnReopen(t *testing.T) { } } +func TestPortal_RefreshPreservesOrClampsScroll(t *testing.T) { + var p DetailPortal + p = p.SetSize(40, 10) + p = p.Open(portalDetails, "Details", strings.Repeat("line\n", 50)) + p.viewport.ScrollDown(8) + offset := p.viewport.YOffset() + + p = p.Refresh("Updated details", strings.Repeat("updated\n", 50)) + if got := p.viewport.YOffset(); got != offset { + t.Fatalf("same-size refresh moved offset from %d to %d", offset, got) + } + + p = p.Refresh("Updated details", "one line") + if got := p.viewport.YOffset(); got != 0 { + t.Fatalf("short refresh did not clamp offset, got %d", got) + } +} + func TestPortal_SizingStandardTerminal(t *testing.T) { var p DetailPortal p = p.SetSize(80, 24) diff --git a/internal/tui/progress.go b/internal/tui/progress.go index bd9c21f..21b5437 100644 --- a/internal/tui/progress.go +++ b/internal/tui/progress.go @@ -1,14 +1,14 @@ package tui import ( - "fmt" + "errors" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "github.com/abiswas97/sentei/internal/cleanup" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/worktree" ) @@ -23,31 +23,18 @@ type cleanupCompleteMsg struct { Result cleanup.Result } -type worktreeDeleteStartedMsg struct{ Path string } -type worktreeDeletedMsg struct{ Path string } -type worktreeDeleteFailedMsg struct { - Path string - Err error -} -type allDeletionsCompleteMsg struct{} +type removalEventMsg struct{ event progress.Event } +type removalEventsCompleteMsg struct{} +type deletionsCompleteMsg struct{ Result worktree.DeletionResult } type pruneCompleteMsg struct{ Err error } -func waitForDeletionEvent(ch <-chan worktree.DeletionEvent) tea.Cmd { +func waitForRemovalEvent(ch <-chan progress.Event) tea.Cmd { return func() tea.Msg { ev, ok := <-ch if !ok { - return allDeletionsCompleteMsg{} - } - switch ev.Type { - case worktree.DeletionStarted: - return worktreeDeleteStartedMsg{Path: ev.Path} - case worktree.DeletionCompleted: - return worktreeDeletedMsg{Path: ev.Path} - case worktree.DeletionFailed: - return worktreeDeleteFailedMsg{Path: ev.Path, Err: ev.Error} - default: - return waitForDeletionEvent(ch)() + return removalEventsCompleteMsg{} } + return removalEventMsg{event: ev} } } @@ -78,41 +65,80 @@ func (m Model) updateProgress(msg tea.Msg) (tea.Model, tea.Cmd) { // lands here, not in updateConfirm. m.remove.run.teardownRunning = false m.remove.run.teardownResults = msg.results + m.remove.run.result.Err = errors.Join(m.remove.run.result.Err, msg.err) return m.startDeletions() - case worktreeDeleteStartedMsg: - m.remove.run.statuses[msg.Path] = statusRemoving - return m, tea.Batch(m.syncProgressBar(), waitForDeletionEvent(m.remove.run.progressCh)) - - case worktreeDeletedMsg: - m.remove.run.statuses[msg.Path] = statusRemoved - m.remove.run.result.SuccessCount++ - m.remove.run.result.Outcomes = append(m.remove.run.result.Outcomes, worktree.WorktreeOutcome{ - Path: msg.Path, - Success: true, - }) - return m, tea.Batch(m.syncProgressBar(), waitForDeletionEvent(m.remove.run.progressCh)) - - case worktreeDeleteFailedMsg: - m.remove.run.statuses[msg.Path] = statusFailed - m.remove.run.result.FailureCount++ - m.remove.run.result.Outcomes = append(m.remove.run.result.Outcomes, worktree.WorktreeOutcome{ - Path: msg.Path, - Success: false, - Error: fmt.Errorf("removing %s: %w", msg.Path, msg.Err), - }) - return m, tea.Batch(m.syncProgressBar(), waitForDeletionEvent(m.remove.run.progressCh)) - - case allDeletionsCompleteMsg: + case removalEventMsg: + m.remove.run.events = append(m.remove.run.events, msg.event) + if msg.event.Phase != worktree.RemovalPhaseID { + return m, tea.Batch(m.syncProgressBar(), waitForRemovalEvent(m.remove.run.progressCh)) + } + path := msg.event.Step + for _, target := range m.remove.run.targets { + if target.StepID == msg.event.Step { + path = target.Worktree.Path + break + } + } + switch msg.event.Status { + case progress.StepRunning: + m.remove.run.statuses[path] = statusRemoving + case progress.StepDone: + m.remove.run.statuses[path] = statusRemoved + case progress.StepFailed: + m.remove.run.statuses[path] = statusFailed + } + return m, tea.Batch(m.syncProgressBar(), waitForRemovalEvent(m.remove.run.progressCh)) + + case deletionsCompleteMsg: + msg.Result.Err = errors.Join(m.remove.run.result.Err, msg.Result.Err) + m.remove.run.result = msg.Result return m, tea.Batch(m.syncProgressBar(), runPrune(m.runner, m.repoPath)) case pruneCompleteMsg: pruneErr := msg.Err m.remove.run.pruneErr = &pruneErr + if m.remove.run.execution != nil { + if msg.Err != nil { + _, transitionErr := m.remove.run.execution.Fail(cleanupPhaseID, pruneStepID, msg.Err) + m.remove.run.result.Err = errors.Join(m.remove.run.result.Err, transitionErr) + } else { + _, transitionErr := m.remove.run.execution.Done(cleanupPhaseID, pruneStepID, "Pruned") + m.remove.run.result.Err = errors.Join(m.remove.run.result.Err, transitionErr) + } + } return m, tea.Batch(m.syncProgressBar(), runCleanup(m.runner, m.repoPath)) case cleanupCompleteMsg: m.remove.run.cleanupResult = &msg.Result + if m.remove.run.execution == nil { + m.remove.selected = make(map[string]bool) + m.worktreeGeneration++ + syncCmd := m.syncProgressBar() + updated, holdCmd := m.holdOrAdvance(summaryView) + return updated, tea.Batch(syncCmd, holdCmd, + recordRemovals(m.repoPath, m.remove.run.result.SuccessCount), + loadWorktreeContext(m.runner, m.repoPath, m.worktreeGeneration)) + } + if len(msg.Result.Errors) > 0 { + cleanupErrors := make([]error, len(msg.Result.Errors)) + for i, operationErr := range msg.Result.Errors { + cleanupErrors[i] = operationErr.Err + } + cleanupErr := errors.Join(cleanupErrors...) + m.remove.run.result.Err = errors.Join(m.remove.run.result.Err, cleanupErr) + _, transitionErr := m.remove.run.execution.Fail(cleanupPhaseID, cleanupStepID, cleanupErr) + m.remove.run.result.Err = errors.Join(m.remove.run.result.Err, transitionErr) + } else { + _, transitionErr := m.remove.run.execution.Done(cleanupPhaseID, cleanupStepID, "Cleaned") + m.remove.run.result.Err = errors.Join(m.remove.run.result.Err, transitionErr) + } + m.remove.run.result.Err = errors.Join(m.remove.run.result.Err, m.remove.run.execution.Finish("removal run complete")) + m.remove.run.result.Phases = m.remove.run.execution.Phases() + close(m.remove.run.progressCh) + return m, m.syncProgressBar() + + case removalEventsCompleteMsg: m.remove.selected = make(map[string]bool) m.worktreeGeneration++ // Final spring target before the hold: all phases are complete, so @@ -128,14 +154,14 @@ func (m Model) updateProgress(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) removalLayout() ProgressLayout { - return ProgressLayout{ + return m.withProgressDetails(ProgressLayout{ Title: titleRemoving, Completed: m.remove.run.cleanupResult != nil, Phases: m.buildRemovalPhases(), Width: m.width, - Height: m.height, + Height: m.progressHeight(), Hints: progressFooter, - } + }) } func (m Model) viewProgress() string { @@ -144,75 +170,6 @@ func (m Model) viewProgress() string { // buildRemovalPhases maps the current removal run onto the shared phase // shape consumed by ProgressLayout. -func (m Model) buildRemovalPhases() []phaseDisplay { - run := m.remove.run - var phases []phaseDisplay - - switch { - case run.teardownRunning: - phases = append(phases, phaseDisplay{ - name: "Teardown", - total: 1, - steps: []stepDisplay{{name: "Removing integration artifacts", status: pipeline.StepRunning}}, - }) - case len(run.teardownResults) > 0: - td := phaseDisplay{name: "Teardown", total: len(run.teardownResults)} - for _, r := range run.teardownResults { - td.steps = append(td.steps, stepDisplay{name: r.Name, status: r.Status}) - switch r.Status { - case pipeline.StepDone, pipeline.StepSkipped: - td.done++ - case pipeline.StepFailed: - td.failed++ - td.done++ - } - } - phases = append(phases, td) - } - - removing := phaseDisplay{name: "Removing worktrees", total: run.total()} - for _, wt := range run.worktrees { - label := worktreeLabel(wt) - var status pipeline.StepStatus - switch run.statuses[wt.Path] { - case statusRemoving: - status = pipeline.StepRunning - case statusRemoved: - status = pipeline.StepDone - removing.done++ - case statusFailed: - status = pipeline.StepFailed - removing.failed++ - removing.done++ - default: - status = pipeline.StepPending - } - removing.steps = append(removing.steps, stepDisplay{name: label, status: status}) - } - phases = append(phases, removing) - - cleanupPhase := phaseDisplay{name: "Prune & cleanup"} - if removing.total > 0 && removing.done == removing.total { - cleanupPhase.total = 2 - cleanupPhase.steps = []stepDisplay{ - {name: "Prune worktree metadata", status: pipeline.StepRunning}, - {name: "Repository cleanup", status: pipeline.StepPending}, - } - if run.pruneErr != nil { - if *run.pruneErr != nil { - cleanupPhase.steps[0].status = pipeline.StepFailed - cleanupPhase.failed++ - } else { - cleanupPhase.steps[0].status = pipeline.StepDone - } - cleanupPhase.done++ - cleanupPhase.steps[1].status = pipeline.StepRunning - } - if run.cleanupResult != nil { - cleanupPhase.steps[1].status = pipeline.StepDone - cleanupPhase.done++ - } - } - phases = append(phases, cleanupPhase) - return phases +func (m Model) buildRemovalPhases() []progress.PhaseState { + return progress.Snapshot(m.remove.run.events) } diff --git a/internal/tui/progress_detail_test.go b/internal/tui/progress_detail_test.go new file mode 100644 index 0000000..d1a40cf --- /dev/null +++ b/internal/tui/progress_detail_test.go @@ -0,0 +1,224 @@ +package tui + +import ( + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/abiswas97/sentei/internal/progress" +) + +func progressDetailModel() Model { + m := NewModel(nil, nil, "/repo") + m.view = progressView + m.width, m.height, m.windowHeight = 50, 18, 24 + m.portal = m.portal.SetSize(50, 24) + m.remove.run.events = []progress.Event{ + {Phase: "phase-id", PhaseLabel: "Readable phase", Step: "skip-id", StepLabel: "界 skipped step", Status: progress.StepPending, Of: 1}, + {Phase: "phase-id", PhaseLabel: "Readable phase", Step: "fail-id", StepLabel: "failed step", Status: progress.StepPending, Of: 1}, + {Phase: "phase-id", PhaseLabel: "Readable phase", Close: true}, + {Phase: "phase-id", Step: "skip-id", Status: progress.StepSkipped, Message: "already installed with a very long reason"}, + {Phase: "phase-id", Step: "fail-id", Status: progress.StepFailed, Error: errors.New("\x1b[31mfull failure detail\x1b[0m")}, + } + return m +} + +func compactProgressDetailText(s string) string { + return strings.Join(strings.Fields(stripANSI(s)), "") +} + +func TestProgressDetail_IncludesStructuredFullText(t *testing.T) { + m := progressDetailModel() + title, content := m.detailContent() + plain := stripANSI(content) + if title != "Progress details" { + t.Fatalf("title = %q", title) + } + for _, want := range []string{"phase-id", "Readable phase", "skip-id", "界 skipped step", "skipped", "already installed with a very long reason", "fail-id", "failed", "full failure detail"} { + if !strings.Contains(compactProgressDetailText(plain), compactProgressDetailText(want)) { + t.Errorf("detail missing %q:\n%s", want, plain) + } + } + for i, line := range strings.Split(m.viewProgress(), "\n") { + if width := lipgloss.Width(line); width > 50 { + t.Fatalf("main line %d width = %d", i+1, width) + } + } +} + +func TestProgressDetail_OfferedOnlyForFailureOmissionOrTopError(t *testing.T) { + m := NewModel(nil, nil, "/repo") + m.view = progressView + m.width, m.height, m.windowHeight = 80, 18, 24 + m.remove.run.events = []progress.Event{{Phase: "p", PhaseLabel: "P", Step: "s", StepLabel: "S", Status: progress.StepPending, Of: 1}, {Phase: "p", Close: true}} + if _, content := m.detailContent(); content != "" { + t.Fatalf("simple pending progress unexpectedly offered details: %q", content) + } + m.remove.run.result.Err = errors.New("delivery failed") + if _, content := m.detailContent(); !strings.Contains(content, "delivery failed") { + t.Fatalf("top-level error did not offer details: %q", content) + } + if footer := stripANSI(viewFooter(80, m.removalLayout().Hints)); !strings.Contains(footer, "? details") { + t.Fatalf("details hint missing: %q", footer) + } +} + +func TestProgressDetail_OfferedWhenConstrainedLiveRegionOmitsAllSteps(t *testing.T) { + layout := ProgressLayout{Height: 4, Phases: []progress.PhaseState{{ + ID: "phase", Name: "Phase", Total: 1, Steps: []progress.StepState{{ID: "step", Name: "Step", Status: progress.StepRunning}}, + }}} + viewport := BuildProgressViewport(layout.Phases, layout.Height, false) + if viewport.DetailRows != 1 { + t.Fatalf("test requires one focus row, got %d", viewport.DetailRows) + } + if !progressNeedsDetails(layout, nil) { + t.Fatal("omitting every step from a constrained live region must offer details") + } +} + +func TestProgressDetail_WrapsLongSourceTextToPortalWidth(t *testing.T) { + skipReason := strings.Repeat("界🙂 reason ", 18) + errorText := strings.Repeat("錯誤🙂 failure ", 18) + m := NewModel(nil, nil, "/repo") + m.view = progressView + m.width, m.height, m.windowHeight = 32, 6, 12 + m.portal = m.portal.SetSize(32, 12) + m.remove.run.events = []progress.Event{ + {Phase: "phase-id", PhaseLabel: "Readable phase", Step: "skip-id", StepLabel: "Skipped step", Status: progress.StepPending, Of: 2}, + {Phase: "phase-id", Step: "fail-id", StepLabel: "Failed step", Status: progress.StepPending, Of: 2}, + {Phase: "phase-id", PhaseLabel: "Readable phase", Close: true}, + {Phase: "phase-id", Step: "skip-id", Status: progress.StepSkipped, Message: skipReason}, + {Phase: "phase-id", Step: "fail-id", Status: progress.StepFailed, Error: errors.New("\x1b[31m" + errorText + "\x1b[0m")}, + } + + _, content := m.detailContent() + plain := stripANSI(content) + for name, source := range map[string]string{"skip reason": skipReason, "error": errorText} { + if !strings.Contains(compactProgressDetailText(plain), compactProgressDetailText(source)) { + t.Errorf("%s is not recoverable after wrapping:\n%s", name, plain) + } + } + for i, line := range strings.Split(content, "\n") { + if got := lipgloss.Width(line); got > m.portal.contentWidth() { + t.Errorf("line %d width = %d, portal width = %d: %q", i+1, got, m.portal.contentWidth(), stripANSI(line)) + } + } +} + +func TestProgressDetail_NarrowPortalPreservesPrefixedUnicodeValues(t *testing.T) { + skipReason := "界🙂 already installed with 完整 reason" + errorText := "錯誤🙂 complete failure detail" + m := NewModel(nil, nil, "/repo") + m.view = progressView + m.width, m.height, m.windowHeight = 20, 6, 12 + m.portal = m.portal.SetSize(20, 12) + m.remove.run.events = []progress.Event{ + {Phase: "phase-id", PhaseLabel: "Readable phase", Step: "skip-id", StepLabel: "Skipped step", Status: progress.StepPending, Of: 2}, + {Phase: "phase-id", Step: "fail-id", StepLabel: "Failed step", Status: progress.StepPending, Of: 2}, + {Phase: "phase-id", PhaseLabel: "Readable phase", Close: true}, + {Phase: "phase-id", Step: "skip-id", Status: progress.StepSkipped, Message: skipReason}, + {Phase: "phase-id", Step: "fail-id", Status: progress.StepFailed, Error: errors.New(errorText)}, + } + + _, content := m.detailContent() + plain := stripANSI(content) + for name, source := range map[string]string{"skip reason": skipReason, "error": errorText} { + if !strings.Contains(compactProgressDetailText(plain), compactProgressDetailText(source)) { + t.Errorf("narrow detail lost %s %q:\n%s", name, source, plain) + } + } + for row, line := range strings.Split(content, "\n") { + if got := lipgloss.Width(line); got > m.portal.contentWidth() { + t.Errorf("row %d width=%d exceeds narrow portal width=%d: %q", row+1, got, m.portal.contentWidth(), stripANSI(line)) + } + } +} + +func TestProgressPortal_StaysOpenAcrossBackgroundEventAndResize(t *testing.T) { + m := progressDetailModel() + title, content := m.detailContent() + m.portal = m.portal.Open(portalDetails, title, content) + updated, _ := m.Update(removalEventMsg{event: progress.Event{Phase: "phase-id", Step: "skip-id", Status: progress.StepSkipped, Message: "updated"}}) + m = updated.(Model) + if !m.portal.Visible() { + t.Fatal("background progress event closed portal") + } + if got := stripANSI(m.portal.viewport.View()); !strings.Contains(got, "updated") { + t.Fatalf("background progress event left stale detail content:\n%s", got) + } + updated, _ = m.Update(tea.WindowSizeMsg{Width: 40, Height: 12}) + m = updated.(Model) + if !m.portal.Visible() || m.portal.viewport.Width() != m.portal.contentWidth() { + t.Fatal("resize did not preserve/refit portal") + } +} + +func TestProgressPortal_BackgroundRefreshPreservesAndClampsScroll(t *testing.T) { + m := progressDetailModel() + m.remove.run.events[4].Error = errors.New(strings.Repeat("failure line\n", 40)) + title, content := m.detailContent() + m.portal = m.portal.Open(portalDetails, title, content) + m.portal.viewport.ScrollDown(8) + before := m.portal.viewport.YOffset() + if before == 0 { + t.Fatal("precondition: portal did not scroll") + } + + updated, _ := m.Update(removalEventMsg{event: progress.Event{ + Phase: "phase-id", Step: "skip-id", Status: progress.StepSkipped, + Message: "fresh background detail", + }}) + m = updated.(Model) + if got := m.portal.viewport.YOffset(); got != before { + t.Fatalf("refresh moved scroll offset from %d to %d", before, got) + } +} + +func TestProgressPortal_BackgroundEventClosesWhenDetailsResolve(t *testing.T) { + m := NewModel(nil, nil, "/repo") + m.view = progressView + m.width, m.height, m.windowHeight = 80, 18, 18 + m.portal = m.portal.SetSize(80, 12) + m.remove.run.events = []progress.Event{ + {Phase: "p1", PhaseLabel: "First", Step: "s1", StepLabel: "First step", Status: progress.StepPending, Of: 1}, + {Phase: "p1", Close: true}, + {Phase: "p2", PhaseLabel: "Second", Step: "s2", StepLabel: "Second step", Status: progress.StepPending, Of: 1}, + {Phase: "p2", Close: true}, + } + title, content := m.detailContent() + if content == "" { + t.Fatal("precondition: queued phase did not offer details") + } + m.portal = m.portal.Open(portalDetails, title, content) + m.portal.viewport.ScrollDown(4) + if m.portal.viewport.YOffset() == 0 { + t.Fatal("precondition: portal did not scroll") + } + + updated, _ := m.Update(removalEventMsg{event: progress.Event{Phase: "p1", Step: "s1", Status: progress.StepDone}}) + m = updated.(Model) + if m.portal.Visible() { + t.Fatal("resolved omission left a stale progress details portal open") + } +} + +func TestProgressPortal_ClosesWhenProgressTransitionsToSummary(t *testing.T) { + m := progressDetailModel() + title, content := m.detailContent() + m.portal = m.portal.Open(portalDetails, title, content) + m.progressToken = 7 + m.progressTransitionPending = true + m.progressTargetView = summaryView + + updated, _ := m.Update(progressTransitionMsg{token: 7}) + m = updated.(Model) + if m.view != summaryView { + t.Fatalf("view=%v, want summary", m.view) + } + if m.portal.Visible() { + t.Fatal("progress details portal persisted over summary transition") + } +} diff --git a/internal/tui/progress_endings_test.go b/internal/tui/progress_endings_test.go index 2974e26..bba3a6c 100644 --- a/internal/tui/progress_endings_test.go +++ b/internal/tui/progress_endings_test.go @@ -8,6 +8,7 @@ import ( "github.com/abiswas97/sentei/internal/config" "github.com/abiswas97/sentei/internal/creator" "github.com/abiswas97/sentei/internal/git" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" ) @@ -16,9 +17,9 @@ func TestProgressLayout_SkippedPhasesAtCompletion(t *testing.T) { Completed: true, Width: 80, Height: 20, - Phases: []phaseDisplay{ - {name: "Setup", done: 1, total: 1}, - {name: "Dependencies", total: 0}, + Phases: []progress.PhaseState{ + {Name: "Setup", Done: 1, Total: 1}, + {Name: "Dependencies", Total: 0}, }, } @@ -39,9 +40,9 @@ func TestProgressLayout_PendingMidRunUnchanged(t *testing.T) { l := ProgressLayout{ Width: 80, Height: 20, - Phases: []phaseDisplay{ - {name: "Setup", done: 1, total: 1}, - {name: "Dependencies", total: 0}, + Phases: []progress.PhaseState{ + {Name: "Setup", Done: 1, Total: 1}, + {Name: "Dependencies", Total: 0}, }, } diff --git a/internal/tui/progress_layout.go b/internal/tui/progress_layout.go index 0004f75..bcc248f 100644 --- a/internal/tui/progress_layout.go +++ b/internal/tui/progress_layout.go @@ -6,35 +6,28 @@ import ( "time" "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/progress" + progressbar "charm.land/bubbles/v2/progress" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" ) -// progressChromeLines is the fixed line budget the layout spends outside -// step lists: title, blank, separators with surrounding blanks, bar, hints. -const progressChromeLines = 9 - // ProgressLayout renders the standard progress view shared by every // long-running flow: title, optional subtitle, separator, phases with // indented steps (windowed to the terminal height), separator, overall -// progress bar, and key hints. Phase data is the phaseDisplay shape that +// progress bar, and key hints. Phase data is the progress.PhaseState shape that // buildPhaseDisplays produces from pipeline events; views with bespoke // event types construct the same shape themselves. type ProgressLayout struct { Title string Subtitle string - Phases []phaseDisplay + Phases []progress.PhaseState Width int Height int Hints []key.Binding - // OverallTotal overrides the bar's denominator when the flow knows its - // full step count upfront and discovered phase totals would undercount. - OverallDone int - OverallTotal int - // Bar, Elapsed, and ActiveGlyph are injected by the model's render path // (animated spring bar, stopwatch readout, spinner frame). Left empty, // View falls back to the static bar, no elapsed line, and the static @@ -52,6 +45,13 @@ type ProgressLayout struct { Completed bool } +func (m Model) withProgressDetails(layout ProgressLayout) ProgressLayout { + if progressNeedsDetails(layout, m.progressTopLevelError()) { + layout.Hints = append(append([]key.Binding(nil), layout.Hints...), detailsHint) + } + return layout +} + // activeGlyph returns the styled active indicator: the injected animation // frame, or the static fallback for pure constructions. func (l ProgressLayout) activeGlyph() string { @@ -61,18 +61,21 @@ func (l ProgressLayout) activeGlyph() string { return styleIndicatorActive.Render(indicatorActiveFallback) } -// overall returns the bar's done/total, honoring the explicit override and -// counting each undiscovered phase as outstanding work so the bar never -// reads 100% beside pending phases. +// overall returns the bar's fill source: checkpoints reached over declared +// across all phases (headers keep counting steps), falling back to step +// counts for phases without checkpoint declarations, and counting each +// undiscovered phase as outstanding work so the bar never reads 100% +// beside pending phases. func (l ProgressLayout) overall() (int, int) { - if l.OverallTotal != 0 { - return l.OverallDone, l.OverallTotal - } done, total := 0, 0 for _, p := range l.Phases { - done += p.done - total += p.total - if p.total == 0 && !l.Completed { + reached, declared := progress.CheckpointProgress([]progress.PhaseState{p}) + if declared == 0 { + reached, declared = p.Done, p.Total + } + done += reached + total += declared + if p.Total == 0 && !l.Completed { total++ } } @@ -80,142 +83,201 @@ func (l ProgressLayout) overall() (int, int) { } func (l ProgressLayout) View() string { - var b strings.Builder - - b.WriteString(viewTitle(l.Title)) - b.WriteString("\n") - if l.Subtitle != "" { - b.WriteString(styleAccent.Render(" " + truncateWithEllipsis(l.Subtitle, max(l.Width-2, 10)))) - b.WriteString("\n") - } - b.WriteString("\n") - b.WriteString(viewSeparator(l.Width)) - b.WriteString("\n\n") - - stepBudget := l.Height - progressChromeLines - len(l.Phases) - for _, p := range l.Phases { - used := l.renderPhase(&b, p, stepBudget) - stepBudget -= used + height := max(l.Height, 0) + if height == 0 { + return "" } + width := max(l.Width, 1) + viewport := BuildProgressViewport(l.Phases, height, l.Completed) + bar := fitProgressLine(l.barLine(), width) + footer := fitProgressLine(viewFooter(width, l.Hints), width) + title := fitProgressLine(viewTitle(l.Title), width) - b.WriteString(viewSeparator(l.Width)) - b.WriteString("\n\n") + if viewport.Tier == progressViewportEmergency { + switch height { + case 1: + return bar + case 2: + return strings.Join([]string{bar, footer}, "\n") + default: + return strings.Join([]string{title, bar, footer}, "\n") + } + } - if l.Bar != "" { - b.WriteString(l.Bar) - } else { - done, total := l.overall() - b.WriteString(renderProgressBar(done, total, overallBarWidth(l.Width)-progressBarPercentReserve)) + live := l.liveRegionLines(viewport, width) + lines := make([]string, 0, height) + switch viewport.Tier { + case progressViewportNormal: + lines = append(lines, title) + if l.Subtitle != "" { + lines = append(lines, fitProgressLine(styleAccent.Render(" "+l.Subtitle), width)) + } else { + lines = append(lines, "") + } + lines = append(lines, fitProgressLine(viewSeparator(width), width), "") + liveRows := viewport.DetailRows + len(viewport.History) + if viewport.HistoryOmitted > 0 { + liveRows++ + } + if viewport.Queued > 0 { + liveRows++ + } + lines = append(lines, padProgressLines(live, liveRows)...) + lines = append(lines, fitProgressLine(viewSeparator(width), width), "", bar, "", footer) + case progressViewportCompact: + lines = append(lines, title, fitProgressLine(viewSeparator(width), width)) + lines = append(lines, padProgressLines(live, liveRegionRows(height, viewport.Tier))...) + lines = append(lines, bar, footer) + case progressViewportMinimal: + lines = append(lines, title) + lines = append(lines, padProgressLines(live, liveRegionRows(height, viewport.Tier))...) + lines = append(lines, bar, footer) } - if l.Elapsed != "" { - b.WriteString(" " + l.Elapsed) + if len(lines) > height { + lines = lines[:height] } - b.WriteString("\n") + for len(lines) < height { + lines = append(lines, "") + } + return strings.Join(lines, "\n") +} - if len(l.Hints) > 0 { - b.WriteString("\n") - b.WriteString(viewFooter(l.Width, l.Hints)) - b.WriteString("\n") +func (l ProgressLayout) liveRegionLines(viewport ProgressViewport, width int) []string { + var lines []string + if viewport.HistoryOmitted > 0 { + lines = append(lines, fitProgressLine(styleDim.Render(fmt.Sprintf(" … %d earlier phases", viewport.HistoryOmitted)), width)) } + for _, phase := range viewport.History { + lines = append(lines, fitProgressLine(l.phaseHeadline(phase), width)) + } + if viewport.Focus != nil && viewport.DetailRows > 0 { + lines = append(lines, l.focusLines(*viewport.Focus, viewport.DetailRows, width)...) + } + if viewport.Queued > 0 { + lines = append(lines, fitProgressLine(styleDim.Render(fmt.Sprintf(" · %d %s waiting", viewport.Queued, pluralize(viewport.Queued, "phase", "phases"))), width)) + } + return lines +} - return b.String() +func (l ProgressLayout) focusLines(phase progress.PhaseState, rows, width int) []string { + if rows <= 0 { + return nil + } + lines := []string{fitProgressLine(l.phaseHeadline(phase), width)} + window := WindowSteps(phase.Steps, rows-1) + for _, step := range window.Steps { + lines = append(lines, fitProgressLine(l.stepLine(step), width)) + } + if window.Windowed && len(lines) < rows { + lines = append(lines, fitProgressLine(viewStatLine(window.Stats, l.activeGlyph()), width)) + } + return lines[:min(len(lines), rows)] } -// renderPhase writes one phase section and returns how many step lines it -// consumed from the windowing budget. -func (l ProgressLayout) renderPhase(b *strings.Builder, p phaseDisplay, stepBudget int) int { - switch { - case p.total == 0: +func (l ProgressLayout) phaseHeadline(phase progress.PhaseState) string { + if phase.Total == 0 { if l.Completed { - // The flow finished and this phase never had work: skipped. - fmt.Fprintf(b, " %s %s %s\n\n", - styleDim.Render("–"), - stylePhasePending.Render(p.name), - styleDim.Render("skipped")) - return 0 + return fmt.Sprintf(" %s %s %s", styleDim.Render("–"), stylePhasePending.Render(phase.Name), styleDim.Render("skipped")) } - // No work discovered yet: pending, never "100% done". - fmt.Fprintf(b, " %s %s %s\n\n", - styleIndicatorPending.Render(indicatorPending), - stylePhasePending.Render(p.name), - styleDim.Render("pending")) - return 0 - - case p.done == p.total && p.failed == 0: - // Fully complete: collapse to a single line. - fmt.Fprintf(b, " %s %s %s\n\n", - styleIndicatorDone.Render(indicatorDone), - stylePhaseDone.Render(p.name), - styleDim.Render(fmt.Sprintf("%d/%d 100%%", p.total, p.total))) - return 0 + return fmt.Sprintf(" %s %s %s", styleIndicatorPending.Render(indicatorPending), stylePhasePending.Render(phase.Name), styleDim.Render("pending")) } - - pct := (p.done * 100) / p.total - if pct > 100 { - pct = 100 + if phase.Settled() && phase.Failed == 0 { + return fmt.Sprintf(" %s %s %s", styleIndicatorDone.Render(indicatorDone), stylePhaseDone.Render(phase.Name), styleDim.Render(fmt.Sprintf("%d/%d 100%%", phase.Total, phase.Total))) } - counts := styleDim.Render(fmt.Sprintf("%d/%d %d%%", min(p.done, p.total), p.total, pct)) - switch { - case p.failed > 0: - nameStyle := stylePhaseActive - if p.done == p.total { - nameStyle = stylePhaseDone + counts := fmt.Sprintf("%d/%d %d%%", min(phase.Done, phase.Total), phase.Total, min((phase.Done*100)/phase.Total, 100)) + if phase.Failed > 0 { + counts = fmt.Sprintf("%d/%d", min(phase.Done, phase.Total), phase.Total) + return fmt.Sprintf(" %s %s %s", styleIndicatorFailed.Render(indicatorFailed), stylePhaseActive.Render(phase.Name), styleDim.Render(counts)) + } + if l.Motion != nil && phaseHasStatus(phase, progress.StepRunning) { + return fmt.Sprintf(" %s %s", l.Motion.Accent(l.Motion.Frame+" "+phase.Name), styleDim.Render(counts)) + } + return fmt.Sprintf(" %s %s %s", l.activeGlyph(), stylePhaseActive.Render(phase.Name), styleDim.Render(counts)) +} + +func (l ProgressLayout) stepLine(step progress.StepState) string { + label := step.Name + switch step.Status { + case progress.StepRunning: + return " " + l.activeGlyph() + " " + label + case progress.StepDone: + return " " + styleIndicatorDone.Render(indicatorDone) + " " + label + case progress.StepFailed: + if step.Error != nil { + label += ": " + step.Error.Error() } - fmt.Fprintf(b, " %s %s %s\n", - styleIndicatorFailed.Render(indicatorFailed), nameStyle.Render(p.name), counts) - case l.Motion != nil: - // The star rides inside the headline's accent shimmer band. - fmt.Fprintf(b, " %s %s\n", l.Motion.Accent(l.Motion.Frame+" "+p.name), counts) + return " " + styleIndicatorFailed.Render(indicatorFailed) + " " + label + case progress.StepSkipped: + reason := "" + if step.Message != "" { + reason = " (" + step.Message + ")" + } + return styleDim.Render(" – " + label + " – skipped" + reason) default: - fmt.Fprintf(b, " %s %s %s\n", - l.activeGlyph(), stylePhaseActive.Render(p.name), counts) + return " " + styleIndicatorPending.Render(indicatorPending) + " " + label } +} - window := WindowSteps(p.steps, stepBudget) - for _, s := range window.Steps { - label := truncateWithEllipsis(s.name, max(l.Width-6, 10)) - if s.status == pipeline.StepRunning && l.Motion != nil { - // Working steps shimmer in the body ramp, star in the band. - fmt.Fprintf(b, " %s\n", l.Motion.Body(l.Motion.Frame+" "+label)) - continue +func (l ProgressLayout) barLine() string { + if l.Bar != "" { + withElapsed := l.Bar + if l.Elapsed != "" { + withElapsed += " " + l.Elapsed } - var stepInd string - switch s.status { - case pipeline.StepDone, pipeline.StepSkipped: - stepInd = styleIndicatorDone.Render(indicatorDone) - case pipeline.StepRunning: - stepInd = l.activeGlyph() - case pipeline.StepFailed: - stepInd = styleIndicatorFailed.Render(indicatorFailed) - default: - stepInd = styleIndicatorPending.Render(indicatorPending) + if lipgloss.Width(withElapsed) <= l.Width { + return withElapsed } - fmt.Fprintf(b, " %s %s\n", stepInd, label) + if lipgloss.Width(l.Bar) <= l.Width { + return l.Bar + } + } + done, total := l.overall() + pct := 0 + if total > 0 { + pct = min((done*100)/total, 100) + } else if l.Completed { + pct = 100 + } + if l.Width < minProgressBarWidth+progressBarPercentReserve+2 { + return fmt.Sprintf(" %d%%", pct) } - used := len(window.Steps) - if window.Windowed { - b.WriteString(viewStatLine(window.Stats, l.activeGlyph())) - b.WriteString("\n") - used++ + return renderProgressBar(done, total, l.Width-2-progressBarPercentReserve) +} + +func fitProgressLine(line string, width int) string { + if width <= 0 { + return "" + } + if lipgloss.Width(line) <= width { + return line + } + return ansi.Truncate(line, width, "…") +} + +func padProgressLines(lines []string, rows int) []string { + if len(lines) > rows { + return lines[:rows] } - b.WriteString("\n") - return used + for len(lines) < rows { + lines = append(lines, "") + } + return lines } // newOverallBar constructs the spring-animated overall bar: block fill // characters, gradient scaled to the filled portion, sized responsively // from WindowSizeMsg (the construction width is only the pre-frame floor). -func newOverallBar() progress.Model { - return progress.New( - progress.WithWidth(minProgressBarWidth), - progress.WithFillCharacters('█', '░'), - progress.WithColors(colorBarStart, colorBarEnd), - progress.WithScaled(true), +func newOverallBar() progressbar.Model { + return progressbar.New( + progressbar.WithWidth(minProgressBarWidth), + progressbar.WithFillCharacters('█', '░'), + progressbar.WithColors(colorBarStart, colorBarEnd), + progressbar.WithScaled(true), // Luxurious spring: a full sweep takes ~1.2s (bar visually full at // ~1.0s), inside the hold plus settle floor. Critically damped: // the component clamps at 100%, so any overshoot would render as // the bar retreating. - progress.WithSpringOptions(6, 1), + progressbar.WithSpringOptions(6, 1), ) } @@ -261,7 +323,11 @@ func (m *Model) syncProgressBar() tea.Cmd { // Nothing was ever discovered and the flow is done: that is 100%. pct = 1 } - cmds := []tea.Cmd{m.bar.SetPercent(pct)} + m.progressTarget = pct + var cmds []tea.Cmd + if m.motionPreference == MotionFull { + cmds = append(cmds, m.bar.SetPercent(pct)) + } if !m.watch.Running() { cmds = append(cmds, m.watch.Start()) } @@ -275,18 +341,37 @@ func (m Model) renderProgressLayout(l ProgressLayout) string { bar := m.bar // Colors re-read the live palette tokens each render: the adaptive // palette can arrive after the bar is constructed. A completed flow - // settles the fill green for its hold — the bar joins the ✦ moment. - if l.Completed { - progress.WithColors(colorBarDoneStart, colorBarDoneEnd)(&bar) + // settles the fill green for its hold — the bar joins the ✦ moment — + // but a flow that finished with failures keeps the standard gradient: + // the truth-hold applies to every ending, the celebration does not. + hasFailures := m.progressTopLevelError() != nil + for _, p := range l.Phases { + if p.Failed > 0 { + hasFailures = true + break + } + } + if l.Completed && !hasFailures { + progressbar.WithColors(colorBarDoneStart, colorBarDoneEnd)(&bar) } else { - progress.WithColors(colorBarStart, colorBarEnd)(&bar) + progressbar.WithColors(colorBarStart, colorBarEnd)(&bar) } bar.EmptyColor = colorDim // The native percentage follows the displayed fill, so bar and label // never disagree; the phase headers state actual counts. - l.Bar = " " + bar.View() - l.Elapsed = styleDim.Render(fmt.Sprintf("elapsed %ds", int(time.Since(m.progressStartedAt).Seconds()))) - l.ActiveGlyph = starGlyph(rampAccent, m.motionTick) - l.Motion = m.motion() + if m.motionPreference == MotionOff { + l.Bar = " " + bar.ViewAs(m.progressTarget) + } else { + l.Bar = " " + bar.View() + } + // Sub-2s elapsed is noise that implies precision the 1Hz ticker does + // not have; the layout reserve keeps the bar from reflowing when the + // readout appears. + if elapsed := time.Since(m.progressStartedAt); elapsed >= 2*time.Second { + l.Elapsed = styleDim.Render(fmt.Sprintf("elapsed %ds", int(elapsed.Seconds()))) + } + if m.motionPreference == MotionFull { + l.Motion = m.motion() + } return l.View() } diff --git a/internal/tui/progress_layout_test.go b/internal/tui/progress_layout_test.go index 38a02b0..c076b81 100644 --- a/internal/tui/progress_layout_test.go +++ b/internal/tui/progress_layout_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" ) func TestProgressLayout_WithSubtitle(t *testing.T) { @@ -13,18 +13,18 @@ func TestProgressLayout_WithSubtitle(t *testing.T) { Subtitle: "feature/foo → from main", Width: 80, Height: 30, - Phases: []phaseDisplay{ - {name: "Setup", total: 2, done: 1, steps: []stepDisplay{ - {name: "Create worktree", status: pipeline.StepDone}, - {name: "Merge base branch", status: pipeline.StepRunning}, + Phases: []progress.PhaseState{ + {Name: "Setup", Total: 2, Done: 1, Steps: []progress.StepState{ + {Name: "Create worktree", Status: progress.StepDone}, + {Name: "Merge base branch", Status: progress.StepRunning}, }}, - {name: "Dependencies"}, - {name: "Integrations"}, + {Name: "Dependencies"}, + {Name: "Integrations"}, }, } view := stripANSI(l.View()) - for _, want := range []string{"sentei ─ Creating worktree", "feature/foo → from main", "┄", "Setup", "Dependencies", "pending"} { + for _, want := range []string{"sentei ─ Creating worktree", "feature/foo → from main", "┄", "Setup", "2 phases waiting"} { if !strings.Contains(view, want) { t.Errorf("expected layout to contain %q, view:\n%s", want, view) } @@ -47,10 +47,10 @@ func TestProgressLayout_WithoutSubtitle(t *testing.T) { func TestProgressLayout_CompletedPhaseCollapses(t *testing.T) { l := ProgressLayout{ Title: "T", Width: 80, Height: 30, - Phases: []phaseDisplay{ - {name: "Setup", total: 2, done: 2, steps: []stepDisplay{ - {name: "Create worktree", status: pipeline.StepDone}, - {name: "Merge base branch", status: pipeline.StepDone}, + Phases: []progress.PhaseState{ + {Name: "Setup", Total: 2, Done: 2, Closed: true, Steps: []progress.StepState{ + {Name: "Create worktree", Status: progress.StepDone}, + {Name: "Merge base branch", Status: progress.StepDone}, }}, }, } @@ -67,10 +67,10 @@ func TestProgressLayout_CompletedPhaseCollapses(t *testing.T) { func TestProgressLayout_CompletedPhaseWithFailuresKeepsSteps(t *testing.T) { l := ProgressLayout{ Title: "T", Width: 80, Height: 30, - Phases: []phaseDisplay{ - {name: "Setup", total: 2, done: 2, failed: 1, steps: []stepDisplay{ - {name: "Create worktree", status: pipeline.StepDone}, - {name: "Install hooks", status: pipeline.StepFailed}, + Phases: []progress.PhaseState{ + {Name: "Setup", Total: 2, Done: 2, Failed: 1, Steps: []progress.StepState{ + {Name: "Create worktree", Status: progress.StepDone}, + {Name: "Install hooks", Status: progress.StepFailed}, }}, }, } @@ -87,11 +87,11 @@ func TestProgressLayout_CompletedPhaseWithFailuresKeepsSteps(t *testing.T) { func TestProgressLayout_ActivePhaseShowsSteps(t *testing.T) { l := ProgressLayout{ Title: "T", Width: 80, Height: 30, - Phases: []phaseDisplay{ - {name: "Removing worktrees", total: 30, done: 12, steps: []stepDisplay{ - {name: "done-step", status: pipeline.StepDone}, - {name: "active-step", status: pipeline.StepRunning}, - {name: "pending-step", status: pipeline.StepPending}, + Phases: []progress.PhaseState{ + {Name: "Removing worktrees", Total: 30, Done: 12, Steps: []progress.StepState{ + {Name: "done-step", Status: progress.StepDone}, + {Name: "active-step", Status: progress.StepRunning}, + {Name: "pending-step", Status: progress.StepPending}, }}, }, } @@ -110,9 +110,9 @@ func TestProgressLayout_ActivePhaseShowsSteps(t *testing.T) { func TestProgressLayout_ZeroTotalPhaseIsPending(t *testing.T) { l := ProgressLayout{ Title: "T", Width: 80, Height: 30, - Phases: []phaseDisplay{ - {name: "Integrations", total: 0, done: 0, steps: []stepDisplay{ - {name: "queued work", status: pipeline.StepPending}, + Phases: []progress.PhaseState{ + {Name: "Integrations", Total: 0, Done: 0, Steps: []progress.StepState{ + {Name: "queued work", Status: progress.StepPending}, }}, }, } @@ -129,14 +129,14 @@ func TestProgressLayout_ZeroTotalPhaseIsPending(t *testing.T) { func TestProgressLayout_OverallBarAggregatesPhases(t *testing.T) { l := ProgressLayout{ Title: "T", Width: 80, Height: 30, - Phases: []phaseDisplay{ - {name: "A", total: 12, done: 8}, - {name: "B", total: 8, done: 2}, + Phases: []progress.PhaseState{ + {Name: "A", Total: 12, Done: 8}, + {Name: "B", Total: 8, Done: 2}, }, } view := stripANSI(l.View()) - barWidth := overallBarWidth(80) - progressBarPercentReserve + barWidth := 80 - 2 - progressBarPercentReserve want := strings.Repeat("█", barWidth/2) + strings.Repeat("░", barWidth-barWidth/2) + " 50%" if !strings.Contains(view, want) { t.Errorf("expected aggregated 50%% bar, view:\n%s", view) @@ -146,8 +146,8 @@ func TestProgressLayout_OverallBarAggregatesPhases(t *testing.T) { func TestProgressLayout_DoneExceedsTotal_NoPanic(t *testing.T) { l := ProgressLayout{ Title: "T", Width: 80, Height: 30, - Phases: []phaseDisplay{ - {name: "A", total: 1, done: 3}, + Phases: []progress.PhaseState{ + {Name: "A", Total: 1, Done: 3}, }, } view := stripANSI(l.View()) // must not panic @@ -161,14 +161,14 @@ func TestProgressLayout_DoneExceedsTotal_NoPanic(t *testing.T) { } func TestProgressLayout_WindowsStepsOnShortTerminal(t *testing.T) { - steps := make([]stepDisplay, 30) + steps := make([]progress.StepState, 30) for i := range steps { - steps[i] = stepDisplay{name: stepName(i), status: pipeline.StepPending} + steps[i] = progress.StepState{Name: stepName(i), Status: progress.StepPending} } - steps[0].status = pipeline.StepRunning + steps[0].Status = progress.StepRunning l := ProgressLayout{ Title: "T", Width: 80, Height: 15, - Phases: []phaseDisplay{{name: "Removing", total: 30, done: 0, steps: steps}}, + Phases: []progress.PhaseState{{Name: "Removing", Total: 30, Done: 0, Steps: steps}}, } view := stripANSI(l.View()) diff --git a/internal/tui/progress_live_region_test.go b/internal/tui/progress_live_region_test.go new file mode 100644 index 0000000..944b5d8 --- /dev/null +++ b/internal/tui/progress_live_region_test.go @@ -0,0 +1,221 @@ +package tui + +import ( + "fmt" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/abiswas97/sentei/internal/progress" +) + +func TestProgressViewport_TierBoundaries(t *testing.T) { + cases := []struct { + height int + want progressViewportTier + }{{18, progressViewportNormal}, {17, progressViewportCompact}, {12, progressViewportCompact}, {11, progressViewportMinimal}, {4, progressViewportMinimal}, {3, progressViewportEmergency}} + for _, tc := range cases { + if got := BuildProgressViewport(denseProgressPhases(), tc.height, false).Tier; got != tc.want { + t.Errorf("height %d tier = %v, want %v", tc.height, got, tc.want) + } + } +} + +func TestModel_KeepsRawWindowHeightAndExactProgressTarget(t *testing.T) { + m := NewModel(nil, nil, "/repo") + updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = updated.(Model) + if m.windowHeight != 24 || m.height != 18 { + t.Fatalf("heights raw/body = %d/%d, want 24/18", m.windowHeight, m.height) + } + m.view = progressView + m.remove.run.events = []progress.Event{ + {Phase: "remove", PhaseLabel: "Remove", Step: "a", StepLabel: "A", Status: progress.StepPending, Of: 2}, + {Phase: "remove", PhaseLabel: "Remove", Close: true}, + {Phase: "remove", Step: "a", Status: progress.StepRunning, Checkpoint: 1, Of: 2}, + } + if cmd := m.syncProgressBar(); cmd == nil { + t.Fatal("syncProgressBar returned nil") + } + if m.progressTarget != 0.5 { + t.Fatalf("progressTarget = %v, want 0.5", m.progressTarget) + } + if got := m.removalLayout().Height; got != 24 { + t.Fatalf("layout height = %d, want raw 24", got) + } +} + +func TestProgressLayout_ResizeRoundTripRestoresPinnedRows(t *testing.T) { + layout := ProgressLayout{Title: "T", Width: 80, Height: 24, Phases: denseProgressPhases(), Hints: progressFooter} + want := layout.View() + layout.Height = 11 + if got := lipgloss.Height(layout.View()); got > 11 { + t.Fatalf("minimal resize rendered %d rows", got) + } + layout.Height = 24 + if got := layout.View(); got != want { + t.Fatal("24→11→24 changed the projected frame") + } +} + +func TestProgressLayout_CompactAndMinimalPinBarFooterToFinalRows(t *testing.T) { + for _, height := range []int{17, 12, 11, 4} { + out := stripANSI((ProgressLayout{Title: "T", Width: 80, Height: height, Phases: denseProgressPhases(), Hints: progressFooter}).View()) + lines := strings.Split(out, "\n") + if len(lines) != height || !strings.Contains(lines[height-2], "%") || !strings.Contains(lines[height-1], "quit") { + t.Fatalf("height %d final rows = %q / %q", height, lines[height-2], lines[height-1]) + } + } +} + +func TestProgressLayout_PinnedRowsAcrossEventPrefixes(t *testing.T) { + steps := []progress.StepState{{ID: "a", Name: "A", Status: progress.StepPending}, {ID: "b", Name: "B", Status: progress.StepPending}} + statuses := [][2]progress.StepStatus{{progress.StepPending, progress.StepPending}, {progress.StepRunning, progress.StepPending}, {progress.StepDone, progress.StepRunning}, {progress.StepDone, progress.StepDone}} + for prefix, pair := range statuses { + steps[0].Status, steps[1].Status = pair[0], pair[1] + done := 0 + for _, step := range steps { + if step.Status == progress.StepDone { + done++ + } + } + out := stripANSI((ProgressLayout{Title: "T", Width: 80, Height: 24, Phases: []progress.PhaseState{{ID: "p", Name: "P", Total: 2, Done: done, Closed: true, Steps: append([]progress.StepState(nil), steps...)}}, Hints: progressFooter}).View()) + lines := strings.Split(out, "\n") + if !strings.Contains(lines[19], "┄") || !strings.Contains(lines[21], "%") || !strings.Contains(lines[23], "quit") { + t.Fatalf("prefix %d displaced chrome", prefix) + } + } +} + +func TestProgressLayout_EmptyPlanHasNoSyntheticPhase(t *testing.T) { + out := stripANSI((ProgressLayout{Title: "T", Width: 40, Height: 12, Hints: progressFooter}).View()) + if strings.Contains(out, "phase waiting") || strings.Contains(out, "pending") || strings.Contains(out, "skipped") { + t.Fatalf("empty plan synthesized work:\n%s", out) + } +} + +func TestProgressLayout_BarDegradesAfterDroppingElapsed(t *testing.T) { + narrow := stripANSI((ProgressLayout{Title: "T", Width: 20, Height: 12, Phases: denseProgressPhases(), Hints: progressFooter}).View()) + bar := strings.Split(narrow, "\n")[10] + if strings.ContainsAny(bar, "█░") || !strings.Contains(bar, "%") { + t.Fatalf("20-cell bar must be percentage-only: %q", bar) + } + layout := ProgressLayout{Title: "T", Width: 39, Height: 12, Phases: denseProgressPhases(), Hints: progressFooter, Bar: " " + strings.Repeat("█", 20) + " 50%", Elapsed: "elapsed 999s"} + bar = stripANSI(strings.Split(layout.View(), "\n")[10]) + if !strings.Contains(bar, "█") || strings.Contains(bar, "elapsed") { + t.Fatalf("elapsed must drop before useful bar: %q", bar) + } +} + +func TestProgressLayout_TruncatesCompleteSkipAndErrorTextByCells(t *testing.T) { + phase := progress.PhaseState{ID: "p", Name: "P", Total: 2, Done: 2, Failed: 1, Closed: true, Steps: []progress.StepState{ + {ID: "skip", Name: "界界界 skipped", Status: progress.StepSkipped, Message: strings.Repeat("reason ", 20)}, + {ID: "fail", Name: "failed", Status: progress.StepFailed, Error: fmt.Errorf("\x1b[31m%s\x1b[0m", strings.Repeat("error ", 20))}, + }} + for _, width := range []int{20, 40, 50} { + out := (ProgressLayout{Title: "T", Width: width, Height: 12, Phases: []progress.PhaseState{phase}, Hints: progressFooter}).View() + for i, line := range strings.Split(out, "\n") { + if got := lipgloss.Width(line); got > width { + t.Fatalf("width %d line %d = %d cells", width, i+1, got) + } + } + } +} + +func denseProgressPhases() []progress.PhaseState { + steps := make([]progress.StepState, 40) + for i := range steps { + steps[i] = progress.StepState{ID: fmt.Sprintf("step-%d", i), Name: fmt.Sprintf("step %02d", i), Status: progress.StepPending} + } + steps[2].Status = progress.StepRunning + steps[8].Status = progress.StepFailed + steps[8].Error = fmt.Errorf("failure detail") + return []progress.PhaseState{ + {ID: "done", Name: "Done", Total: 1, Done: 1, Closed: true, Steps: []progress.StepState{{ID: "old", Name: "old", Status: progress.StepDone}}}, + {ID: "active", Name: "Active", Total: len(steps), Done: 1, Failed: 1, Closed: true, Steps: steps}, + {ID: "queued", Name: "Queued", Total: 1, Closed: true, Steps: []progress.StepState{{ID: "later", Name: "later", Status: progress.StepPending}}}, + } +} + +func TestProgressLayout_PinsNormalChromeAt80x24(t *testing.T) { + out := stripANSI((ProgressLayout{Title: "T", Width: 80, Height: 24, Phases: denseProgressPhases(), Hints: progressFooter}).View()) + lines := strings.Split(out, "\n") + if len(lines) != 24 { + t.Fatalf("rows = %d, want 24:\n%s", len(lines), out) + } + if !strings.Contains(lines[19], "┄") || !strings.Contains(lines[21], "%") || !strings.Contains(lines[23], "quit") { + t.Fatalf("pinned rows wrong: row20=%q row22=%q row24=%q", lines[19], lines[21], lines[23]) + } +} + +func TestProgressLayout_NeverExceedsTerminal(t *testing.T) { + for _, width := range []int{20, 40, 50, 80, 120} { + for height := 1; height <= 40; height++ { + out := (ProgressLayout{Title: "Unicode 界", Subtitle: strings.Repeat("long ", 40), Width: width, Height: height, Phases: denseProgressPhases(), Hints: progressFooter}).View() + if got := lipgloss.Height(out); got > height { + t.Fatalf("%dx%d rendered %d rows", width, height, got) + } + for i, line := range strings.Split(out, "\n") { + if got := lipgloss.Width(line); got > width { + t.Fatalf("%dx%d line %d rendered %d cols: %q", width, height, i+1, got, stripANSI(line)) + } + } + } + } +} + +func TestBuildProgressViewport_FocusRules(t *testing.T) { + phase := func(id string, status progress.StepStatus) progress.PhaseState { + return progress.PhaseState{ID: id, Name: id, Total: 1, Closed: true, Steps: []progress.StepState{{ID: id + "-step", Status: status}}} + } + tests := []struct { + name string + phases []progress.PhaseState + completed bool + want string + }{ + {"first running", []progress.PhaseState{phase("failed", progress.StepFailed), phase("run-a", progress.StepRunning), phase("run-b", progress.StepRunning)}, false, "run-a"}, + {"latest failed", []progress.PhaseState{phase("fail-a", progress.StepFailed), phase("fail-b", progress.StepFailed)}, false, "fail-b"}, + {"earliest unresolved", []progress.PhaseState{phase("pending-a", progress.StepPending), phase("pending-b", progress.StepPending)}, false, "pending-a"}, + {"latest terminal completion", []progress.PhaseState{phase("done-a", progress.StepDone), phase("done-b", progress.StepFailed)}, true, "done-b"}, + {"empty", nil, false, ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + viewport := BuildProgressViewport(tc.phases, 24, tc.completed) + got := "" + if viewport.Focus != nil { + got = viewport.Focus.ID + } + if got != tc.want { + t.Fatalf("focus = %q, want %q", got, tc.want) + } + }) + } +} + +func TestWindowSteps_HardBoundsManyActiveFailures(t *testing.T) { + steps := make([]progress.StepState, 30) + for i := range steps { + status := progress.StepFailed + if i < 10 { + status = progress.StepRunning + } + steps[i] = progress.StepState{ID: fmt.Sprintf("s-%d", i), Name: fmt.Sprintf("step-%d", i), Status: status} + } + for budget := 0; budget <= 8; budget++ { + window := WindowSteps(steps, budget) + if len(window.Steps)+btoi(window.Windowed && budget > 0) > budget { + t.Fatalf("budget %d returned %d steps plus stat", budget, len(window.Steps)) + } + } +} + +func btoi(value bool) int { + if value { + return 1 + } + return 0 +} diff --git a/internal/tui/progress_teatest_test.go b/internal/tui/progress_teatest_test.go new file mode 100644 index 0000000..1ebfd44 --- /dev/null +++ b/internal/tui/progress_teatest_test.go @@ -0,0 +1,278 @@ +package tui + +import ( + "errors" + "strings" + "sync" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/teatest/v2" + + "github.com/abiswas97/sentei/internal/cleanup" + "github.com/abiswas97/sentei/internal/progress" +) + +type progressFrameRecorder struct { + mu sync.Mutex + frames []progressRecordedFrame + notify chan struct{} +} + +type progressRecordedFrame struct { + width, height int + content string +} + +func newProgressFrameRecorder() *progressFrameRecorder { + return &progressFrameRecorder{notify: make(chan struct{}, 1)} +} + +func (r *progressFrameRecorder) add(m Model, content string) { + height := m.progressHeight() + if m.width <= 0 || height <= 0 { + return + } + r.mu.Lock() + r.frames = append(r.frames, progressRecordedFrame{m.width, height, content}) + r.mu.Unlock() + select { + case r.notify <- struct{}{}: + default: + } +} + +func (r *progressFrameRecorder) snapshot() []progressRecordedFrame { + r.mu.Lock() + defer r.mu.Unlock() + return append([]progressRecordedFrame(nil), r.frames...) +} + +func (r *progressFrameRecorder) waitFor(t *testing.T, condition func([]progressRecordedFrame) bool) { + t.Helper() + timer := time.NewTimer(testTimeout) + defer timer.Stop() + for { + if condition(r.snapshot()) { + return + } + select { + case <-r.notify: + case <-timer.C: + t.Fatal("timed out waiting for progress frame") + } + } +} + +type progressScenarioMsg func(*Model) +type progressScenarioQuitMsg struct{} + +type recordedProgressModel struct { + model Model + recorder *progressFrameRecorder +} + +func (m recordedProgressModel) Init() tea.Cmd { return nil } + +func (m recordedProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case progressScenarioMsg: + msg(&m.model) + return m, nil + case progressScenarioQuitMsg: + return m, tea.Quit + default: + updated, cmd := m.model.Update(msg) + m.model = updated.(Model) + return m, cmd + } +} + +func (m recordedProgressModel) View() tea.View { + view := m.model.View() + m.recorder.add(m.model, view.Content) + return view +} + +func TestTeatestProgressFramesStayTruthfulAndBounded(t *testing.T) { + t.Setenv("SENTEI_MOTION", "off") + recorder := newProgressFrameRecorder() + m := NewModel(nil, nil, "/repo") + m.view = progressView + m.remove.run.events = removalProgressEvents(progress.StepRunning, progress.StepPending) + + tm := teatest.NewTestModel(t, recordedProgressModel{model: m, recorder: recorder}, teatest.WithInitialTermSize(80, 24)) + recorder.waitFor(t, func(frames []progressRecordedFrame) bool { return len(frames) > 0 }) + + tm.Send(progressScenarioMsg(func(m *Model) { + m.remove.run.events = removalProgressEvents(progress.StepDone, progress.StepRunning) + })) + recorder.waitFor(t, func(frames []progressRecordedFrame) bool { + return frameContains(frames, "second worktree") + }) + tm.Send(tea.WindowSizeMsg{Width: 52, Height: 16}) + recorder.waitFor(t, func(frames []progressRecordedFrame) bool { + return frameWithSize(frames, 52, 16) + }) + tm.Send(tea.WindowSizeMsg{Width: 80, Height: 24}) + tm.Send(progressScenarioMsg(func(m *Model) { + m.remove.run.events = removalProgressEvents(progress.StepDone, progress.StepDone) + result := cleanup.Result{} + m.remove.run.cleanupResult = &result + })) + recorder.waitFor(t, func(frames []progressRecordedFrame) bool { + return frameContains(frames, "100%") && frameWithSize(frames, 80, 24) + }) + tm.Send(progressScenarioQuitMsg{}) + _ = tm.FinalModel(t, teatest.WithFinalTimeout(testTimeout)) + + frames := recorder.snapshot() + assertProgressFrameBounds(t, frames) + assertProgressCheckpointsMonotonic(t, frames) + last := stripANSI(frames[len(frames)-1].content) + if strings.Contains(last, indicatorActiveFallback) || !strings.Contains(last, "100%") { + t.Fatalf("successful terminal frame is not settled:\n%s", last) + } +} + +func TestTeatestFailedProgressFrameShowsBlockedSuffixes(t *testing.T) { + t.Setenv("SENTEI_MOTION", "off") + recorder := newProgressFrameRecorder() + m := NewModel(nil, nil, "/repo") + m.view = integrationProgressView + m.integ.lifecycle = integrationExecuting + m.integ.events = integrationFailureEvents() + + tm := teatest.NewTestModel(t, recordedProgressModel{model: m, recorder: recorder}, teatest.WithInitialTermSize(80, 24)) + recorder.waitFor(t, func(frames []progressRecordedFrame) bool { + return frameContains(frames, "dependency failed") + }) + tm.Send(progressScenarioMsg(func(m *Model) { m.integ.lifecycle = integrationSettling })) + recorder.waitFor(t, func(frames []progressRecordedFrame) bool { + return frameContains(frames, "index exited 17") + }) + tm.Send(progressScenarioQuitMsg{}) + _ = tm.FinalModel(t, teatest.WithFinalTimeout(testTimeout)) + + frames := recorder.snapshot() + assertProgressFrameBounds(t, frames) + last := stripANSI(frames[len(frames)-1].content) + for _, want := range []string{"index exited 17", "skipped (dependency failed)"} { + if !strings.Contains(last, want) { + t.Fatalf("failed terminal frame missing %q:\n%s", want, last) + } + } + if strings.Contains(last, indicatorActiveFallback) { + t.Fatalf("failed terminal frame retained active glyph:\n%s", last) + } +} + +func assertProgressFrameBounds(t *testing.T, frames []progressRecordedFrame) { + t.Helper() + for i, frame := range frames { + lines := strings.Split(stripANSI(frame.content), "\n") + if len(lines) != frame.height { + t.Errorf("frame %d has %d rows, want %d", i, len(lines), frame.height) + } + for row, line := range lines { + if ansi.StringWidth(line) > frame.width { + t.Errorf("frame %d row %d width %d exceeds %d", i, row+1, ansi.StringWidth(line), frame.width) + } + } + if frame.width == 80 && frame.height == 24 { + if !strings.Contains(lines[21], "%") || !strings.Contains(lines[23], "q quit") { + t.Errorf("frame %d drifted from fixed 80x24 bar/footer rows", i) + } + } + } +} + +func assertProgressCheckpointsMonotonic(t *testing.T, frames []progressRecordedFrame) { + t.Helper() + previous := -1 + for i, frame := range frames { + if frame.width != 80 || frame.height != 24 { + continue + } + line := stripANSI(strings.Split(frame.content, "\n")[21]) + pct := progressPercent(line) + if pct < previous { + t.Errorf("frame %d progress regressed from %d%% to %d%%", i, previous, pct) + } + previous = pct + } +} + +func progressPercent(line string) int { + for value := 0; value <= 100; value++ { + if strings.Contains(line, " "+itoa(value)+"%") { + return value + } + } + return -1 +} + +func itoa(value int) string { + if value == 0 { + return "0" + } + var digits [3]byte + i := len(digits) + for value > 0 { + i-- + digits[i] = byte('0' + value%10) + value /= 10 + } + return string(digits[i:]) +} + +func frameContains(frames []progressRecordedFrame, text string) bool { + for _, frame := range frames { + if strings.Contains(stripANSI(frame.content), text) { + return true + } + } + return false +} + +func frameWithSize(frames []progressRecordedFrame, width, height int) bool { + for _, frame := range frames { + if frame.width == width && frame.height == height { + return true + } + } + return false +} + +func removalProgressEvents(first, second progress.StepStatus) []progress.Event { + events := []progress.Event{ + {Phase: "removal", PhaseLabel: "Removing worktrees", Step: "one", StepLabel: "first worktree", Status: progress.StepPending, Of: 1}, + {Phase: "removal", PhaseLabel: "Removing worktrees", Step: "two", StepLabel: "second worktree", Status: progress.StepPending, Of: 1}, + {Phase: "removal", PhaseLabel: "Removing worktrees", Close: true}, + } + for _, item := range []struct { + id string + status progress.StepStatus + }{{"one", first}, {"two", second}} { + if item.status != progress.StepPending { + events = append(events, progress.Event{Phase: "removal", Step: item.id, Status: item.status, Checkpoint: 1, Of: 1}) + } + } + return events +} + +func integrationFailureEvents() []progress.Event { + return []progress.Event{ + {Phase: "ccc", PhaseLabel: "ccc", Step: "presence", StepLabel: "Check presence", Status: progress.StepPending, Of: 1}, + {Phase: "ccc", PhaseLabel: "ccc", Step: "init", StepLabel: "Initialize", Status: progress.StepPending, Of: 1}, + {Phase: "ccc", PhaseLabel: "ccc", Step: "index", StepLabel: "Index", Status: progress.StepPending, Of: 1}, + {Phase: "ccc", PhaseLabel: "ccc", Step: "setup", StepLabel: "Dependent setup", Status: progress.StepPending, Of: 1}, + {Phase: "ccc", PhaseLabel: "ccc", Close: true}, + {Phase: "ccc", Step: "presence", Status: progress.StepDone, Checkpoint: 1, Of: 1}, + {Phase: "ccc", Step: "init", Status: progress.StepDone, Checkpoint: 1, Of: 1}, + {Phase: "ccc", Step: "index", Status: progress.StepFailed, Checkpoint: 1, Of: 1, Error: errors.New("index exited 17")}, + {Phase: "ccc", Step: "setup", Status: progress.StepSkipped, Checkpoint: 1, Of: 1, Message: "dependency failed"}, + } +} diff --git a/internal/tui/progress_test.go b/internal/tui/progress_test.go index 226fd9d..332e808 100644 --- a/internal/tui/progress_test.go +++ b/internal/tui/progress_test.go @@ -1,6 +1,7 @@ package tui import ( + "errors" "fmt" "strings" "testing" @@ -8,11 +9,67 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/abiswas97/sentei/internal/cleanup" "github.com/abiswas97/sentei/internal/git" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" "github.com/abiswas97/sentei/internal/worktree" ) +func TestUpdateProgress_CleanupErrorsFailResultAndAppearInSummary(t *testing.T) { + cleanupErr := errors.New("config cleanup failed") + m := NewModel(nil, nil, "/repo") + m.view = progressView + m.remove.run.progressCh = make(chan progress.Event, 8) + execution, err := progress.Start(progress.Plan{Phases: []progress.PlannedPhase{{ + ID: cleanupPhaseID, Label: "Prune & cleanup", + Steps: []progress.PlannedStep{{ID: cleanupStepID, Label: "Repository cleanup"}}, + }}}, func(event progress.Event) { m.remove.run.progressCh <- event }) + if err != nil { + t.Fatal(err) + } + m.remove.run.execution = execution + + updated, _ := m.updateProgress(cleanupCompleteMsg{Result: cleanup.Result{Errors: []cleanup.OperationError{{Step: "config", Err: cleanupErr}}}}) + m = updated.(Model) + if !errors.Is(m.remove.run.result.Err, cleanupErr) || !m.remove.run.result.HasFailures() { + t.Fatalf("cleanup failure classification = %+v", m.remove.run.result) + } + m.view = summaryView + view := stripANSI(m.viewSummary()) + if !strings.Contains(view, "Cleanup failures") || !strings.Contains(view, "config cleanup failed") { + t.Fatalf("summary omitted cleanup error:\n%s", view) + } +} + +func TestViewSummary_ExecutionFailureOverridesSuccessfulDeletionCopy(t *testing.T) { + m := NewModel(nil, nil, "/repo") + m.remove.run.result = worktree.DeletionResult{ + SuccessCount: 1, + Phases: []progress.Phase{{Name: "Unlock", Steps: []progress.StepResult{{Name: "feature/a", Status: progress.StepFailed, Error: errors.New("unlock denied")}}}}, + } + m.view = summaryView + + view := stripANSI(m.viewSummary()) + if strings.Contains(view, "removed successfully") { + t.Fatalf("summary advertised success despite failed execution:\n%s", view) + } + if !strings.Contains(view, "1 removed") || !strings.Contains(view, "unlock denied") { + t.Fatalf("summary lost deletion count or execution error:\n%s", view) + } +} + +func TestViewSummary_DeliveryErrorIsVisible(t *testing.T) { + m := NewModel(nil, nil, "/repo") + m.remove.run.result = worktree.DeletionResult{SuccessCount: 1, Err: errors.New("progress delivery failed")} + m.view = summaryView + + view := stripANSI(m.viewSummary()) + if strings.Contains(view, "removed successfully") || !strings.Contains(view, "progress delivery failed") { + t.Fatalf("delivery failure not visible:\n%s", view) + } +} + type stubRunner struct { responses map[string]stubResponse } @@ -34,7 +91,7 @@ func (s *stubRunner) Run(dir string, args ...string) (string, error) { return resp.output, resp.err } -func TestUpdateProgress_AllDeletionsComplete_TriggersPrune(t *testing.T) { +func TestUpdateProgress_DeletionsComplete_TriggersPrune(t *testing.T) { runner := &stubRunner{ responses: map[string]stubResponse{ "/repo worktree prune": {output: ""}, @@ -46,7 +103,7 @@ func TestUpdateProgress_AllDeletionsComplete_TriggersPrune(t *testing.T) { }, runner, "/repo") m.view = progressView - updated, cmd := m.updateProgress(allDeletionsCompleteMsg{}) + updated, cmd := m.updateProgress(deletionsCompleteMsg{}) if cmd == nil { t.Fatal("expected a Cmd from allDeletionsCompleteMsg, got nil") @@ -107,6 +164,7 @@ func TestUpdateProgress_CleanupComplete_TransitionsToSummary(t *testing.T) { updated, _ := m.updateProgress(cleanupCompleteMsg{}) model := updated.(Model) + model = settleNow(t, model) if model.view != summaryView { t.Errorf("expected summaryView, got %d", model.view) } @@ -193,21 +251,6 @@ func TestWithMinProgressDuration_SetsField(t *testing.T) { } } -func TestHoldOrAdvance_ZeroDuration_TransitionsImmediately(t *testing.T) { - m := NewModel([]git.Worktree{}, nil, "/repo") - // minProgressDuration defaults to 0 - - result, cmd := m.holdOrAdvance(summaryView) - model := result.(Model) - - if model.view != summaryView { - t.Errorf("expected summaryView, got %d", model.view) - } - if cmd != nil { - t.Error("expected nil cmd when transitioning immediately") - } -} - func TestHoldOrAdvance_MinDurationNotElapsed_HoldsAndReturnsCmd(t *testing.T) { m := NewModel([]git.Worktree{}, nil, "/repo") m.minProgressDuration = 10 * time.Second @@ -245,7 +288,7 @@ func TestHoldOrAdvance_MinDurationElapsed_StillSettles(t *testing.T) { } } -func TestHoldOrAdvance_NoHold_TransitionsImmediately(t *testing.T) { +func TestHoldOrAdvance_NoEntryHold_StillSettles(t *testing.T) { m := NewModel([]git.Worktree{}, nil, "/repo") m.minProgressDuration = 0 m.progressToken = 1 @@ -253,98 +296,110 @@ func TestHoldOrAdvance_NoHold_TransitionsImmediately(t *testing.T) { result, cmd := m.holdOrAdvance(summaryView) model := result.(Model) - if model.view != summaryView { - t.Errorf("expected immediate transition with holds disabled, got %d", model.view) + if model.view == summaryView { + t.Error("the completion settle applies in every run mode: no instant cut") } - if cmd != nil { - t.Error("expected nil cmd with holds disabled") + if !model.progressSettling { + t.Error("holdOrAdvance must begin settling") + } + if cmd == nil { + t.Error("expected the hard-timeout probe cmd") } } -func TestProgressHoldExpiredMsg_CorrectToken_Transitions(t *testing.T) { +func TestObserveSettle_AdvancesAfterBeat(t *testing.T) { m := NewModel([]git.Worktree{}, nil, "/repo") m.view = progressView - m.progressToken = 3 m.progressTargetView = summaryView + m.progressSettling = true + m.progressSettlingSince = time.Now().Add(-time.Second) + m.progressSettledAt = time.Now().Add(-progressSettleBeat - time.Millisecond) - result, cmd := m.Update(progressHoldExpiredMsg{token: 3}) - model := result.(Model) - - if model.view != summaryView { - t.Errorf("expected summaryView, got %d", model.view) + model, advanced := m.observeSettle(time.Now()) + if !advanced || !model.progressTransitionPending || model.view != progressView { + t.Errorf("settled fill must schedule a renderer-safe transition, got view %d pending=%v", model.view, model.progressTransitionPending) } - if cmd != nil { - t.Errorf("expected nil cmd, got %v", cmd) + if model.progressSettling { + t.Error("advancing must end the settling state") } } -func TestHoldCycle_CompletionHoldsUntilExpiry(t *testing.T) { - // Model just entered progressView with a 50ms hold. +func TestObserveSettle_BeatNotElapsed_Holds(t *testing.T) { m := NewModel([]git.Worktree{}, nil, "/repo") m.view = progressView - m.minProgressDuration = 50 * time.Millisecond - m.progressStartedAt = time.Now() - m.progressToken = 1 - - // Operation completes immediately. - result, cmd := m.updateProgress(cleanupCompleteMsg{}) - model := result.(Model) + m.progressTargetView = summaryView + m.progressSettling = true + m.progressSettlingSince = time.Now() + m.progressSettledAt = time.Now() // just settled: beat starts now - // Should NOT have transitioned yet — min duration not elapsed. - if model.view == summaryView { - t.Fatal("should not transition to summaryView before min duration elapses") - } - if cmd == nil { - t.Fatal("expected a hold cmd (tea.Tick)") + model, advanced := m.observeSettle(time.Now()) + if advanced || model.view == summaryView { + t.Error("the view must hold until the settled beat elapses") } +} - // Execute the cmd — returns a tea.BatchMsg containing the hold tick and reload. - msg := cmd() - batch, ok := msg.(tea.BatchMsg) - if !ok { - t.Fatalf("expected tea.BatchMsg, got %T", msg) - } +func TestObserveSettle_EntryHoldGates(t *testing.T) { + m := NewModel([]git.Worktree{}, nil, "/repo") + m.view = progressView + m.progressTargetView = summaryView + m.minProgressDuration = 10 * time.Second // playground entry hold, far from done + m.progressStartedAt = time.Now() + m.progressSettling = true + m.progressSettlingSince = time.Now() + m.progressSettledAt = time.Now().Add(-progressSettleBeat - time.Millisecond) - // Find the hold tick in the batch and execute it. - var holdMsg progressHoldExpiredMsg - found := false - for _, batchCmd := range batch { - if batchCmd == nil { - continue - } - batchMsg := batchCmd() - if hm, ok := batchMsg.(progressHoldExpiredMsg); ok { - holdMsg = hm - found = true - break - } - } - if !found { - t.Fatal("expected progressHoldExpiredMsg in batch") - } - if holdMsg.token != 1 { - t.Errorf("token = %d, want 1", holdMsg.token) + model, advanced := m.observeSettle(time.Now()) + if advanced || model.view == summaryView { + t.Error("playground entry hold must keep gating even after the beat") } +} - // Deliver the expired message. - result2, _ := model.Update(holdMsg) - model2 := result2.(Model) +func TestObserveSettle_TimeoutForcesAdvance(t *testing.T) { + m := NewModel([]git.Worktree{}, nil, "/repo") + m.view = progressView + m.progressTargetView = summaryView + m.progressSettling = true + m.progressSettlingSince = time.Now().Add(-progressSettleTimeout - time.Millisecond) + // Never settled (settledAt zero): the hard timeout must still advance. - if model2.view != summaryView { - t.Errorf("expected summaryView after hold expired, got %d", model2.view) + model, advanced := m.observeSettle(time.Now()) + if !advanced || !model.progressTransitionPending || model.view != progressView { + t.Error("the hard timeout must schedule a view that cannot settle") } } -func TestProgressHoldExpiredMsg_StaleToken_Ignored(t *testing.T) { +func TestSettleProbe_StaleToken_Ignored(t *testing.T) { m := NewModel([]git.Worktree{}, nil, "/repo") m.view = progressView m.progressToken = 5 m.progressTargetView = summaryView + m.progressSettling = true + m.progressSettlingSince = time.Now().Add(-progressSettleTimeout - time.Millisecond) - result, _ := m.Update(progressHoldExpiredMsg{token: 3}) // stale token + result, _ := m.Update(progressSettleProbeMsg{token: 3}) // stale token model := result.(Model) if model.view == summaryView { - t.Error("stale token should not transition view") + t.Error("stale probe token should not transition view") + } +} + +func TestSettleProbe_CorrectToken_RunsCheck(t *testing.T) { + m := NewModel([]git.Worktree{}, nil, "/repo") + m.view = progressView + m.progressToken = 3 + m.progressTargetView = summaryView + m.progressSettling = true + m.progressSettlingSince = time.Now().Add(-progressSettleTimeout - time.Millisecond) + + result, cmd := m.Update(progressSettleProbeMsg{token: 3}) + model := result.(Model) + + if model.view != progressView || !model.progressTransitionPending || cmd == nil { + t.Errorf("timeout probe must schedule refresh before transition, view=%d pending=%v cmd=%v", model.view, model.progressTransitionPending, cmd != nil) + } + completed, _ := model.Update(progressTransitionMsg{token: 3}) + if completed.(Model).view != summaryView { + t.Errorf("post-refresh transition ended at %d, want summary", completed.(Model).view) } } diff --git a/internal/tui/progress_viewport.go b/internal/tui/progress_viewport.go new file mode 100644 index 0000000..4522df6 --- /dev/null +++ b/internal/tui/progress_viewport.go @@ -0,0 +1,131 @@ +package tui + +import "github.com/abiswas97/sentei/internal/progress" + +type progressViewportTier uint8 + +const ( + progressViewportEmergency progressViewportTier = iota + progressViewportMinimal + progressViewportCompact + progressViewportNormal +) + +type ProgressViewport struct { + History []progress.PhaseState + HistoryOmitted int + Focus *progress.PhaseState + Queued int + DetailRows int + Tier progressViewportTier +} + +func BuildProgressViewport(phases []progress.PhaseState, rows int, completed bool) ProgressViewport { + viewport := ProgressViewport{Tier: progressTier(rows)} + focusIndex := focusPhaseIndex(phases, completed) + if focusIndex >= 0 { + focus := phases[focusIndex] + viewport.Focus = &focus + } + + var history []progress.PhaseState + for i, phase := range phases { + if i == focusIndex { + continue + } + if phaseSettledOrTerminal(phase) || (completed && phase.Total == 0) { + history = append(history, phase) + } else { + viewport.Queued++ + } + } + + liveRows := liveRegionRows(rows, viewport.Tier) + historyLimit := 0 + switch viewport.Tier { + case progressViewportNormal: + historyLimit = min(3, max(liveRows-3, 0)) + case progressViewportCompact: + historyLimit = min(1, max(liveRows-3, 0)) + } + if len(history) > historyLimit { + viewport.HistoryOmitted = len(history) - historyLimit + history = history[len(history)-historyLimit:] + } + viewport.History = history + used := len(viewport.History) + if viewport.HistoryOmitted > 0 { + used++ + } + if viewport.Queued > 0 { + used++ + } + viewport.DetailRows = max(liveRows-used, 0) + return viewport +} + +func progressTier(rows int) progressViewportTier { + switch { + case rows >= 18: + return progressViewportNormal + case rows >= 12: + return progressViewportCompact + case rows >= 4: + return progressViewportMinimal + default: + return progressViewportEmergency + } +} + +func liveRegionRows(rows int, tier progressViewportTier) int { + switch tier { + case progressViewportNormal: + return max(rows-9, 0) + case progressViewportCompact: + return max(rows-4, 0) + case progressViewportMinimal: + return max(rows-3, 0) + default: + return 0 + } +} + +func focusPhaseIndex(phases []progress.PhaseState, completed bool) int { + for i, phase := range phases { + for _, step := range phase.Steps { + if step.Status == progress.StepRunning { + return i + } + } + } + for i := len(phases) - 1; i >= 0; i-- { + if phases[i].Failed > 0 || phaseHasStatus(phases[i], progress.StepFailed) { + return i + } + } + if !completed { + for i, phase := range phases { + if !phaseSettledOrTerminal(phase) { + return i + } + } + return -1 + } + if len(phases) > 0 { + return len(phases) - 1 + } + return -1 +} + +func phaseHasStatus(phase progress.PhaseState, status progress.StepStatus) bool { + for _, step := range phase.Steps { + if step.Status == status { + return true + } + } + return false +} + +func phaseSettledOrTerminal(phase progress.PhaseState) bool { + return phase.Settled() || (phase.Total > 0 && phase.Done >= phase.Total) +} diff --git a/internal/tui/removal_e2e_test.go b/internal/tui/removal_e2e_test.go index c262b0e..03fc713 100644 --- a/internal/tui/removal_e2e_test.go +++ b/internal/tui/removal_e2e_test.go @@ -7,6 +7,8 @@ import ( tea "charm.land/bubbletea/v2" "github.com/abiswas97/sentei/internal/git" + "github.com/abiswas97/sentei/internal/progress" + "github.com/abiswas97/sentei/internal/worktree" ) // TestE2E_RemovalFlowChrome drives select -> confirm -> progress -> summary @@ -42,12 +44,15 @@ func TestE2E_RemovalFlowChrome(t *testing.T) { } } - for _, path := range []string{"/work/a", "/work/b"} { - updated, _ = m.updateProgress(worktreeDeletedMsg{Path: path}) - m = updated.(Model) - } + updated, _ = m.updateProgress(deletionsCompleteMsg{Result: worktree.DeletionResult{SuccessCount: 2}}) + m = updated.(Model) + updated, _ = m.updateProgress(pruneCompleteMsg{}) + m = updated.(Model) updated, _ = m.updateProgress(cleanupCompleteMsg{}) m = updated.(Model) + updated, _ = m.updateProgress(removalEventsCompleteMsg{}) + m = updated.(Model) + m = settleNow(t, m) if m.view != summaryView { t.Fatalf("expected summaryView, got %d", m.view) } @@ -86,6 +91,14 @@ func TestUpdateProgress_WindowSizeUpdatesWindowing(t *testing.T) { m.width = 100 m.remove.run = newRemovalRun(worktrees) m.remove.run.statuses[worktrees[0].Path] = statusRemoving + for i, wt := range worktrees { + stepID := progress.StepID(stepName(i)) + m.remove.run.events = append(m.remove.run.events, progress.Event{Phase: worktree.RemovalPhaseID, PhaseLabel: worktree.RemovalPhaseName, Step: stepID, StepLabel: wt.Branch, Status: progress.StepPending, Of: 2}) + } + m.remove.run.events = append(m.remove.run.events, + progress.Event{Phase: worktree.RemovalPhaseID, PhaseLabel: worktree.RemovalPhaseName, Close: true}, + progress.Event{Phase: worktree.RemovalPhaseID, Step: progress.StepID(stepName(0)), Status: progress.StepRunning, Checkpoint: 1, Of: 2}, + ) m.view = progressView updated, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 16}) diff --git a/internal/tui/removal_plan_test.go b/internal/tui/removal_plan_test.go new file mode 100644 index 0000000..7c57a6a --- /dev/null +++ b/internal/tui/removal_plan_test.go @@ -0,0 +1,65 @@ +package tui + +import ( + "testing" + + "github.com/abiswas97/sentei/internal/git" + "github.com/abiswas97/sentei/internal/progress" + "github.com/abiswas97/sentei/internal/worktree" +) + +// Spec "Teardown counts are real": the running Teardown phase displays the +// planned total from its first frame, not a placeholder. +func TestBuildRemovalPhases_TeardownDeclaresPlannedTotal(t *testing.T) { + m := NewModel([]git.Worktree{{Path: "/wt/a"}, {Path: "/wt/b"}}, nil, "/repo") + m.remove.run = newRemovalRun(m.remove.worktrees) + m.remove.run.teardownRunning = true + m.remove.run.teardownPlanned = []string{ + "Teardown code-review-graph", "Teardown cocoindex-code", + "Teardown code-review-graph", "Teardown cocoindex-code", + } + for i, label := range m.remove.run.teardownPlanned { + m.remove.run.events = append(m.remove.run.events, progress.Event{Phase: teardownPhaseID, PhaseLabel: "Teardown", Step: progress.StepID(string(rune('a' + i))), StepLabel: label, Status: progress.StepPending, Of: 1}) + } + m.remove.run.events = append(m.remove.run.events, progress.Event{Phase: teardownPhaseID, PhaseLabel: "Teardown", Close: true}) + + phases := m.buildRemovalPhases() + td := phases[0] + if td.Name != "Teardown" || td.Total != 4 { + t.Fatalf("running teardown = %s %d, want Teardown with total 4 from the plan", td.Name, td.Total) + } + if td.Done != 0 || td.Settled() { + t.Error("running teardown must not report completion before results land") + } +} + +// Spec "Parallel removal moves the bar at start": started-but-unfinished +// removals reach their first checkpoint while headers still count 0 done. +func TestBuildRemovalPhases_StartCheckpointsMoveTheBar(t *testing.T) { + wts := []git.Worktree{{Path: "/wt/a"}, {Path: "/wt/b"}, {Path: "/wt/c"}} + m := NewModel(wts, nil, "/repo") + m.remove.run = newRemovalRun(wts) + for i := range wts { + stepID := progress.StepID(string(rune('a' + i))) + m.remove.run.events = append(m.remove.run.events, + progress.Event{Phase: worktree.RemovalPhaseID, PhaseLabel: worktree.RemovalPhaseName, Step: stepID, Status: progress.StepPending, Of: 2}, + progress.Event{Phase: worktree.RemovalPhaseID, Step: stepID, Status: progress.StepRunning, Checkpoint: 1, Of: 2}, + ) + } + m.remove.run.events = append(m.remove.run.events, progress.Event{Phase: worktree.RemovalPhaseID, PhaseLabel: worktree.RemovalPhaseName, Close: true}) + + phases := m.buildRemovalPhases() + var removing progress.PhaseState + for _, p := range phases { + if p.Name == "Removing worktrees" { + removing = p + } + } + if removing.Done != 0 { + t.Fatalf("headers must count 0 done while all removals run, got %d", removing.Done) + } + reached, declared := progress.CheckpointProgress([]progress.PhaseState{removing}) + if reached != 3 || declared != 6 { + t.Errorf("checkpoints = %d/%d, want 3/6 (start credit for three parallel removals)", reached, declared) + } +} diff --git a/internal/tui/removal_preparation_test.go b/internal/tui/removal_preparation_test.go new file mode 100644 index 0000000..236d181 --- /dev/null +++ b/internal/tui/removal_preparation_test.go @@ -0,0 +1,137 @@ +package tui + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/abiswas97/sentei/internal/git" + "github.com/abiswas97/sentei/internal/integration" + "github.com/abiswas97/sentei/internal/progress" +) + +func TestPrepareRemoval_SemanticIDsSurviveSelectionReordering(t *testing.T) { + a := git.Worktree{Path: "/repo/../repo/a", Branch: "refs/heads/feature/a", IsLocked: true} + b := git.Worktree{Path: "/repo/b", Branch: "refs/heads/feature/b", IsLocked: true} + + first, err := prepareRemoval([]git.Worktree{a, b}, nil) + if err != nil { + t.Fatal(err) + } + second, err := prepareRemoval([]git.Worktree{b, a}, nil) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(stepIDsByLabel(first.plan), stepIDsByLabel(second.plan)) { + t.Fatalf("IDs changed after reorder:\nfirst=%v\nsecond=%v", stepIDsByLabel(first.plan), stepIDsByLabel(second.plan)) + } + for _, phase := range first.plan.Phases { + if phase.ID == cleanupPhaseID { + continue + } + for _, step := range phase.Steps { + if !strings.Contains(string(step.ID), ":") || strings.HasSuffix(string(step.ID), "-0") || strings.HasSuffix(string(step.ID), "-1") { + t.Fatalf("step ID %q is not semantic", step.ID) + } + } + } +} + +func TestPrepareRemoval_RejectsDuplicateSemanticWorktree(t *testing.T) { + _, err := prepareRemoval([]git.Worktree{ + {Path: "/repo/a", Branch: "refs/heads/feature/a"}, + {Path: "/repo/x/../a", Branch: "refs/heads/feature/a"}, + }, nil) + if err == nil || !strings.Contains(err.Error(), "duplicate worktree identity") { + t.Fatalf("duplicate error = %v", err) + } +} + +func TestPrepareRemoval_RejectsDuplicateSemanticTeardown(t *testing.T) { + wtPath := t.TempDir() + if err := os.Mkdir(filepath.Join(wtPath, ".artifact"), 0o755); err != nil { + t.Fatal(err) + } + duplicate := integration.Integration{Name: "tool", Teardown: integration.TeardownSpec{Command: "tool clean", Dirs: []string{".artifact/"}}} + _, err := prepareRemoval([]git.Worktree{{Path: wtPath, Branch: "refs/heads/a"}}, []integration.Integration{duplicate, duplicate}) + if err == nil || !strings.Contains(err.Error(), "duplicate teardown identity") { + t.Fatalf("duplicate teardown error = %v", err) + } +} + +func TestPrepareRemoval_RejectsUnsafeArtifactPathBeforeScanning(t *testing.T) { + wtPath := t.TempDir() + integrations := []integration.Integration{{ + Name: "tool", Teardown: integration.TeardownSpec{Dirs: []string{"../victim"}}, + }} + + _, err := prepareRemoval([]git.Worktree{{Path: wtPath, Branch: "refs/heads/a"}}, integrations) + if err == nil || !strings.Contains(err.Error(), "managed path") { + t.Fatalf("prepareRemoval error = %v, want managed path validation error", err) + } +} + +func TestPrepareRemoval_RejectsSymlinkedArtifactPathBeforeScanning(t *testing.T) { + wtPath := t.TempDir() + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(wtPath, "escape")); err != nil { + t.Fatal(err) + } + integrations := []integration.Integration{{ + Name: "tool", Teardown: integration.TeardownSpec{Dirs: []string{"escape/victim"}}, + }} + + _, err := prepareRemoval([]git.Worktree{{Path: wtPath, Branch: "refs/heads/a"}}, integrations) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("prepareRemoval error = %v, want symlink validation error", err) + } +} + +func TestPrepareRemoval_ArtifactOperationsHaveStableSortedSemanticIDs(t *testing.T) { + wtPath := t.TempDir() + for _, dir := range []string{"zeta", "alpha"} { + if err := os.Mkdir(filepath.Join(wtPath, dir), 0o755); err != nil { + t.Fatal(err) + } + } + integrationWithDirs := func(dirs []string) integration.Integration { + return integration.Integration{ + Name: "tool", Teardown: integration.TeardownSpec{Command: "tool clean", Dirs: dirs}, + } + } + + first, err := prepareRemoval([]git.Worktree{{Path: wtPath, Branch: "refs/heads/a"}}, []integration.Integration{integrationWithDirs([]string{"zeta", "alpha"})}) + if err != nil { + t.Fatal(err) + } + second, err := prepareRemoval([]git.Worktree{{Path: wtPath, Branch: "refs/heads/a"}}, []integration.Integration{integrationWithDirs([]string{"alpha", "zeta"})}) + if err != nil { + t.Fatal(err) + } + + if len(first.teardownOps) != 3 { + t.Fatalf("teardown operations = %d, want command plus two removals", len(first.teardownOps)) + } + for i := range first.teardownOps { + if first.teardownOps[i].stepID != second.teardownOps[i].stepID || first.teardownOps[i].label != second.teardownOps[i].label { + t.Fatalf("operation %d changed under input reorder: first=%+v second=%+v", i, first.teardownOps[i], second.teardownOps[i]) + } + } + wantLabels := []string{integration.RemoveDirStepName("alpha", wtPath), integration.RemoveDirStepName("zeta", wtPath)} + if got := []string{first.teardownOps[1].label, first.teardownOps[2].label}; !reflect.DeepEqual(got, wantLabels) { + t.Fatalf("artifact operation order = %v", got) + } +} + +func stepIDsByLabel(plan progress.Plan) map[string]progress.StepID { + ids := map[string]progress.StepID{} + for _, phase := range plan.Phases { + for _, step := range phase.Steps { + ids[string(phase.ID)+"\x00"+step.Label] = step.ID + } + } + return ids +} diff --git a/internal/tui/remove_run.go b/internal/tui/remove_run.go index 744510f..2f5a0f0 100644 --- a/internal/tui/remove_run.go +++ b/internal/tui/remove_run.go @@ -3,7 +3,7 @@ package tui import ( "github.com/abiswas97/sentei/internal/cleanup" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/worktree" ) @@ -16,15 +16,52 @@ type removalRun struct { worktrees []git.Worktree statuses map[string]string result worktree.DeletionResult - progressCh <-chan worktree.DeletionEvent + progressCh chan progress.Event + execution *progress.Execution + events []progress.Event + targets []worktree.RemovalTarget teardownRunning bool - teardownResults []pipeline.StepResult + // teardownPlanned is the step list scanned at confirm time (one step per + // worktree-integration with artifacts present), so the Teardown phase + // displays its real total from its first frame. + teardownPlanned []string + teardownResults []progress.StepResult + teardownOps []teardownOperation pruneErr *error cleanupResult *cleanup.Result } +type teardownOperation struct { + groupID string + stepID progress.StepID + label string + kind teardownOperationKind + wtPath string + command string + managedPaths []string +} + +type teardownOperationKind uint8 + +const ( + teardownCommand teardownOperationKind = iota + teardownArtifacts +) + +type unlockOperation struct { + stepID progress.StepID + worktree git.Worktree +} + +type removalPreparation struct { + plan progress.Plan + unlockOps []unlockOperation + teardownOps []teardownOperation + targets []worktree.RemovalTarget +} + func newRemovalRun(selected []git.Worktree) removalRun { statuses := make(map[string]string, len(selected)) for _, wt := range selected { diff --git a/internal/tui/remove_run_test.go b/internal/tui/remove_run_test.go index 62d210f..7fa8fe6 100644 --- a/internal/tui/remove_run_test.go +++ b/internal/tui/remove_run_test.go @@ -9,7 +9,7 @@ import ( "github.com/abiswas97/sentei/internal/cleanup" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/worktree" ) @@ -34,7 +34,7 @@ func pollutedRun() removalRun { {Path: "/work/b", Success: true}, }, }, - teardownResults: []pipeline.StepResult{{Name: "old", Status: pipeline.StepDone}}, + teardownResults: []progress.StepResult{{Name: "old", Status: progress.StepDone}}, pruneErr: &pruneErr, cleanupResult: &cleanup.Result{}, } @@ -86,8 +86,10 @@ func TestConfirmYes_SecondRunStartsFresh(t *testing.T) { if !strings.Contains(view, "0%") { t.Errorf("expected fresh run to start at 0%%, view:\n%s", view) } - if !strings.Contains(view, "pending") { - t.Errorf("expected prune phase pending on fresh run, view:\n%s", view) + phases := model.buildRemovalPhases() + cleanupPhase := phases[len(phases)-1] + if cleanupPhase.Name != "Prune & cleanup" || cleanupPhase.Done != 0 { + t.Errorf("expected prune phase pending on fresh run, got %+v", cleanupPhase) } } @@ -98,7 +100,7 @@ func TestViewProgress_SecondRunCompletesAtHundredPercent(t *testing.T) { m.remove.run = newRemovalRun([]git.Worktree{{Path: "/work/c", Branch: "refs/heads/c"}}) m.view = progressView - updated, _ := m.updateProgress(worktreeDeletedMsg{Path: "/work/c"}) + updated, _ := m.updateProgress(removalEventMsg{event: progress.Event{Phase: worktree.RemovalPhaseName, Step: "/work/c", Status: progress.StepDone}}) model := updated.(Model) view := model.viewProgress() @@ -107,6 +109,30 @@ func TestViewProgress_SecondRunCompletesAtHundredPercent(t *testing.T) { } } +func TestUpdateProgress_TerminalDeletionResultIsNotDoubleCountedByTrailingEvents(t *testing.T) { + wt := git.Worktree{Path: "/work/a", Branch: "refs/heads/a"} + m := NewModel([]git.Worktree{wt}, nil, "/repo") + m.remove.run = newRemovalRun([]git.Worktree{wt}) + m.remove.run.targets = []worktree.RemovalTarget{{Worktree: wt, StepID: "remove-a"}} + m.remove.run.progressCh = make(chan progress.Event) + m.view = progressView + + terminal := worktree.DeletionResult{ + SuccessCount: 1, + Outcomes: []worktree.WorktreeOutcome{{Path: wt.Path, Success: true}}, + } + updated, _ := m.updateProgress(deletionsCompleteMsg{Result: terminal}) + m = updated.(Model) + updated, _ = m.updateProgress(removalEventMsg{event: progress.Event{ + Phase: worktree.RemovalPhaseID, Step: "remove-a", Status: progress.StepDone, + }}) + m = updated.(Model) + + if m.remove.run.result.SuccessCount != 1 || len(m.remove.run.result.Outcomes) != 1 { + t.Fatalf("trailing terminal event double-counted result: %+v", m.remove.run.result) + } +} + func TestUpdateProgress_CleanupComplete_ClearsSelection(t *testing.T) { m := NewModel([]git.Worktree{ {Path: "/work/a", Branch: "refs/heads/a"}, @@ -148,6 +174,11 @@ func TestViewProgress_TeardownRunning_ShowsActivePhase(t *testing.T) { }, nil, "/repo") m.remove.run = newRemovalRun([]git.Worktree{{Path: "/work/a", Branch: "refs/heads/a"}}) m.remove.run.teardownRunning = true + m.remove.run.events = []progress.Event{ + {Phase: teardownPhaseID, PhaseLabel: "Teardown", Step: "teardown-0", StepLabel: "Teardown code-review-graph", Status: progress.StepPending, Of: 1}, + {Phase: teardownPhaseID, PhaseLabel: "Teardown", Close: true}, + {Phase: teardownPhaseID, Step: "teardown-0", Status: progress.StepRunning, Of: 1}, + } m.view = progressView view := stripAnsi(m.viewProgress()) @@ -190,10 +221,20 @@ func TestUpdateProgress_TeardownComplete_StartsDeletions(t *testing.T) { m := NewModel(wts, nil, "/repo") m.remove.run = newRemovalRun(wts) m.remove.run.teardownRunning = true + m.remove.run.progressCh = make(chan progress.Event, 8) + m.remove.run.targets = []worktree.RemovalTarget{{Worktree: wts[0], StepID: "remove-0"}} + execution, err := progress.Start(progress.Plan{Phases: []progress.PlannedPhase{{ + ID: worktree.RemovalPhaseID, Label: worktree.RemovalPhaseName, + Steps: []progress.PlannedStep{{ID: "remove-0", Label: "a", Checkpoints: 2}}, + }}}, nil) + if err != nil { + t.Fatal(err) + } + m.remove.run.execution = execution m.view = progressView updated, cmd := m.updateProgress(teardownCompleteMsg{ - results: []pipeline.StepResult{{Name: "Teardown code-review-graph", Status: pipeline.StepDone}}, + results: []progress.StepResult{{Name: "Teardown code-review-graph", Status: progress.StepDone}}, }) model := updated.(Model) @@ -203,10 +244,7 @@ func TestUpdateProgress_TeardownComplete_StartsDeletions(t *testing.T) { if len(model.remove.run.teardownResults) != 1 { t.Errorf("expected teardown results stored, got %d", len(model.remove.run.teardownResults)) } - if model.remove.run.progressCh == nil { - t.Error("expected deletion channel to be created") - } if cmd == nil { - t.Fatal("expected a Cmd that consumes deletion events") + t.Fatal("expected a Cmd that starts deletion") } } diff --git a/internal/tui/repo_progress.go b/internal/tui/repo_progress.go index 23716be..aba96d0 100644 --- a/internal/tui/repo_progress.go +++ b/internal/tui/repo_progress.go @@ -4,11 +4,11 @@ import ( "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" ) -type repoEventMsg pipeline.Event +type repoEventMsg progress.Event type repoDoneMsg struct { result interface{} // CreateResult, CloneResult, or MigrateResult @@ -16,7 +16,7 @@ type repoDoneMsg struct { // startRepoPipeline launches the appropriate pipeline based on opts type. func (m *Model) startRepoPipeline(opts interface{}) tea.Cmd { - ch := make(chan pipeline.Event, 50) + ch := make(chan progress.Event, 50) resultCh := make(chan interface{}, 1) m.repo.eventCh = ch m.repo.resultCh = resultCh @@ -27,19 +27,19 @@ func (m *Model) startRepoPipeline(opts interface{}) tea.Cmd { switch o := opts.(type) { case repo.CreateOptions: go func() { - result := repo.Create(runner, shell, o, func(e pipeline.Event) { ch <- e }) + result := repo.Create(runner, shell, o, func(e progress.Event) { ch <- e }) close(ch) resultCh <- result }() case repo.CloneOptions: go func() { - result := repo.Clone(runner, o, func(e pipeline.Event) { ch <- e }) + result := repo.Clone(runner, o, func(e progress.Event) { ch <- e }) close(ch) resultCh <- result }() case repo.MigrateOptions: go func() { - result := repo.Migrate(runner, shell, o, func(e pipeline.Event) { ch <- e }) + result := repo.Migrate(runner, shell, o, func(e progress.Event) { ch <- e }) close(ch) resultCh <- result }() @@ -68,7 +68,7 @@ func (m Model) updateRepoProgress(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case repoEventMsg: - m.repo.events = append(m.repo.events, pipeline.Event(msg)) + m.repo.events = append(m.repo.events, progress.Event(msg)) return m, tea.Batch(m.syncProgressBar(), m.waitForRepoEvent()) case repoDoneMsg: @@ -98,15 +98,15 @@ func (m Model) repoLayout() ProgressLayout { subject = m.repoPath } - return ProgressLayout{ + return m.withProgressDetails(ProgressLayout{ Title: title, Subtitle: subject, Completed: m.repo.result != nil, - Phases: buildPhaseDisplays(m.repo.events), + Phases: progress.Snapshot(m.repo.events), Width: m.width, - Height: m.height, + Height: m.progressHeight(), Hints: progressFooter, - } + }) } func (m Model) viewRepoProgress() string { diff --git a/internal/tui/repo_progress_test.go b/internal/tui/repo_progress_test.go index 50238d4..e600dbf 100644 --- a/internal/tui/repo_progress_test.go +++ b/internal/tui/repo_progress_test.go @@ -1,24 +1,25 @@ package tui import ( + "fmt" "strings" "testing" "github.com/abiswas97/sentei/internal/config" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" ) // drainRepoPipeline executes wait commands until the repo pipeline goroutine // reports completion, returning the final result and the events seen. -func drainRepoPipeline(t *testing.T, m Model) (interface{}, []pipeline.Event) { +func drainRepoPipeline(t *testing.T, m Model) (interface{}, []progress.Event) { t.Helper() - var events []pipeline.Event + var events []progress.Event for range 100 { msg := m.waitForRepoEvent()() switch msg := msg.(type) { case repoEventMsg: - events = append(events, pipeline.Event(msg)) + events = append(events, progress.Event(msg)) case repoDoneMsg: return msg.result, events default: @@ -64,8 +65,12 @@ func TestStartRepoPipeline_DispatchesByOptionsType(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { m := repoProgressModel(t) + location := t.TempDir() + if tc.name == "migrate" { + m.runner.(*stubRunner).responses[location+" remote get-url origin"] = stubResponse{err: fmt.Errorf("error: No such remote 'origin'")} + } - cmd := m.startRepoPipeline(tc.opts(t.TempDir())) + cmd := m.startRepoPipeline(tc.opts(location)) if m.repo.eventCh == nil || m.repo.resultCh == nil { t.Fatal("pipeline channels must be wired") @@ -86,10 +91,10 @@ func TestStartRepoPipeline_DispatchesByOptionsType(t *testing.T) { func TestWaitForRepoEvent_DeliversEventThenDone(t *testing.T) { m := repoProgressModel(t) - m.repo.eventCh = make(chan pipeline.Event, 1) + m.repo.eventCh = make(chan progress.Event, 1) m.repo.resultCh = make(chan interface{}, 1) - m.repo.eventCh <- pipeline.Event{Phase: "Clone", Step: "Clone bare repository", Status: pipeline.StepRunning} + m.repo.eventCh <- progress.Event{Phase: "Clone", Step: "Clone bare repository", Status: progress.StepRunning} msg := m.waitForRepoEvent()() ev, ok := msg.(repoEventMsg) if !ok { @@ -124,8 +129,8 @@ func TestViewRepoProgress_TitlePerOperation(t *testing.T) { t.Run(tc.opType, func(t *testing.T) { m := repoProgressModel(t) m.repo.opType = tc.opType - m.repo.events = []pipeline.Event{ - {Phase: "Validate", Step: "Check repo", Status: pipeline.StepDone}, + m.repo.events = []progress.Event{ + {Phase: "Validate", Step: "Check repo", Status: progress.StepDone}, } view := stripANSI(m.viewRepoProgress()) diff --git a/internal/tui/repo_summary.go b/internal/tui/repo_summary.go index 77d1851..2c690c2 100644 --- a/internal/tui/repo_summary.go +++ b/internal/tui/repo_summary.go @@ -9,17 +9,20 @@ import ( tea "charm.land/bubbletea/v2" "github.com/abiswas97/sentei/internal/git" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" ) // cloneFailed reports whether the clone left the repo in a failed state and, if // so, the error from the first failed step. func cloneFailed(result repo.CloneResult) (bool, error) { + if result.Err != nil { + return true, result.Err + } if !result.HasFailures() { return false, nil } - _, step, _ := pipeline.FirstFailure(result.Phases) + _, step, _ := progress.FirstFailure(result.Phases) return true, step.Error } diff --git a/internal/tui/repo_summary_test.go b/internal/tui/repo_summary_test.go index 666bb35..eae13e5 100644 --- a/internal/tui/repo_summary_test.go +++ b/internal/tui/repo_summary_test.go @@ -8,10 +8,18 @@ import ( tea "charm.land/bubbletea/v2" "github.com/abiswas97/sentei/internal/config" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" "github.com/abiswas97/sentei/internal/repo" ) +func TestCloneFailedPropagatesContractError(t *testing.T) { + want := errors.New("delivery") + failed, err := cloneFailed(repo.CloneResult{Err: want}) + if !failed || !errors.Is(err, want) { + t.Fatalf("cloneFailed = %v, %v", failed, err) + } +} + func makeRepoSummaryModel(result any) Model { m := NewMenuModel(nil, nil, "/repo", &config.Config{}, repo.ContextNoRepo) m.repo.result = result @@ -25,10 +33,10 @@ func cloneFailureResult() repo.CloneResult { RepoPath: "/tmp/myrepo", DefaultBranch: "main", // WorktreePath intentionally empty: the worktree was never created. - Phases: []pipeline.Phase{ - {Name: "Clone", Steps: []pipeline.StepResult{{Name: "Clone bare repository", Status: pipeline.StepDone}}}, - {Name: "Worktree", Steps: []pipeline.StepResult{ - {Name: "Create worktree", Status: pipeline.StepFailed, Error: errors.New("fatal: invalid reference: main")}, + Phases: []progress.Phase{ + {Name: "Clone", Steps: []progress.StepResult{{Name: "Clone bare repository", Status: progress.StepDone}}}, + {Name: "Worktree", Steps: []progress.StepResult{ + {Name: "Create worktree", Status: progress.StepFailed, Error: errors.New("fatal: invalid reference: main")}, }}, }, } @@ -40,8 +48,8 @@ func cloneSuccessResult() repo.CloneResult { DefaultBranch: "main", WorktreePath: "/tmp/myrepo/main", OriginURL: "git@github.com:user/myrepo.git", - Phases: []pipeline.Phase{ - {Name: "Worktree", Steps: []pipeline.StepResult{{Name: "Create worktree", Status: pipeline.StepDone}}}, + Phases: []progress.Phase{ + {Name: "Worktree", Steps: []progress.StepResult{{Name: "Create worktree", Status: progress.StepDone}}}, }, } } @@ -85,9 +93,9 @@ func TestViewCloneRepoSummary_Success_ShowsReady(t *testing.T) { func TestViewCreateRepoSummary_SetupFailure_ShowsFailed(t *testing.T) { result := repo.CreateResult{ RepoPath: "/tmp/my-project", - Phases: []pipeline.Phase{ - {Name: "Setup", Steps: []pipeline.StepResult{ - {Name: "Initial commit", Status: pipeline.StepFailed, Error: errors.New("git commit: exit status 128")}, + Phases: []progress.Phase{ + {Name: "Setup", Steps: []progress.StepResult{ + {Name: "Initial commit", Status: progress.StepFailed, Error: errors.New("git commit: exit status 128")}, }}, }, } @@ -112,10 +120,10 @@ func TestViewCreateRepoSummary_GitHubFailure_ShowsLocalOnly(t *testing.T) { result := repo.CreateResult{ RepoPath: "/tmp/my-project", WorktreePath: "/tmp/my-project/main", - Phases: []pipeline.Phase{ - {Name: "Setup", Steps: []pipeline.StepResult{{Name: "Create main worktree", Status: pipeline.StepDone}}}, - {Name: "GitHub", Steps: []pipeline.StepResult{ - {Name: "Push to GitHub", Status: pipeline.StepFailed, Error: errors.New("permission denied (publickey)")}, + Phases: []progress.Phase{ + {Name: "Setup", Steps: []progress.StepResult{{Name: "Create main worktree", Status: progress.StepDone}}}, + {Name: "GitHub", Steps: []progress.StepResult{ + {Name: "Push to GitHub", Status: progress.StepFailed, Error: errors.New("permission denied (publickey)")}, }}, }, } diff --git a/internal/tui/summary.go b/internal/tui/summary.go index 05b403e..7d3914b 100644 --- a/internal/tui/summary.go +++ b/internal/tui/summary.go @@ -6,13 +6,17 @@ import ( "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" + + "github.com/abiswas97/sentei/internal/progress" ) func (m Model) updateSummary(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch { - case key.Matches(msg, keys.Quit), key.Matches(msg, keys.Confirm): + case key.Matches(msg, keys.Quit): + return m, tea.Quit + case key.Matches(msg, keys.Confirm): if m.menuItems != nil { m.view = menuView return m, nil @@ -29,7 +33,7 @@ func (m Model) updateSummary(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -func (m Model) viewSummary() string { +func (m Model) renderRemovalSummary() string { var b strings.Builder b.WriteString(viewTitle(titleRemovalComplete)) @@ -38,7 +42,7 @@ func (m Model) viewSummary() string { b.WriteString("\n\n") r := m.remove.run.result - if r.FailureCount == 0 { + if !r.HasFailures() { fmt.Fprintf(&b, " %s %s\n", styleIndicatorDone.Render(indicatorDone), styleSuccess.Render(fmt.Sprintf("%d %s removed successfully", r.SuccessCount, pluralize(r.SuccessCount, "worktree", "worktrees")))) @@ -47,14 +51,31 @@ func (m Model) viewSummary() string { styleSuccess.Render(fmt.Sprintf("%d removed", r.SuccessCount)), styleError.Render(fmt.Sprintf("%d failed", r.FailureCount)), ) - b.WriteString("\n") - b.WriteString(styleError.Render(" Failures:\n")) - for _, o := range r.Outcomes { - if !o.Success { - fmt.Fprintf(&b, " %s %s: %s\n", - styleIndicatorFailed.Render(indicatorFailed), - truncateWithEllipsis(o.Path, max(m.width-10, 20)), - truncateWithEllipsis(fmt.Sprint(o.Error), max(m.width-10, 20))) + if r.FailureCount > 0 { + b.WriteString("\n") + b.WriteString(styleError.Render(" Removal failures:\n")) + for _, o := range r.Outcomes { + if !o.Success { + fmt.Fprintf(&b, " %s %s: %s\n", + styleIndicatorFailed.Render(indicatorFailed), + o.Path, + fmt.Sprint(o.Error)) + } + } + } + if r.Err != nil { + b.WriteString("\n") + fmt.Fprintf(&b, " %s %s\n", styleIndicatorFailed.Render(indicatorFailed), styleError.Render(r.Err.Error())) + } + if progress.PhasesHaveFailures(r.Phases) { + b.WriteString("\n") + b.WriteString(styleError.Render(" Execution failures:\n")) + for _, phase := range r.Phases { + for _, step := range phase.Steps { + if step.Status == progress.StepFailed { + fmt.Fprintf(&b, " %s %s / %s: %s\n", styleIndicatorFailed.Render(indicatorFailed), phase.Name, step.Name, step.Error) + } + } } } } @@ -69,6 +90,14 @@ func (m Model) viewSummary() string { } if cr := m.remove.run.cleanupResult; cr != nil { + if len(cr.Errors) > 0 { + b.WriteString("\n") + b.WriteString(styleError.Render(" Cleanup failures:")) + b.WriteString("\n") + for _, operationErr := range cr.Errors { + fmt.Fprintf(&b, " %s %s: %s\n", styleIndicatorFailed.Render(indicatorFailed), operationErr.Step, operationErr.Err) + } + } cleanupActions := cr.StaleRefsRemoved + cr.ConfigDedupResult.Removed + cr.GoneBranchesDeleted + cr.ConfigOrphanResult.Removed if cleanupActions > 0 { @@ -121,3 +150,59 @@ func (m Model) viewSummary() string { return b.String() } + +func (m Model) viewSummary() string { + return boundRemovalSummary(m.renderRemovalSummary(), m.width, m.progressHeight()) +} + +func boundRemovalSummary(summary string, width, height int) string { + lines := strings.Split(strings.TrimSuffix(summary, "\n"), "\n") + widthOmitted := false + for i, line := range lines { + if fit := fitProgressLine(line, width); fit != line { + widthOmitted = true + lines[i] = fit + } + } + if widthOmitted { + const footerRows = 4 + at := max(len(lines)-footerRows, 0) + hint := fitProgressLine(" ? details for omitted text", width) + lines = append(lines, "") + copy(lines[at+1:], lines[at:]) + lines[at] = hint + } + if !widthOmitted && (height <= 0 || len(lines) <= height) { + return summary + } + if height <= 0 || len(lines) <= height { + return strings.Join(lines, "\n") + } + const footerRows = 4 + if height <= footerRows { + return strings.Join(lines[len(lines)-height:], "\n") + } + previewRows := height - footerRows - 1 + omitted := len(lines) - previewRows - footerRows + visible := append([]string(nil), lines[:previewRows]...) + visible = append(visible, fitProgressLine(fmt.Sprintf(" ? details for %d omitted %s", omitted, pluralize(omitted, "line", "lines")), width)) + visible = append(visible, lines[len(lines)-footerRows:]...) + return strings.Join(visible, "\n") +} + +func (m Model) removalSummaryDetailContent() (string, string) { + full := m.renderRemovalSummary() + lines := strings.Split(strings.TrimSuffix(full, "\n"), "\n") + omitted := m.progressHeight() > 0 && len(lines) > m.progressHeight() + for _, line := range lines { + omitted = omitted || fitProgressLine(line, m.width) != line + } + if !omitted { + return "", "" + } + var detail strings.Builder + for _, line := range lines { + writeProgressDetailValue(&detail, "", line, m.portal.contentWidth()) + } + return "Removal details", strings.TrimSuffix(detail.String(), "\n") +} diff --git a/internal/tui/summary_test.go b/internal/tui/summary_test.go index cf57291..2ce3ff1 100644 --- a/internal/tui/summary_test.go +++ b/internal/tui/summary_test.go @@ -1,21 +1,105 @@ package tui import ( + "errors" + "strings" "testing" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" "github.com/abiswas97/sentei/internal/config" "github.com/abiswas97/sentei/internal/repo" + "github.com/abiswas97/sentei/internal/worktree" ) +func TestViewSummary_BoundsEveryLineAndOffersFullWidthOmissions(t *testing.T) { + for _, width := range []int{20, 40, 80} { + m := NewModel(nil, nil, "/repo") + m.view = summaryView + m.width, m.windowHeight, m.height = width, 40, 34 + m.portal = m.portal.SetSize(width, 40) + fullPath := "/repo/界/" + strings.Repeat("very-long-worktree-name-", 5) + fullError := strings.Repeat("錯誤🙂 failure detail ", 8) + m.remove.run.result.FailureCount = 1 + m.remove.run.result.Outcomes = []worktree.WorktreeOutcome{{Path: fullPath, Error: errors.New(fullError)}} + + view := m.viewSummary() + for row, line := range strings.Split(view, "\n") { + if got := lipgloss.Width(line); got > width { + t.Fatalf("width %d row %d rendered %d cells: %q", width, row+1, got, stripANSI(line)) + } + } + if !strings.Contains(stripANSI(view), "? details") { + t.Fatalf("width %d summary did not advertise width-omitted detail:\n%s", width, stripANSI(view)) + } + title, detail := m.detailContent() + compact := compactProgressDetailText(stripANSI(detail)) + if title == "" || !strings.Contains(compact, compactProgressDetailText(fullPath)) || !strings.Contains(compact, compactProgressDetailText(fullError)) { + t.Fatalf("width %d portal lost full source detail, title=%q:\n%s", width, title, stripANSI(detail)) + } + for row, line := range strings.Split(detail, "\n") { + if got := lipgloss.Width(line); got > m.portal.contentWidth() { + t.Fatalf("width %d detail row %d rendered %d cells", width, row+1, got) + } + } + } +} + +func denseRemovalSummaryModel() Model { + m := NewMenuModel(nil, nil, "/repo", &config.Config{}, repo.ContextBareRepo) + m.view = summaryView + m.width, m.windowHeight, m.height = 80, 24, 18 + m.portal = m.portal.SetSize(80, 24) + m.remove.run.result.FailureCount = 30 + for i := 0; i < 30; i++ { + m.remove.run.result.Outcomes = append(m.remove.run.result.Outcomes, worktree.WorktreeOutcome{ + Path: "/repo/worktrees/failed-" + itoa(i), + Success: false, + Error: errors.New("removal failed " + itoa(i)), + }) + } + return m +} + +func TestViewSummary_DenseFailuresStayWithinTerminalAndKeepFooter(t *testing.T) { + m := denseRemovalSummaryModel() + view := stripANSI(m.viewSummary()) + lines := strings.Split(strings.TrimSuffix(view, "\n"), "\n") + + if len(lines) > m.windowHeight { + t.Fatalf("summary has %d rows, terminal has %d", len(lines), m.windowHeight) + } + if !strings.Contains(lines[len(lines)-1], "q quit") { + t.Fatalf("visible footer missing quit action: %q", lines[len(lines)-1]) + } +} + +func TestViewSummary_DenseFailuresExposeOmittedResultsInDetails(t *testing.T) { + m := denseRemovalSummaryModel() + view := stripANSI(m.viewSummary()) + if !strings.Contains(view, "? details") { + t.Fatalf("bounded summary does not advertise omitted details:\n%s", view) + } + + title, detail := m.detailContent() + if title == "" || !strings.Contains(stripANSI(detail), "failed-29") { + t.Fatalf("detail portal lost final omitted failure: title=%q\n%s", title, stripANSI(detail)) + } + for row, line := range strings.Split(detail, "\n") { + if width := ansi.StringWidth(line); width > m.portal.contentWidth() { + t.Fatalf("detail row %d width=%d exceeds portal width=%d", row+1, width, m.portal.contentWidth()) + } + } +} + func TestUpdateSummary_MenuLaunch_KeysReturnToMenu(t *testing.T) { cases := []struct { name string msg tea.KeyPressMsg }{ {"enter", tea.KeyPressMsg{Code: tea.KeyEnter}}, - {"quit key", keyMsg("q")}, {"esc", tea.KeyPressMsg{Code: tea.KeyEsc}}, } for _, tc := range cases { @@ -35,6 +119,22 @@ func TestUpdateSummary_MenuLaunch_KeysReturnToMenu(t *testing.T) { } } +func TestUpdateSummary_MenuLaunch_QuitKeyMatchesFooter(t *testing.T) { + m := NewMenuModel(nil, nil, "/repo", &config.Config{}, repo.ContextBareRepo) + m.view = summaryView + + updated, cmd := m.updateSummary(keyMsg("q")) + if updated.(Model).view != summaryView { + t.Fatalf("quit key changed view to %d before exit", updated.(Model).view) + } + if cmd == nil { + t.Fatal("footer advertises q quit, but q emitted no quit command") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatalf("q emitted %T, want tea.QuitMsg", cmd()) + } +} + func TestUpdateSummary_DirectLaunch_KeysQuit(t *testing.T) { cases := []struct { name string diff --git a/internal/tui/testmain_test.go b/internal/tui/testmain_test.go index 539e28f..b49bc04 100644 --- a/internal/tui/testmain_test.go +++ b/internal/tui/testmain_test.go @@ -11,5 +11,7 @@ import ( // flake on macOS (the indexer transiently holds git object files, breaking // t.TempDir's RemoveAll). See internal/testtmp. func TestMain(m *testing.M) { + _ = os.Setenv("SENTEI_MOTION", "full") + _ = os.Setenv("TERM", "xterm-256color") os.Exit(testtmp.RunWithIsolatedTemp(m)) } diff --git a/internal/tui/window.go b/internal/tui/window.go index 55b5a6d..260ee30 100644 --- a/internal/tui/window.go +++ b/internal/tui/window.go @@ -1,6 +1,6 @@ package tui -import "github.com/abiswas97/sentei/internal/pipeline" +import "github.com/abiswas97/sentei/internal/progress" // WindowStats summarizes a windowed step list for the stat line. type WindowStats struct { @@ -14,7 +14,7 @@ type WindowStats struct { // WindowResult is the outcome of windowing a step list. type WindowResult struct { - Steps []stepDisplay + Steps []progress.StepState Windowed bool Stats WindowStats } @@ -24,54 +24,77 @@ type WindowResult struct { // visible; remaining room shows the most recently completed steps (up to // WindowCompletedTrail) and the next pending steps (up to WindowPendingLead). // One line is reserved for the stat line when windowing engages. -func WindowSteps(steps []stepDisplay, availableLines int) WindowResult { +func WindowSteps(steps []progress.StepState, availableLines int) WindowResult { stats := WindowStats{Total: len(steps)} for _, s := range steps { - switch s.status { - case pipeline.StepDone, pipeline.StepSkipped: + switch s.Status { + case progress.StepDone, progress.StepSkipped: stats.Done++ - case pipeline.StepRunning: + case progress.StepRunning: stats.Active++ - case pipeline.StepFailed: + case progress.StepFailed: stats.Failed++ default: stats.Pending++ } } + availableLines = max(availableLines, 0) if len(steps) <= availableLines { stats.Showing = len(steps) return WindowResult{Steps: steps, Windowed: false, Stats: stats} } - visible := make([]bool, len(steps)) - for i, s := range steps { - if s.status == pipeline.StepFailed || s.status == pipeline.StepRunning { - visible[i] = true - } + if availableLines == 0 { + return WindowResult{Windowed: len(steps) > 0, Stats: stats} } - budget := max(availableLines-1, 0) // reserve the stat line - remaining := budget - stats.Failed - stats.Active - - completedTrail := min(WindowCompletedTrail, max(remaining, 0)) - for i := len(steps) - 1; i >= 0 && completedTrail > 0; i-- { - if steps[i].status == pipeline.StepDone || steps[i].status == pipeline.StepSkipped { - visible[i] = true - completedTrail-- - remaining-- + budget := availableLines - 1 // reserve the omission stat + priority := make([]int, 0, len(steps)) + for i, step := range steps { + if step.Status == progress.StepRunning { + priority = append(priority, i) } } - - pendingLead := min(WindowPendingLead, max(remaining, 0)) - for i := 0; i < len(steps) && pendingLead > 0; i++ { - if steps[i].status == pipeline.StepPending { - visible[i] = true - pendingLead-- + latestFailure := -1 + for i := len(steps) - 1; i >= 0; i-- { + if steps[i].Status == progress.StepFailed { + latestFailure = i + priority = append(priority, i) + break + } + } + for i, step := range steps { + if step.Status == progress.StepFailed && i != latestFailure { + priority = append(priority, i) + } + } + resolved := 0 + for i := len(steps) - 1; i >= 0 && resolved < WindowCompletedTrail; i-- { + if steps[i].Status == progress.StepDone || steps[i].Status == progress.StepSkipped { + priority = append(priority, i) + resolved++ + } + } + pending := 0 + for i, step := range steps { + if step.Status == progress.StepPending && pending < WindowPendingLead { + priority = append(priority, i) + pending++ } } - var windowed []stepDisplay + visible := make([]bool, len(steps)) + for _, index := range priority { + if budget == 0 { + break + } + if !visible[index] { + visible[index] = true + budget-- + } + } + var windowed []progress.StepState for i, s := range steps { if visible[i] { windowed = append(windowed, s) diff --git a/internal/tui/window_test.go b/internal/tui/window_test.go index 7eee833..d36a37e 100644 --- a/internal/tui/window_test.go +++ b/internal/tui/window_test.go @@ -3,13 +3,13 @@ package tui import ( "testing" - "github.com/abiswas97/sentei/internal/pipeline" + "github.com/abiswas97/sentei/internal/progress" ) -func makeSteps(statuses ...pipeline.StepStatus) []stepDisplay { - steps := make([]stepDisplay, len(statuses)) +func makeSteps(statuses ...progress.StepStatus) []progress.StepState { + steps := make([]progress.StepState, len(statuses)) for i, st := range statuses { - steps[i] = stepDisplay{name: stepName(i), status: st} + steps[i] = progress.StepState{Name: stepName(i), Status: st} } return steps } @@ -18,10 +18,10 @@ func stepName(i int) string { return string(rune('a' + i%26)) } -func countByStatus(steps []stepDisplay, status pipeline.StepStatus) int { +func countByStatus(steps []progress.StepState, status progress.StepStatus) int { n := 0 for _, s := range steps { - if s.status == status { + if s.Status == status { n++ } } @@ -29,7 +29,7 @@ func countByStatus(steps []stepDisplay, status pipeline.StepStatus) int { } func TestWindowSteps_AllFit(t *testing.T) { - steps := makeSteps(pipeline.StepDone, pipeline.StepRunning, pipeline.StepPending, pipeline.StepPending, pipeline.StepPending) + steps := makeSteps(progress.StepDone, progress.StepRunning, progress.StepPending, progress.StepPending, progress.StepPending) r := WindowSteps(steps, 10) if r.Windowed { t.Error("expected no windowing when all items fit") @@ -40,13 +40,13 @@ func TestWindowSteps_AllFit(t *testing.T) { } func TestWindowSteps_ExceedsBudget(t *testing.T) { - var statuses []pipeline.StepStatus + var statuses []progress.StepStatus for range 10 { - statuses = append(statuses, pipeline.StepDone) + statuses = append(statuses, progress.StepDone) } - statuses = append(statuses, pipeline.StepRunning, pipeline.StepRunning, pipeline.StepRunning) + statuses = append(statuses, progress.StepRunning, progress.StepRunning, progress.StepRunning) for range 17 { - statuses = append(statuses, pipeline.StepPending) + statuses = append(statuses, progress.StepPending) } r := WindowSteps(makeSteps(statuses...), 8) @@ -71,66 +71,58 @@ func TestWindowSteps_ExceedsBudget(t *testing.T) { } func TestWindowSteps_FailedAlwaysVisible(t *testing.T) { - var statuses []pipeline.StepStatus + var statuses []progress.StepStatus for range 27 { - statuses = append(statuses, pipeline.StepDone) + statuses = append(statuses, progress.StepDone) } - statuses = append(statuses, pipeline.StepFailed, pipeline.StepFailed, pipeline.StepFailed) + statuses = append(statuses, progress.StepFailed, progress.StepFailed, progress.StepFailed) r := WindowSteps(makeSteps(statuses...), 5) - if got := countByStatus(r.Steps, pipeline.StepFailed); got != 3 { + if got := countByStatus(r.Steps, progress.StepFailed); got != 3 { t.Errorf("expected all 3 failed steps visible under budget pressure, got %d", got) } } func TestWindowSteps_ActiveAlwaysVisible(t *testing.T) { - var statuses []pipeline.StepStatus + var statuses []progress.StepStatus for range 25 { - statuses = append(statuses, pipeline.StepPending) + statuses = append(statuses, progress.StepPending) } for range 5 { - statuses = append(statuses, pipeline.StepRunning) + statuses = append(statuses, progress.StepRunning) } r := WindowSteps(makeSteps(statuses...), 6) - if got := countByStatus(r.Steps, pipeline.StepRunning); got != 5 { + if got := countByStatus(r.Steps, progress.StepRunning); got != 5 { t.Errorf("expected all 5 active steps visible, got %d", got) } } func TestWindowSteps_BudgetZero_MinimumViable(t *testing.T) { - steps := makeSteps(pipeline.StepDone, pipeline.StepFailed, pipeline.StepRunning, pipeline.StepPending) + steps := makeSteps(progress.StepDone, progress.StepFailed, progress.StepRunning, progress.StepPending) r := WindowSteps(steps, 0) - if !r.Windowed { - t.Fatal("expected windowing at zero budget") - } - if len(r.Steps) != 2 { - t.Fatalf("expected only failed+active at zero budget, got %d steps", len(r.Steps)) - } - for _, s := range r.Steps { - if s.status != pipeline.StepFailed && s.status != pipeline.StepRunning { - t.Errorf("unexpected status %v in minimum viable display", s.status) - } + if !r.Windowed || len(r.Steps) != 0 || r.Stats.Showing != 0 || r.Stats.Total != len(steps) { + t.Fatalf("zero budget must report all rows omitted, got windowed=%v steps=%d stats=%+v", r.Windowed, len(r.Steps), r.Stats) } } func TestWindowSteps_RecentCompletedShown(t *testing.T) { - steps := []stepDisplay{ - {name: "old-done", status: pipeline.StepDone}, - {name: "mid-done", status: pipeline.StepDone}, - {name: "new-done", status: pipeline.StepDone}, - {name: "running", status: pipeline.StepRunning}, - {name: "next-pending", status: pipeline.StepPending}, - {name: "far-pending-1", status: pipeline.StepPending}, - {name: "far-pending-2", status: pipeline.StepPending}, - {name: "far-pending-3", status: pipeline.StepPending}, + steps := []progress.StepState{ + {Name: "old-done", Status: progress.StepDone}, + {Name: "mid-done", Status: progress.StepDone}, + {Name: "new-done", Status: progress.StepDone}, + {Name: "running", Status: progress.StepRunning}, + {Name: "next-pending", Status: progress.StepPending}, + {Name: "far-pending-1", Status: progress.StepPending}, + {Name: "far-pending-2", Status: progress.StepPending}, + {Name: "far-pending-3", Status: progress.StepPending}, } r := WindowSteps(steps, 5) // 8 items in 5 lines -> windowed names := make(map[string]bool) for _, s := range r.Steps { - names[s.name] = true + names[s.Name] = true } for _, want := range []string{"running", "new-done", "mid-done", "next-pending"} { if !names[want] { @@ -143,13 +135,13 @@ func TestWindowSteps_RecentCompletedShown(t *testing.T) { } func TestWindowSteps_ResponsiveAcrossHeights(t *testing.T) { - var statuses []pipeline.StepStatus + var statuses []progress.StepStatus for range 12 { - statuses = append(statuses, pipeline.StepDone) + statuses = append(statuses, progress.StepDone) } - statuses = append(statuses, pipeline.StepRunning, pipeline.StepRunning) + statuses = append(statuses, progress.StepRunning, progress.StepRunning) for range 16 { - statuses = append(statuses, pipeline.StepPending) + statuses = append(statuses, progress.StepPending) } steps := makeSteps(statuses...) // 30 items diff --git a/internal/worktree/deleter.go b/internal/worktree/deleter.go index 96fe17c..3cce854 100644 --- a/internal/worktree/deleter.go +++ b/internal/worktree/deleter.go @@ -1,25 +1,24 @@ package worktree import ( + "errors" "fmt" "strings" "sync" "github.com/abiswas97/sentei/internal/git" + "github.com/abiswas97/sentei/internal/progress" ) -type DeletionEventType int +// RemovalPhaseName is the canonical phase under which worktree deletions +// report progress; the TUI renders the same phase name. +const RemovalPhaseName = "Removing worktrees" -const ( - DeletionStarted DeletionEventType = iota - DeletionCompleted - DeletionFailed -) +const RemovalPhaseID progress.PhaseID = "remove-worktrees" -type DeletionEvent struct { - Type DeletionEventType - Path string - Error error +type RemovalTarget struct { + Worktree git.Worktree + StepID progress.StepID } type WorktreeOutcome struct { @@ -32,6 +31,12 @@ type DeletionResult struct { SuccessCount int FailureCount int Outcomes []WorktreeOutcome + Phases []progress.Phase + Err error +} + +func (r DeletionResult) HasFailures() bool { + return r.Err != nil || r.FailureCount > 0 || progress.PhasesHaveFailures(r.Phases) } func PruneWorktrees(runner git.CommandRunner, repoPath string) error { @@ -47,10 +52,8 @@ func UnlockWorktree(runner git.CommandRunner, repoPath, wtPath string) error { return err } -func DeleteWorktrees(remover func(string) error, worktrees []git.Worktree, maxConcurrency int, progress chan<- DeletionEvent) DeletionResult { - defer close(progress) - - if len(worktrees) == 0 { +func DeleteWorktrees(execution *progress.Execution, phaseID progress.PhaseID, remover func(string) error, targets []RemovalTarget, maxConcurrency int) DeletionResult { + if len(targets) == 0 { return DeletionResult{} } @@ -60,44 +63,64 @@ func DeleteWorktrees(remover func(string) error, worktrees []git.Worktree, maxCo var mu sync.Mutex result := DeletionResult{ - Outcomes: make([]WorktreeOutcome, len(worktrees)), + Outcomes: make([]WorktreeOutcome, len(targets)), + } + recordExecutionError := func(err error) { + if err == nil { + return + } + mu.Lock() + result.Err = errors.Join(result.Err, err) + mu.Unlock() } sem := make(chan struct{}, maxConcurrency) var wg sync.WaitGroup - for i, wt := range worktrees { + for i, target := range targets { wg.Add(1) sem <- struct{}{} - go func(idx int, w git.Worktree) { + go func(idx int, target RemovalTarget) { defer wg.Done() defer func() { <-sem }() - progress <- DeletionEvent{Type: DeletionStarted, Path: w.Path} + if err := execution.Running(phaseID, target.StepID, 1, "Removing from disk"); err != nil { + recordExecutionError(err) + return + } - err := remover(w.Path) + err := remover(target.Worktree.Path) mu.Lock() - defer mu.Unlock() - if err != nil { result.FailureCount++ result.Outcomes[idx] = WorktreeOutcome{ - Path: w.Path, + Path: target.Worktree.Path, Success: false, - Error: fmt.Errorf("removing %s: %w", w.Path, err), + Error: fmt.Errorf("removing %s: %w", target.Worktree.Path, err), } - progress <- DeletionEvent{Type: DeletionFailed, Path: w.Path, Error: err} } else { result.SuccessCount++ result.Outcomes[idx] = WorktreeOutcome{ - Path: w.Path, + Path: target.Worktree.Path, Success: true, } - progress <- DeletionEvent{Type: DeletionCompleted, Path: w.Path} } - }(i, wt) + mu.Unlock() + + if err != nil { + _, progressErr := execution.Fail(phaseID, target.StepID, err) + recordExecutionError(progressErr) + return + } + if progressErr := execution.Running(phaseID, target.StepID, 2, "Removed from disk"); progressErr != nil { + recordExecutionError(progressErr) + return + } + _, progressErr := execution.Done(phaseID, target.StepID, "Removed") + recordExecutionError(progressErr) + }(i, target) } wg.Wait() diff --git a/internal/worktree/deleter_execution_test.go b/internal/worktree/deleter_execution_test.go new file mode 100644 index 0000000..9fce8a2 --- /dev/null +++ b/internal/worktree/deleter_execution_test.go @@ -0,0 +1,115 @@ +package worktree + +import ( + "errors" + "strings" + "sync" + "testing" + + "github.com/abiswas97/sentei/internal/git" + "github.com/abiswas97/sentei/internal/progress" +) + +func TestDeleteWorktrees_DeliveryFailurePopulatesErr(t *testing.T) { + deliveryErr := errors.New("delivery failed") + emissions := 0 + execution, err := progress.Start(progress.Plan{Phases: []progress.PlannedPhase{{ + ID: RemovalPhaseID, Steps: []progress.PlannedStep{{ID: "remove-a", Checkpoints: 2}}, + }}}, func(progress.Event) { + emissions++ + if emissions > 2 { + panic(deliveryErr) + } + }) + if err != nil { + t.Fatal(err) + } + + result := DeleteWorktrees(execution, RemovalPhaseID, func(string) error { return nil }, []RemovalTarget{{ + Worktree: git.Worktree{Path: "/work/a"}, StepID: "remove-a", + }}, 1) + if result.Err == nil || !strings.Contains(result.Err.Error(), deliveryErr.Error()) { + t.Fatalf("DeleteWorktrees() error = %v", result.Err) + } +} + +func TestDeleteWorktrees_UsesCallerExecutionAndLeavesChannelOpen(t *testing.T) { + targets := []RemovalTarget{ + {Worktree: git.Worktree{Path: "/work/a"}, StepID: "remove-a"}, + {Worktree: git.Worktree{Path: "/work/b"}, StepID: "remove-b"}, + } + events := make(chan progress.Event, 32) + execution, err := progress.Start(progress.Plan{Phases: []progress.PlannedPhase{{ + ID: RemovalPhaseID, Label: RemovalPhaseName, + Steps: []progress.PlannedStep{ + {ID: "remove-a", Label: "/work/a", Checkpoints: 2}, + {ID: "remove-b", Label: "/work/b", Checkpoints: 2}, + }, + }}}, func(event progress.Event) { events <- event }) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + result := DeleteWorktrees(execution, RemovalPhaseID, func(string) error { return nil }, targets, 2) + if result.Err != nil { + t.Fatalf("DeleteWorktrees() error = %v", result.Err) + } + if result.SuccessCount != 2 || result.FailureCount != 0 { + t.Fatalf("counts = (%d, %d), want (2, 0)", result.SuccessCount, result.FailureCount) + } + for i, target := range targets { + if result.Outcomes[i].Path != target.Worktree.Path || !result.Outcomes[i].Success { + t.Fatalf("outcome[%d] = %+v", i, result.Outcomes[i]) + } + } + + // The caller still owns the channel and can continue using it. + events <- progress.Event{Phase: "caller", Close: true} + if err := execution.Finish("test complete"); err != nil { + t.Fatalf("Finish() error = %v", err) + } + close(events) + + var all []progress.Event + for event := range events { + all = append(all, event) + } + states := progress.Snapshot(all) + if len(states) < 1 || states[0].Done != 2 || !states[0].Settled() { + t.Fatalf("removal state = %+v", states) + } +} + +func TestDeleteWorktrees_JoinsWorkersBeforeReturning(t *testing.T) { + targets := []RemovalTarget{ + {Worktree: git.Worktree{Path: "/work/a"}, StepID: "remove-a"}, + {Worktree: git.Worktree{Path: "/work/b"}, StepID: "remove-b"}, + } + execution, err := progress.Start(progress.Plan{Phases: []progress.PlannedPhase{{ + ID: RemovalPhaseID, + Steps: []progress.PlannedStep{ + {ID: "remove-a", Checkpoints: 2}, + {ID: "remove-b", Checkpoints: 2}, + }, + }}}, nil) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + + var mu sync.Mutex + completed := map[string]bool{} + result := DeleteWorktrees(execution, RemovalPhaseID, func(path string) error { + mu.Lock() + completed[path] = true + mu.Unlock() + return nil + }, targets, 2) + if result.Err != nil { + t.Fatalf("DeleteWorktrees() error = %v", result.Err) + } + mu.Lock() + defer mu.Unlock() + if !completed["/work/a"] || !completed["/work/b"] { + t.Fatalf("returned before workers completed: %v", completed) + } +} diff --git a/internal/worktree/deleter_test.go b/internal/worktree/deleter_test.go index b113d9e..2cca22b 100644 --- a/internal/worktree/deleter_test.go +++ b/internal/worktree/deleter_test.go @@ -12,16 +12,46 @@ import ( "github.com/abiswas97/sentei/internal/testutil/mock" "github.com/abiswas97/sentei/internal/git" + "github.com/abiswas97/sentei/internal/progress" + "github.com/abiswas97/sentei/internal/testtmp" ) -func collectEvents(ch <-chan DeletionEvent) []DeletionEvent { - var events []DeletionEvent +func collectEvents(ch <-chan progress.Event) []progress.Event { + var events []progress.Event for e := range ch { events = append(events, e) } return events } +func deleteWorktreesForTest(t *testing.T, remover func(string) error, worktrees []git.Worktree, maxConcurrency int, events chan progress.Event) DeletionResult { + t.Helper() + if len(worktrees) == 0 { + result := DeleteWorktrees(nil, RemovalPhaseID, remover, nil, maxConcurrency) + close(events) + return result + } + targets := make([]RemovalTarget, len(worktrees)) + steps := make([]progress.PlannedStep, len(worktrees)) + for i, wt := range worktrees { + stepID := progress.StepID(fmt.Sprintf("remove-%d", i)) + targets[i] = RemovalTarget{Worktree: wt, StepID: stepID} + steps[i] = progress.PlannedStep{ID: stepID, Label: wt.Path, Checkpoints: 2} + } + execution, err := progress.Start(progress.Plan{Phases: []progress.PlannedPhase{{ + ID: RemovalPhaseID, Label: RemovalPhaseName, Steps: steps, + }}}, func(event progress.Event) { events <- event }) + if err != nil { + t.Fatalf("Start() error = %v", err) + } + result := DeleteWorktrees(execution, RemovalPhaseID, remover, targets, maxConcurrency) + if finishErr := execution.Finish("test complete"); finishErr != nil { + t.Fatalf("Finish() error = %v", finishErr) + } + close(events) + return result +} + func TestDeleteWorktrees_DeletesRealDirectories(t *testing.T) { dirA := t.TempDir() dirB := t.TempDir() @@ -31,9 +61,9 @@ func TestDeleteWorktrees_DeletesRealDirectories(t *testing.T) { {Path: dirB, Branch: "refs/heads/b"}, } - progress := make(chan DeletionEvent, 20) - result := DeleteWorktrees(os.RemoveAll, worktrees, 5, progress) - events := collectEvents(progress) + ch := make(chan progress.Event, 20) + result := deleteWorktreesForTest(t, os.RemoveAll, worktrees, 5, ch) + events := collectEvents(ch) if result.SuccessCount != 2 { t.Errorf("SuccessCount = %d, want 2", result.SuccessCount) @@ -50,17 +80,17 @@ func TestDeleteWorktrees_DeletesRealDirectories(t *testing.T) { var started, completed int for _, e := range events { - switch e.Type { - case DeletionStarted: + switch e.Status { + case progress.StepRunning: started++ - case DeletionCompleted: + case progress.StepDone: completed++ - case DeletionFailed: + case progress.StepFailed: t.Error("unexpected failure event") } } - if started != 2 { - t.Errorf("started events = %d, want 2", started) + if started != 4 { + t.Errorf("started events = %d, want 4 (two checkpoints per worktree)", started) } if completed != 2 { t.Errorf("completed events = %d, want 2", completed) @@ -72,9 +102,9 @@ func TestDeleteWorktrees_DirectoryAlreadyMissing_Succeeds(t *testing.T) { {Path: "/nonexistent/path/worktree"}, } - progress := make(chan DeletionEvent, 10) - result := DeleteWorktrees(os.RemoveAll, worktrees, 5, progress) - collectEvents(progress) + ch := make(chan progress.Event, 10) + result := deleteWorktreesForTest(t, os.RemoveAll, worktrees, 5, ch) + collectEvents(ch) if result.SuccessCount != 1 { t.Errorf("SuccessCount = %d, want 1 (missing directory should be a no-op success)", result.SuccessCount) @@ -95,9 +125,9 @@ func TestDeleteWorktrees_RemovalFailure_ReportsFailure(t *testing.T) { {Path: "/work/b"}, } - progress := make(chan DeletionEvent, 20) - result := DeleteWorktrees(failingRemover, worktrees, 5, progress) - collectEvents(progress) + ch := make(chan progress.Event, 20) + result := deleteWorktreesForTest(t, failingRemover, worktrees, 5, ch) + collectEvents(ch) if result.SuccessCount != 0 { t.Errorf("SuccessCount = %d, want 0", result.SuccessCount) @@ -132,9 +162,9 @@ func TestDeleteWorktrees_MixedOutcomes(t *testing.T) { {Path: badPath}, } - progress := make(chan DeletionEvent, 20) - result := DeleteWorktrees(remover, worktrees, 5, progress) - events := collectEvents(progress) + ch := make(chan progress.Event, 20) + result := deleteWorktreesForTest(t, remover, worktrees, 5, ch) + events := collectEvents(ch) if result.SuccessCount != 1 { t.Errorf("SuccessCount = %d, want 1", result.SuccessCount) @@ -151,7 +181,7 @@ func TestDeleteWorktrees_MixedOutcomes(t *testing.T) { var failed int for _, e := range events { - if e.Type == DeletionFailed { + if e.Status == progress.StepFailed { failed++ } } @@ -161,9 +191,9 @@ func TestDeleteWorktrees_MixedOutcomes(t *testing.T) { } func TestDeleteWorktrees_EmptyInput(t *testing.T) { - progress := make(chan DeletionEvent, 20) - result := DeleteWorktrees(os.RemoveAll, nil, 5, progress) - events := collectEvents(progress) + ch := make(chan progress.Event, 20) + result := deleteWorktreesForTest(t, os.RemoveAll, nil, 5, ch) + events := collectEvents(ch) if result.SuccessCount != 0 || result.FailureCount != 0 { t.Errorf("expected zero counts, got success=%d failure=%d", result.SuccessCount, result.FailureCount) @@ -201,9 +231,9 @@ func TestDeleteWorktrees_ConcurrencyBound(t *testing.T) { worktrees[i] = git.Worktree{Path: fmt.Sprintf("/work/%d", i+1)} } - progress := make(chan DeletionEvent, 50) + ch := make(chan progress.Event, 50) go func() { - DeleteWorktrees(remover, worktrees, maxConcurrency, progress) + deleteWorktreesForTest(t, remover, worktrees, maxConcurrency, ch) }() // Wait for maxConcurrency workers to arrive (proves they're running concurrently). @@ -214,7 +244,7 @@ func TestDeleteWorktrees_ConcurrencyBound(t *testing.T) { // Release all workers. close(gate) - collectEvents(progress) + collectEvents(ch) if maxConcurrent.Load() > int32(maxConcurrency) { t.Errorf("max concurrent = %d, want <= %d", maxConcurrent.Load(), maxConcurrency) @@ -280,6 +310,7 @@ func TestUnlockWorktree_UnlocksLockedWorktree(t *testing.T) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v: %s", args, out) @@ -301,7 +332,9 @@ func TestUnlockWorktree_UnlocksLockedWorktree(t *testing.T) { run(repoPath, "worktree", "lock", wtPath) // Verify it's locked — check for "locked" as a standalone line in porcelain output - out, _ := exec.Command("git", "-C", repoPath, "worktree", "list", "--porcelain").CombinedOutput() + lockCheck := exec.Command("git", "-C", repoPath, "worktree", "list", "--porcelain") + lockCheck.Env = testtmp.HermeticGitEnv() + out, _ := lockCheck.CombinedOutput() if !containsLockedLine(string(out)) { t.Fatal("worktree should be locked") } @@ -313,7 +346,9 @@ func TestUnlockWorktree_UnlocksLockedWorktree(t *testing.T) { } // Verify it's no longer locked - out, _ = exec.Command("git", "-C", repoPath, "worktree", "list", "--porcelain").CombinedOutput() + lockCheck2 := exec.Command("git", "-C", repoPath, "worktree", "list", "--porcelain") + lockCheck2.Env = testtmp.HermeticGitEnv() + out, _ = lockCheck2.CombinedOutput() if containsLockedLine(string(out)) { t.Fatal("worktree should no longer be locked after UnlockWorktree") } @@ -338,6 +373,7 @@ func TestUnlockWorktree_AlreadyUnlocked_NoError(t *testing.T) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir + cmd.Env = testtmp.HermeticGitEnv() out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v: %s", args, out) diff --git a/openspec/changes/progress-completion-settle/.openspec.yaml b/openspec/changes/progress-completion-settle/.openspec.yaml new file mode 100644 index 0000000..8fe2055 --- /dev/null +++ b/openspec/changes/progress-completion-settle/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-12 diff --git a/openspec/changes/progress-completion-settle/design.md b/openspec/changes/progress-completion-settle/design.md new file mode 100644 index 0000000..7ef55f0 --- /dev/null +++ b/openspec/changes/progress-completion-settle/design.md @@ -0,0 +1,37 @@ +## Context + +`holdOrAdvance` (model.go) transitions instantly when `minProgressDuration == 0`, which is every non-playground run; `progressSettleFloor` only applies when holds are enabled and is measured from the final event, not from the bar reaching full. The spring (frequency 6) needs ~1.2s to traverse, so even the playground's settle delivers 4 frames at 100% (measured). User decision: short settle only — no entry hold for real runs; endings always tell the truth. + +## Goals / Non-Goals + +**Goals**: every flow's last progress frame shows a settled 100% bar (green on success); fast real flows stay fast (sub-second added latency); playground demos keep their legibility hold. + +**Non-Goals**: entry holds for real runs; changing spring physics; changing what 100% means (that is `progress-declarations`). + +## Decisions + +**D1: Settle is a state predicate, not a timer re-tune.** +The advance condition becomes: final event received AND displayed fill >= threshold (~99.5%) AND `settledBeat` (500-800ms, one constant) elapsed since the threshold was first met. This is immune to spring-frequency changes; re-tuning the existing event-relative floor to ~1.7s was rejected because it re-breaks the moment anyone touches the spring. + +**D2: One mechanism for both modes.** +Playground = settle + entry hold; real = settle only. `progressSettleFloor`'s event-relative semantics are replaced, not special-cased; the constant is renamed to match its new meaning so no reader trusts the old doc comment. + +**D3: Failure endings settle too.** +The hold-until-truth applies regardless of outcome; only the gradient differs. A failure that cuts away mid-bar is the same lie as a success that does. + +**D4: Spring sync on final event stays the existing mechanism.** +No jump-cut to 100%: the spring glides (~1.2s) and the settle beat starts when the displayed fill arrives. Worst case added wait ~1.2s glide + 0.5s beat on a flow that completed instantly; acceptable against the strobe it replaces, and the glide shortens as `progress-declarations` makes mid-flow fill more truthful (less distance to travel at the end). + +## Risks / Trade-offs + +- [Spring never reaches threshold due to float asymptote] → threshold at 99.5% with a hard timeout fallback (2x expected glide) so the view can never wedge; invariant-tested. +- [Added latency annoys the 10x/day user] → measured cost <=1.7s worst case, typically <1s; the audit's F6 quantified the alternative (sub-perceptible strobing) as worse. Revisit with real usage if it grates. +- [teatest flakiness around animation frames] → assertions poll rendered output via WaitFor (no sleeps, per project rule); the threshold state is deterministic given pumped frames. + +## Migration Plan + +Single PR. Re-record the real-repo motion tape afterward and frame-verify: last progress frame of each flow shows 100% (green on success). Rollback: revert; behavior returns to instant-advance. + +## Open Questions + +- Exact beat duration (500 vs 800ms): pick in implementation by re-recording the real-repo tape at both and frame-reviewing; not worth a lab. diff --git a/openspec/changes/progress-completion-settle/proposal.md b/openspec/changes/progress-completion-settle/proposal.md new file mode 100644 index 0000000..38d5d58 --- /dev/null +++ b/openspec/changes/progress-completion-settle/proposal.md @@ -0,0 +1,27 @@ +## Why + +Every documented timing guarantee (minimum hold, settle at 100%, the green completion bar) is currently playground-only: real runs advance the instant the final event lands, cutting the progress view mid-spring at 10-86% fill (measured frame-exact in the June 2026 UX audit). Even with holds enabled, the settle floor is event-relative while the spring needs ~1.2s to traverse, yielding a measured 0.16s at 100%, below a comfortable eye fixation. The ending of every flow contradicts the "layout tells the truth" invariant for every real user. + +## What Changes + +- The completion settle becomes unconditional and state-relative: after a flow's final event, the progress view may not advance until the displayed bar fill has reached ~100% AND a settled beat (500-800ms) has elapsed since it got there. +- On the final event the spring target syncs to 100% (existing mechanism) and the success gradient applies on success (existing Wave 3a mechanism, now actually visible outside playground). +- Failure endings get the same truth-hold without success styling. +- Playground keeps its additional 1.5s entry hold on top; real runs gain no entry hold. +- `q`/`ctrl+c` still quit immediately mid-settle (existing stderr trace covers the abandoned operation). + +## Capabilities + +### New Capabilities + + + +### Modified Capabilities +- `tui-progress`: gains completion-settle requirements (added; the existing spec has no timing requirements to modify). + +## Impact + +- Affected code: `internal/tui` (`holdOrAdvance`, `progressSettleFloor` semantics in constants.go, model.go), `main.go` (playground wiring unchanged in meaning). +- Independent of `progress-package`/`progress-declarations`; can merge first. +- Adds at most ~0.5-0.8s to fast flows in exchange for every flow ending at a visible, truthful 100%. +- Tests: condition-based (teatest WaitFor on the rendered 100% state), no sleeps. diff --git a/openspec/changes/progress-completion-settle/specs/tui-progress/spec.md b/openspec/changes/progress-completion-settle/specs/tui-progress/spec.md new file mode 100644 index 0000000..4251007 --- /dev/null +++ b/openspec/changes/progress-completion-settle/specs/tui-progress/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: Completion settle is unconditional +Once a flow's final progress event has arrived, the progress view SHALL NOT advance to its summary until the displayed overall bar fill has reached completion (>= 99.5%) and a settled beat (a single named constant, 500-800ms) has elapsed since the fill first reached it, in every run mode (playground and real), for success and failure outcomes alike, with a hard timeout fallback so the view can never wedge if the fill cannot reach the threshold. + +#### Scenario: Real-run removal ends settled +- **WHEN** a removal completes in a real (non-playground) run +- **THEN** the last rendered progress frame before the summary shows the bar at 100% with the success gradient + +#### Scenario: Failure endings settle without success styling +- **WHEN** a flow's final event reports failures +- **THEN** the view holds until the displayed fill completes and the settled beat elapses, rendering the standard gradient, not the success gradient + +#### Scenario: Settle is state-relative, not event-relative +- **WHEN** the spring takes longer than the settled beat to glide to completion after the final event +- **THEN** the beat starts only when the displayed fill reaches the threshold, so the time visibly settled is never less than the beat + +### Requirement: Quit remains immediate during settle +`q` and `ctrl+c` SHALL quit immediately during the completion settle, leaving the existing stderr trace naming the in-flight operation. + +#### Scenario: Quit mid-settle +- **WHEN** the user presses `q` while the settle beat is running +- **THEN** the program exits immediately with the operation trace on stderr + +### Requirement: Playground keeps its entry hold +Playground mode SHALL retain its minimum progress duration (entry hold) in addition to the completion settle; real runs SHALL have no entry hold. + +#### Scenario: Fast real flow gains only the settle +- **WHEN** a real-run flow completes faster than the playground entry hold +- **THEN** the only added latency before the summary is the glide-plus-beat of the completion settle diff --git a/openspec/changes/progress-completion-settle/tasks.md b/openspec/changes/progress-completion-settle/tasks.md new file mode 100644 index 0000000..c8be57f --- /dev/null +++ b/openspec/changes/progress-completion-settle/tasks.md @@ -0,0 +1,17 @@ +## 1. Settle mechanism + +- [x] 1.1 Replace `progressSettleFloor`'s event-relative semantics with the state-relative advance predicate in `holdOrAdvance`: final event AND displayed fill >= 99.5% AND settled beat elapsed since threshold; rename the constant to match the new meaning +- [x] 1.2 Apply unconditionally (real and playground); playground keeps the entry hold on top; add the hard timeout fallback (2x expected glide) with an invariant test that the view cannot wedge +- [x] 1.3 Verify quit-during-settle exits immediately with the stderr operation trace (existing test or add one) + +## 2. Tests + +- [x] 2.1 teatest: real-mode removal's last progress frame before summary renders 100% + success gradient (WaitFor-based, no sleeps) +- [x] 2.2 teatest: failure outcome holds to threshold without success gradient +- [x] 2.3 Table-driven unit tests for the advance predicate (event-not-final, fill-below-threshold, beat-not-elapsed, timeout fallback) + +## 3. Verification + +- [x] 3.1 Pick the beat duration (500 vs 800ms) by re-recording the real-repo motion tape at both and frame-reviewing the ending +- [x] 3.2 Full gauntlet; golden tests unaffected (settle changes timing, not stable-view rendering) +- [x] 3.3 Update `.impeccable.md` Timing section: guarantees now hold for all runs, settle is state-relative diff --git a/openspec/changes/progress-declarations/.openspec.yaml b/openspec/changes/progress-declarations/.openspec.yaml new file mode 100644 index 0000000..8fe2055 --- /dev/null +++ b/openspec/changes/progress-declarations/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-12 diff --git a/openspec/changes/progress-declarations/design.md b/openspec/changes/progress-declarations/design.md new file mode 100644 index 0000000..9ffadef --- /dev/null +++ b/openspec/changes/progress-declarations/design.md @@ -0,0 +1,51 @@ +## Context + +`progress-package` consolidated the vocabulary; this change makes it honest. The audit's three measured failures (✦ melting, Teardown's fake total, the 0%-dead bar) share one root cause: `total = len(steps discovered so far)`. The plan for every lying flow is actually known before work starts (staged integrations x worktrees; artifact count; selected worktrees). User decision from the design FAQ: plan-in-stream (declaration is events), with a typed builder for ergonomics; checkpoints per flow as declared data rather than a global started-equals-half heuristic. + +## Goals / Non-Goals + +**Goals** +- Totals come from declaration; discovery semantics remain only for genuinely undiscoverable work. +- ✦/green/collapse mean "closed and complete", restoring the "anything still is settled" promise. +- The bar moves when real sub-stage boundaries (checkpoints) are crossed, including step starts. + +**Non-Goals** +- No timing/hold/settle changes (`progress-completion-settle`). +- No per-checkpoint weighting (equal weights; revisit on a demonstrated need, rule of three). +- No continuous progress (e.g. clone object-transfer percentage) — the checkpoint model leaves room; not used yet. + +## Decisions + +**D1: Plan-in-stream with a typed compiler.** +`Declare(plan, emit)` emits one Pending event per planned step (creating steps and totals in the fold with no fold rework — first-mention-creates-step flips from bug to feature) and a close marker per closed phase. The stream remains the single source of truth; replay/golden testing keeps working. Alternative — a Plan struct joined with events at fold time — rejected: two artifacts that must agree reintroduce the consistency problem being fixed. + +**D2: Close is an explicit event; declared phases default to closed.** +`Declare` closes each phase unless `Open: true` (scan-style phases that append steps after declaration close later via an explicit close emit). Alternative — implicit close when the burst arrives — is the default behavior; the explicit marker exists only for Open phases, keeping the common case zero-ceremony. + +**D3: Checkpoints are fields on the existing Event, not a new event type.** +`Event{Checkpoint: k, Of: n}` on a Running event means "reached sub-stage k of n". A step's declared checkpoint count lives in the Pending burst (`Of` on the Pending event). Atomic steps declare 1 checkpoint; start/finish steps declare 2 (start = checkpoint 1). The fold treats a step's resolved status as reaching its final checkpoint. Alternative — a separate CheckpointReached event kind — rejected as a second vocabulary for the same fact. + +**D4: Headers count steps; the bar counts checkpoints.** +Human-facing counts (`2/3`) stay in human units. The bar's fill = checkpoints reached / checkpoints declared across all phases, equal weights. This is the resolution split that lets parallel removals move the bar at start without overstating completion in the counts. + +**D5: ✦ gate is `closed && done == total` in one derivation.** +`PhaseState.Settled()` is the single predicate used by collapse, ✦ rendering, and green styling, so the three can never disagree (mirrors the cleanup truth-chain pattern). + +## Risks / Trade-offs + +- [Open phases forget to close, ✦ never appears] → invariant test: every flow's plan either closes all phases at declaration or the flow's completion path emits the close; E2E asserts terminal views show settled phases. +- [A flow emits a step not in its declaration] → fold accepts it (total grows; monotonicity preserved) but a debug-mode invariant flags it in tests, keeping production forgiving and tests strict. +- [Bar regressions from miscounted checkpoints] → fold clamps: reached never exceeds declared; property test drives random event interleavings and asserts monotonic non-decreasing fill. +- [Checkpoint declarations drift from emitter reality] → declarations and emissions live in the same flow package, adjacent by construction; the table-driven flow tests assert final reached == declared. + +## Migration Plan + +1. Land Plan/Declare + Event fields + fold semantics + invariants in `internal/progress` (no flow changes; undeclared flows behave exactly as today). +2. Seed plans flow by flow, each its own commit with its table-driven test: apply, teardown, removal, repo flows. +3. Switch `ProgressLayout` bar fill to checkpoint counts and ✦/collapse to `Settled()`; re-record the playground tapes and frame-verify (last progress frame of each flow shows monotonic bar, single ✦ per phase). + +Rollback: flows revert to undeclared independently; the fold's discovery path is the unchanged base case, not a legacy branch. + +## Open Questions + +- Whether removal's per-worktree step should declare 2 checkpoints (start/finish) or 3 (start, removed, teardown-of-artifacts) — decide when seeding, based on what the deleter can honestly observe. diff --git a/openspec/changes/progress-declarations/proposal.md b/openspec/changes/progress-declarations/proposal.md new file mode 100644 index 0000000..530986a --- /dev/null +++ b/openspec/changes/progress-declarations/proposal.md @@ -0,0 +1,27 @@ +## Why + +The June 2026 UX audit proved the progress system lies during real runs: ✦ phases reopen and run backward (`✦ 1/1 100%` melts to `1/2 50%` because step totals are discovered per-event though the plan is fully known), Teardown renders a placeholder `0/1` then jumps to `4/4` in one frame, and the overall bar sits at 0% for ~85% of a parallel removal because only completion events exist. Totals derived from discovery instead of declaration is the root cause of all three. + +## What Changes + +- `internal/progress` gains plan declaration: a typed `Plan` (phases > steps > checkpoint counts) that compiles into the event stream as a Pending burst plus per-phase close markers — the stream stays the single source of truth. +- New event semantics: optional `Checkpoint/Of` fields on Running events for intra-step progress; a phase-close marker meaning "no more steps will be added here". +- Fold invariants, enforced by tests in the package: a phase's total never decreases; done never exceeds total; checkpoints are monotonic per step; no step event may follow its phase's close; an undeclared phase stays pending, never 100%. +- Completion semantics change: phase collapse and the ✦/green done treatment derive from `closed && done == total`, never `done == total` alone. +- The overall progress bar fill derives from checkpoints reached over checkpoints declared; phase headers continue to count steps. +- Flows seed real plans: integration apply declares staged-changes x worktrees upfront (fixes phase reopening); teardown declares its artifact-removal count upfront (fixes the 0/1 lie); worktree removal declares per-worktree steps with start/finish checkpoints (fixes the dead bar during parallel work); repo create/clone/migrate declare the step lists they already know, leaving genuinely undiscoverable phases open. + +## Capabilities + +### New Capabilities +- `progress-declarations`: plan declaration, checkpoint events, close markers, and the honesty invariants binding them. + +### Modified Capabilities +- `progress-events`: the fold gains closed/checkpoint state and the new completion semantics (delta against the spec landed by the `progress-package` change). +- `tui-progress`: the overall bar's fill source changes from completed-steps to declared checkpoints, and done-styling of a phase requires the phase to be closed. + +## Impact + +- Affected code: `internal/progress` (Plan, Declare, Event fields, fold, invariant tests), `internal/integration` (plan seeding), `internal/creator` (teardown plan), `internal/worktree` (removal checkpoints), `internal/repo` (plan seeding), `internal/tui` (ProgressLayout bar source, ✦ gating). +- Depends on `progress-package` having merged. +- Visual change is honesty-only: bars move earlier and never regress; ✦ appears once per phase, when true. diff --git a/openspec/changes/progress-declarations/specs/progress-declarations/spec.md b/openspec/changes/progress-declarations/specs/progress-declarations/spec.md new file mode 100644 index 0000000..c5c42f5 --- /dev/null +++ b/openspec/changes/progress-declarations/specs/progress-declarations/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Plan declaration compiles into the stream +`internal/progress` SHALL provide a typed plan (phases containing steps, each step declaring a checkpoint count of at least 1) and a `Declare` operation that compiles the plan into the event stream as one Pending event per planned step (carrying the step's declared checkpoint count) followed by a close marker for every phase not marked Open, so that declaration, progress, and history are the same data. + +#### Scenario: Declared plan establishes totals before work starts +- **WHEN** a flow declares a plan with phase `feat-1` containing 2 steps and then begins work +- **THEN** the folded display state shows `feat-1` with total 2 and done 0 before any Running event is emitted + +#### Scenario: Open phase accepts later steps until closed +- **WHEN** a plan declares a scan phase as Open, work appends a discovered step, and the flow then emits the phase close +- **THEN** the fold accepts the appended step (total grows) before close, and the phase can render settled only after the close + +### Requirement: Checkpoint progress within steps +A Running event MAY carry `Checkpoint k of n` meaning the step has reached sub-stage k of its n declared checkpoints; the fold SHALL track reached checkpoints per step, clamped to the declared count, monotonically non-decreasing, and SHALL treat a step's resolution (Done, Failed, Skipped) as reaching its final checkpoint. + +#### Scenario: Step start moves checkpoint progress +- **WHEN** a step declared with 2 checkpoints emits Running with checkpoint 1 of 2 +- **THEN** the fold reports 1 of the step's 2 checkpoints reached while the step's status remains Running + +#### Scenario: Checkpoints never regress +- **WHEN** events arrive reporting checkpoint 2 of 3 followed by a stale checkpoint 1 of 3 +- **THEN** the folded reached count remains 2 + +### Requirement: Honesty invariants +The fold SHALL enforce: a phase's total never decreases; done never exceeds total; an undeclared phase with no resolved steps never reports completion; and in test builds, an event introducing a previously unseen step after its phase's close marker is flagged as an invariant violation (work events for already declared steps legitimately follow the close). + +#### Scenario: Totals are monotonic +- **WHEN** any valid or invalid event interleaving is folded +- **THEN** the sequence of per-phase totals observed across successive folds of growing prefixes is non-decreasing + +#### Scenario: Event after close is flagged in tests +- **WHEN** a test folds a stream containing an event that introduces a new step after that phase's close marker +- **THEN** the invariant check reports the violation + +### Requirement: Settled is a single predicate +Phase display state SHALL expose one settled predicate, true exactly when the phase is closed and all declared steps are resolved, and all done-treatments of a phase (collapse, done indicator, success styling) SHALL derive from this predicate. + +#### Scenario: Completed-but-open phase is not settled +- **WHEN** a phase's done count equals its current total but the phase has not closed +- **THEN** the settled predicate is false and the phase renders as in-progress, so a later-discovered step never reopens a settled phase + +### Requirement: Flows declare what they know +Integration apply SHALL declare staged-changes-per-worktree upfront; teardown SHALL declare its artifact-removal step count upfront; worktree removal SHALL declare per-worktree steps whose checkpoint counts give start credit; repo create, clone, and migrate SHALL declare the step lists they know at flow start, leaving genuinely undiscoverable phases Open. + +#### Scenario: Apply phases never reopen +- **WHEN** two integrations are applied across two worktrees +- **THEN** each worktree phase declares total 2 at start, progresses 0/2 to 2/2 monotonically, and renders settled exactly once + +#### Scenario: Teardown counts are real +- **WHEN** teardown removes artifacts across N worktrees +- **THEN** the Teardown phase's displayed total equals the declared artifact-removal count from its first rendered frame + +#### Scenario: Parallel removal moves the bar at start +- **WHEN** three parallel worktree removals start and none has completed +- **THEN** checkpoint progress is nonzero (start checkpoints reached) while all step statuses remain Running diff --git a/openspec/changes/progress-declarations/specs/progress-events/spec.md b/openspec/changes/progress-declarations/specs/progress-events/spec.md new file mode 100644 index 0000000..e718d85 --- /dev/null +++ b/openspec/changes/progress-declarations/specs/progress-events/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: Pure event fold +`internal/progress` SHALL provide a pure fold (`Snapshot`) from an ordered event slice to per-phase display state (phase name, ordered steps with statuses, done/total/failed counts, closed flag, and per-step reached/declared checkpoint counts), deterministic for a given input, preserving phase first-appearance order and step first-appearance order within a phase, and counting Done, Skipped, and Failed steps as resolved. + +#### Scenario: Fold is deterministic and order-preserving +- **WHEN** the same event slice is folded twice +- **THEN** both results are deeply equal, phases appear in first-mention order, and steps appear in first-mention order within their phase + +#### Scenario: Skipped counts as resolved +- **WHEN** a phase's events resolve one step Done, one Skipped, and one Failed +- **THEN** the phase reports done=3 of total=3 with failed=1, so a phase containing a best-effort skip still reaches completion + +#### Scenario: Later status supersedes earlier +- **WHEN** a step emits Running and subsequently Done +- **THEN** the folded step's status is Done and the step is not duplicated + +#### Scenario: Fold carries declaration state +- **WHEN** a stream contains a declaration burst, a close marker, and checkpointed Running events +- **THEN** the folded phase state reports the declared totals, the closed flag, and per-step checkpoint progress alongside the step statuses diff --git a/openspec/changes/progress-declarations/specs/tui-progress/spec.md b/openspec/changes/progress-declarations/specs/tui-progress/spec.md new file mode 100644 index 0000000..b0244e7 --- /dev/null +++ b/openspec/changes/progress-declarations/specs/tui-progress/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Bar fill derives from checkpoints +The overall progress bar's fill target SHALL derive from checkpoints reached over checkpoints declared across all phases (equal checkpoint weights), while phase headers SHALL continue to count steps, so the bar gains resolution from declared sub-stages without changing the human-readable counts. + +#### Scenario: Bar moves during parallel work +- **WHEN** three parallel removals have started (start checkpoints reached) and none has completed +- **THEN** the overall bar's fill target is greater than zero while every phase header still reads 0 of its step total done + +#### Scenario: Bar never overstates +- **WHEN** all checkpoints of all declared steps are reached +- **THEN** the bar's fill target is exactly 100%, and at no prior moment does it exceed the reached/declared ratio + +### Requirement: Done styling requires settled phases +Phase collapse, the done indicator on a phase headline, and success styling SHALL render only for phases whose settled predicate is true (closed and fully resolved), and a settled phase SHALL never return to an in-progress rendering. + +#### Scenario: No premature done styling +- **WHEN** a phase's done count equals its currently known total but more declared work exists or the phase is open +- **THEN** the phase renders with in-progress treatment, not the done indicator + +#### Scenario: Settled phases stay settled +- **WHEN** a phase has rendered as settled +- **THEN** no subsequent event causes that phase to render in-progress again in any later frame diff --git a/openspec/changes/progress-declarations/tasks.md b/openspec/changes/progress-declarations/tasks.md new file mode 100644 index 0000000..a8f26fd --- /dev/null +++ b/openspec/changes/progress-declarations/tasks.md @@ -0,0 +1,25 @@ +## 1. Vocabulary extensions + +- [x] 1.1 Add `Checkpoint`/`Of` fields to `progress.Event` and the phase-close marker; extend the fold to track closed flags and per-step reached/declared checkpoints (clamped, monotonic, resolution reaches final checkpoint) +- [x] 1.2 Add `Plan`/`PlannedPhase`/`PlannedStep` and `Declare(plan, emit)` compiling to the Pending burst + close markers (Open phases close later via explicit emit) +- [x] 1.3 Add `PhaseState.Settled()` (closed && all declared steps resolved) as the single done predicate +- [x] 1.4 Invariant + property tests: totals monotonic, done <= total, checkpoints never regress, undeclared phase never completes, event-after-close flagged in tests, random interleavings yield monotonic non-decreasing overall fill + +## 2. Flow plan seeding (one commit each, table-driven test each) + +- [x] 2.1 Integration apply declares staged-changes x worktrees; verify phases never reopen across a 2x2 apply (success, failure, skip permutations) +- [x] 2.2 Teardown declares its artifact-removal count; verify the displayed total is real from first frame +- [x] 2.3 Worktree removal declares per-worktree steps with start/finish checkpoints (resolved: 2 checkpoints — the deleter observes nothing finer inside `git worktree remove`); verify nonzero checkpoint progress with all steps Running +- [x] 2.4 Repo create/clone/migrate declare known step lists; genuinely undiscoverable phases stay Open and close on completion paths + +## 3. Rendering integration + +- [x] 3.1 `ProgressLayout` overall bar fill derives from checkpoint counts; phase headers keep step counts +- [x] 3.2 ✦/collapse/green derive from `Settled()`; remove any `done == total` styling paths +- [x] 3.3 E2E: terminal frames of every flow show each phase settling exactly once; no settled phase re-renders in-progress + +## 4. Verification + +- [x] 4.1 Full gauntlet; golden tests regenerated only where honesty changes rendering (bar fill, ✦ timing) with frame-by-frame review of re-recorded playground tapes +- [x] 4.2 Re-record final_motion-style real-repo tape; frame-verify monotonic bar and single-✦-per-phase via the frame-hash segmentation method +- [x] 4.3 Update `.impeccable.md` decision log (declarations, checkpoint bar, settled predicate) diff --git a/openspec/changes/progress-package/.openspec.yaml b/openspec/changes/progress-package/.openspec.yaml new file mode 100644 index 0000000..8fe2055 --- /dev/null +++ b/openspec/changes/progress-package/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-12 diff --git a/openspec/changes/progress-package/design.md b/openspec/changes/progress-package/design.md new file mode 100644 index 0000000..af27175 --- /dev/null +++ b/openspec/changes/progress-package/design.md @@ -0,0 +1,54 @@ +## Context + +Four progress representations coexist: `internal/pipeline` (Event stream + RunStep/PhaseRecorder, used by repo/creator flows and folded by `internal/tui/phase_display.go`), `integration.ManagerEvent` (apply + migrate-integrations, pumped through channels into tui messages), `worktree.DeletionEvent` plus bespoke tui messages (removal), and `internal/progress.Tracker` (dead, zero importers). The June 2026 UX audit's honesty fixes (upfront totals, checkpoints, close-gated ✦) need a single place to land. This change creates that place and is deliberately behavior-neutral: it must not change a single rendered byte. + +Constraints: the removal flow is the destructive path; golden chrome tests pin five stable views byte-exact; teatest E2E covers the flows end to end; the project forbids shims and dead-path flags (dialect types are replaced at the emitter, never wrapped). + +## Goals / Non-Goals + +**Goals** +- One status vocabulary, one `Event` type, one exported fold, one runner-helper set, in `internal/progress`. +- Zero behavior change: golden tests pass without `-update`; E2E unchanged. +- Dead `Tracker` and `internal/pipeline` deleted; `ManagerEvent`/`DeletionEvent` deleted. + +**Non-Goals** +- No plan declarations, checkpoints, phase-close events, or ✦-gating changes (that is `progress-declarations`). +- No timing/settle changes (`progress-completion-settle`). +- No copy or layout changes. + +## Decisions + +**D1: Consolidate under `internal/progress`, delete `internal/pipeline`.** +The package names the domain (progress of multi-phase operations), not one mechanism (a pipeline). The runner helpers move in unchanged. Alternative considered: consolidate under `pipeline` and delete `progress` — rejected because the domain term outlives the mechanism and the next change adds declaration types that are not "pipeline" concepts. + +**D2: Replace dialects at the emitter; no adapters.** +`integration.Manager` emits `progress.Event` directly, mapping worktree name to `Event.Phase` at emit time (the fold already groups by phase, so today's per-worktree phase rendering is preserved). `worktree` deletion emits `progress.Event` with phase `Removing worktrees` and step = path; the tui translates events to its messages in one place instead of three message types. Alternative: adapter functions wrapping old types — rejected per the no-shims rule; adapters would preserve the duplicated vocabulary the change exists to delete. + +**D3: The fold moves to the package as `Snapshot(events) []PhaseState`.** +`buildPhaseDisplays` and `withPendingPhases` move from `internal/tui/phase_display.go` to `internal/progress`, exported, with their tests. `PhaseState`/`StepState` are the exported forms of `phaseDisplay`/`stepDisplay`. The tui keeps zero folding logic; `ProgressLayout` consumes `[]PhaseState`. Alternative: keep the fold in tui and export only types — rejected; the fold is the contract's semantics and belongs beside its invariant tests. + +**D4: One `StepStatus` enum, values preserved.** +`pipeline.StepStatus` becomes `progress.StepStatus` with identical ordering (Pending, Running, Done, Failed, Skipped) so any persisted or switch-based logic is a mechanical rename. The dead tracker's enum is deleted with it. + +**D5: Event field names follow the existing `pipeline.Event` exactly** (`Phase, Step, Status, Message, Error`), minimizing the rename diff. New fields wait for `progress-declarations`. + +## Risks / Trade-offs + +- [Removal flow regression while rewiring its three message types] → the byte-identical golden criterion plus the existing removal E2E suite are the acceptance gate; the rewire is a separate task from the mechanical renames so it reviews in isolation. +- [ManagerEvent's worktree→phase mapping changes grouping subtly] → integration progress already renders per-worktree phases; a table-driven test asserts the fold of mapped events reproduces today's `buildIntegrationPhases` output for the same scenario. +- [Large mechanical diff hides a semantic edit] → commits separate "rename imports" (no logic) from "replace emitters" (logic); reviewers diff the latter only. +- [Two `StepStatus` enums diverge silently today] → consolidation removes the risk class; an invariant test pins the enum ordering. + +## Migration Plan + +1. Create `internal/progress` with vocabulary + fold + runner helpers (copied from pipeline + tui, tests moved along); delete `tracker.go`. +2. Mechanical import rename across ~20 files; delete `internal/pipeline`. +3. Replace `ManagerEvent` at the emitter and consumers. +4. Replace `DeletionEvent`/bespoke removal messages at emitter and consumers. +5. Gauntlet (gofmt, go vet, go test -race, golangci-lint) + golden tests without `-update` at every step. + +Rollback: each step is a commit; the change is internal-only, so revert is clean at any boundary. + +## Open Questions + +- None blocking. The exported `PhaseState` field set should anticipate `progress-declarations` (closed flag, checkpoint counts) but not include them; reviewer should confirm the field names leave room (e.g. avoid claiming `Total` for anything other than step count). diff --git a/openspec/changes/progress-package/proposal.md b/openspec/changes/progress-package/proposal.md new file mode 100644 index 0000000..9f5352a --- /dev/null +++ b/openspec/changes/progress-package/proposal.md @@ -0,0 +1,29 @@ +## Why + +Progress reporting has fragmented into four parallel vocabularies: `internal/pipeline` (repo/creator flows), `integration.ManagerEvent` (apply), `worktree.DeletionEvent` plus bespoke tui messages (removal), and `internal/progress.Tracker`, which has zero importers and is dead code. Two packages define identically-named `StepStatus` enums. Every honesty fix the June 2026 UX audit demands (upfront totals, checkpoint progress, phase-close gating) would otherwise have to be implemented three times; consolidating first means implementing them once. + +## What Changes + +- Rebirth `internal/progress` as the single package owning the progress contract: status vocabulary, `Event`, the event fold (today's `buildPhaseDisplays` in `internal/tui`), and the runner helpers (`RunStep`, `PhaseRecorder`) absorbed from `internal/pipeline`. +- Delete the dead `progress.Tracker` (zero importers). +- Delete `internal/pipeline`; all importers (~20 files across `internal/repo`, `internal/creator`, `internal/tui`, `cmd`) move to `internal/progress` mechanically. +- Replace `integration.ManagerEvent` with `progress.Event` at the emitter (worktree name maps to phase at emit time); the dialect type is removed, not adapted. +- Replace `worktree.DeletionEvent` and the bespoke removal tui messages (`worktreeDeleteStartedMsg`, `worktreeDeletedMsg`, `worktreeDeleteFailedMsg`) with `progress.Event` at the emitter; the dialect types are removed. +- Move `buildPhaseDisplays`/`withPendingPhases` from `internal/tui` into `internal/progress` as the exported fold (`Snapshot`); `internal/tui` consumes the exported display state. +- **Behavior-neutral**: rendered output is unchanged. Golden chrome tests must pass byte-identical without regeneration; all E2E tests unchanged. + +## Capabilities + +### New Capabilities +- `progress-events`: the consolidated progress contract — one status vocabulary, one `Event` type emitted by every multi-phase flow (removal, apply, teardown, create, clone, migrate, cleanup), and one pure fold from event stream to per-phase display state. Defines the determinism and ordering guarantees later changes build on (declarations, checkpoints, close gating arrive in `progress-declarations`, not here). + +### Modified Capabilities + + + +## Impact + +- Affected code: `internal/progress` (rewritten), `internal/pipeline` (deleted), `internal/integration` (ManagerEvent removed), `internal/worktree` (DeletionEvent removed), `internal/tui` (phase_display.go moves out; progress.go, integration_progress.go, migrate_integrations.go, model.go rewired), `internal/repo`, `internal/creator`, `cmd` (import renames). +- No CLI surface change, no rendered-output change, no new dependencies. +- Risk concentrated in the removal flow (destructive path); mitigated by the byte-identical golden criterion and the existing E2E suite (PRD 9.1 edge cases: missing worktree directories, locked worktrees, permission failures all flow through the replaced events and must keep their behavior). +- Unblocks: `progress-declarations` (plan bursts, checkpoints, ✦ close gating), `progress-truth-polish`. diff --git a/openspec/changes/progress-package/specs/progress-events/spec.md b/openspec/changes/progress-package/specs/progress-events/spec.md new file mode 100644 index 0000000..ab4eb3b --- /dev/null +++ b/openspec/changes/progress-package/specs/progress-events/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: Single progress vocabulary +The system SHALL define exactly one progress status vocabulary (`StepStatus`: Pending, Running, Done, Failed, Skipped) and exactly one progress event type (`Event` with Phase, Step, Status, Message, Error), both in `internal/progress`, and every multi-phase flow (worktree removal, integration apply, teardown, worktree create, repo create, repo clone, repo migrate, cleanup) SHALL emit its progress exclusively through this vocabulary. + +#### Scenario: No parallel progress vocabularies exist +- **WHEN** the codebase is searched for progress event or status type definitions +- **THEN** `internal/progress` contains the only `StepStatus` enum and the only progress event struct, and `internal/pipeline`, `integration.ManagerEvent`, and `worktree.DeletionEvent` do not exist + +#### Scenario: Integration apply emits the shared vocabulary +- **WHEN** the integration manager applies changes to a worktree +- **THEN** it emits `progress.Event` values whose Phase is the worktree's display name, preserving the per-worktree phase grouping rendered today + +#### Scenario: Worktree removal emits the shared vocabulary +- **WHEN** a worktree deletion starts, completes, or fails +- **THEN** the deleter emits a `progress.Event` with Running, Done, or Failed status respectively, carrying the worktree path as the step and any error on the Failed event + +### Requirement: Pure event fold +`internal/progress` SHALL provide a pure fold (`Snapshot`) from an ordered event slice to per-phase display state (phase name, ordered steps with statuses, done/total/failed counts), deterministic for a given input, preserving phase first-appearance order and step first-appearance order within a phase, and counting Done, Skipped, and Failed steps as resolved. + +#### Scenario: Fold is deterministic and order-preserving +- **WHEN** the same event slice is folded twice +- **THEN** both results are deeply equal, phases appear in first-mention order, and steps appear in first-mention order within their phase + +#### Scenario: Skipped counts as resolved +- **WHEN** a phase's events resolve one step Done, one Skipped, and one Failed +- **THEN** the phase reports done=3 of total=3 with failed=1, so a phase containing a best-effort skip still reaches completion + +#### Scenario: Later status supersedes earlier +- **WHEN** a step emits Running and subsequently Done +- **THEN** the folded step's status is Done and the step is not duplicated + +### Requirement: Canonical phase placeholders +The fold SHALL support reordering display state onto a canonical phase sequence, inserting an empty pending phase for any canonical phase that has not emitted events, while phases outside the canonical list retain discovery order after the canonical ones. + +#### Scenario: Unstarted canonical phase renders pending +- **WHEN** a flow declares the canonical sequence Teardown, Removing worktrees, Prune & cleanup and only Removing worktrees has emitted events +- **THEN** the display state contains all three phases in canonical order with Teardown and Prune & cleanup empty (pending, zero totals) + +### Requirement: Consolidation is behavior-neutral +Replacing the previous progress dialects SHALL NOT change any rendered output: golden chrome tests SHALL pass byte-identical without regeneration, and all flow E2E tests SHALL pass unchanged. + +#### Scenario: Golden views unchanged +- **WHEN** the golden chrome test suite runs against the consolidated code without `-update` +- **THEN** every pinned view matches byte-for-byte, ANSI included + +#### Scenario: Removal flow behavior preserved +- **WHEN** the removal E2E suite exercises deletion including failure and locked-worktree edge cases +- **THEN** all assertions pass without modification to the tests diff --git a/openspec/changes/progress-package/tasks.md b/openspec/changes/progress-package/tasks.md new file mode 100644 index 0000000..c62b4b9 --- /dev/null +++ b/openspec/changes/progress-package/tasks.md @@ -0,0 +1,29 @@ +## 1. Package foundation + +- [x] 1.1 Delete dead `internal/progress/tracker.go` and `tracker_test.go`; create `internal/progress` vocabulary: `StepStatus` (Pending, Running, Done, Failed, Skipped, ordering preserved from pipeline) and `Event{Phase, Step, Status, Message, Error}`, with an invariant test pinning enum ordering +- [x] 1.2 Move `RunStep`, `PhaseRecorder`, `Phase`, `StepResult`, `HasFailures`, `PhasesHaveFailures`, `FirstFailure` from `internal/pipeline` into `internal/progress` with their tests +- [x] 1.3 Move the fold from `internal/tui/phase_display.go` into `internal/progress` as exported `Snapshot(events) []PhaseState` and `WithPendingPhases`, exporting `PhaseState`/`StepState`; move and extend the fold tests (determinism, order preservation, skipped-counts-as-resolved, later-status-supersedes) + +## 2. Mechanical migration + +- [x] 2.1 Rename all `internal/pipeline` imports to `internal/progress` across `internal/repo`, `internal/creator`, `internal/tui`, `cmd` (no logic edits in this task); delete `internal/pipeline` +- [x] 2.2 Rewire `internal/tui` consumers of the fold (`progress.go`, `ProgressLayout` call sites) to `progress.Snapshot`/`progress.PhaseState`; delete `internal/tui/phase_display.go` +- [x] 2.3 Gauntlet checkpoint: gofmt, go vet, go test -race ./..., golangci-lint; golden tests must pass without `-update` + +## 3. Integration dialect replacement + +- [x] 3.1 Replace `integration.ManagerEvent` emission with `progress.Event` (worktree name maps to `Event.Phase` at emit time); update manager tests +- [x] 3.2 Rewire `internal/tui` integration consumers (`integration_progress.go`, `migrate_integrations.go`, `model.go` channels) to `progress.Event`; delete the `ManagerEvent` type +- [x] 3.3 Table-driven test: folding the mapped events reproduces today's `buildIntegrationPhases` display state for a representative apply scenario (success, failure, skip) + +## 4. Removal dialect replacement + +- [x] 4.1 Replace `worktree.DeletionEvent` emission with `progress.Event` (phase `Removing worktrees`, step = path, Running/Done/Failed); update deleter tests +- [x] 4.2 Rewire the removal flow in `internal/tui/progress.go` to consume `progress.Event`, deleting `worktreeDeleteStartedMsg`, `worktreeDeletedMsg`, `worktreeDeleteFailedMsg`; preserve Cmd-chained one-event-per-Msg consumption +- [x] 4.3 Run the full removal E2E suite unmodified; verify locked-worktree, missing-directory, and failure-path edge cases pass + +## 5. Verification + +- [x] 5.1 Repo-wide search proves no `pipeline.`, `ManagerEvent`, or `DeletionEvent` references remain; `internal/progress` is the only `StepStatus` definition +- [x] 5.2 Full gauntlet + golden tests byte-identical + commitlint on all commits +- [x] 5.3 Update `.impeccable.md` decision log with the consolidation entry diff --git a/openspec/changes/progress-truth-polish/.openspec.yaml b/openspec/changes/progress-truth-polish/.openspec.yaml new file mode 100644 index 0000000..8fe2055 --- /dev/null +++ b/openspec/changes/progress-truth-polish/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-12 diff --git a/openspec/changes/progress-truth-polish/design.md b/openspec/changes/progress-truth-polish/design.md new file mode 100644 index 0000000..57852df --- /dev/null +++ b/openspec/changes/progress-truth-polish/design.md @@ -0,0 +1,34 @@ +## Context + +Four small honesty leaks measured by the June 2026 UX audit, independent of the structural progress work except where noted. All presentation-layer; all decided in the session FAQ. + +## Goals / Non-Goals + +**Goals**: every dim/meta element either tells the truth or is absent. **Non-Goals**: structural progress changes (other changes in this arc); any new layout vocabulary. + +## Decisions + +**D1: Suppress elapsed under 2s rather than render sub-second precision.** +Alternative (showing `0.4s`) rejected: sub-second elapsed on a TUI flow is noise that implies precision the 1Hz repaint ticker does not have; absence is honest. The reserve stays so appearance does not reflow the bar. + +**D2: Failed phase headers drop the percent (FAQ: chosen over split `1 done · 1 failed` counts).** +Minimal change, no second header format to maintain; the ✗ indicator plus attempted/total counts carry the meaning. + +**D3: Skip traces as dim per-step lines (FAQ: chosen over summary-count-only).** +Auditable per worktree/integration, which is the point after the ccc detection incident; reuses the existing dim `– skipped` vocabulary, so no new styling. + +**D4: Errors title is a `copy.go` const, red count leads.** +Voice-registry rule: all titles in copy.go, one-line edits. Ordering follows the audit finding that green-first momentarily reads as success. + +## Risks / Trade-offs + +- [Golden churn] → regenerate only affected views, frame-review before commit. +- [Failed-header tests written against pre-declarations semantics] → sequenced after `progress-declarations` (noted in tasks 2.1). + +## Migration Plan + +Single PR after `progress-declarations` merges. Rollback: revert. + +## Open Questions + +None. diff --git a/openspec/changes/progress-truth-polish/proposal.md b/openspec/changes/progress-truth-polish/proposal.md new file mode 100644 index 0000000..6ead2f0 --- /dev/null +++ b/openspec/changes/progress-truth-polish/proposal.md @@ -0,0 +1,26 @@ +## Why + +Four small honesty leaks measured by the June 2026 UX audit remain after the structural fixes: `elapsed 0s` renders beside a 46%-filled bar (floor-truncated seconds are noise on short flows); failed phase headers read `✗ feat-1 2/2 100%`, conflating attempted with succeeded; the failure summary is titled "Apply complete" with the green count leading; and skip-install detection is invisible, so a wrong detection (the ccc incident class) has no surface to be noticed on. + +## What Changes + +- The elapsed readout is suppressed until elapsed >= 2s; its layout reserve is kept so the bar does not reflow when it appears. +- Phase headers for phases containing failures drop the percentage; counts remain (`✗ feat-1 2/2`). +- The apply failure summary gets its own title const in `copy.go` ("Apply finished with errors") and the headline leads with the failed count. +- Skipped steps render a dim per-step trace line during progress and on the summary (`· Install ccc – skipped (already installed)`), reusing the existing dim skipped vocabulary. + +## Capabilities + +### New Capabilities + + + +### Modified Capabilities +- `tui-progress`: added requirements for elapsed suppression, failed-phase header format, and skipped-step traces. +- `integration-apply-summary`: the outcome-summary requirement's failure presentation changes (title and headline ordering). + +## Impact + +- Affected code: `internal/tui` (progress_layout.go, integration_summary.go, copy.go), golden tests for affected stable views regenerated with frame review. +- Depends on `progress-declarations` for stable failed-phase semantics (sequenced after it); elapsed suppression and skip traces have no dependency. +- FAQ decisions locked: dim per-step skip line; drop-percent-on-failure (no new header format). diff --git a/openspec/changes/progress-truth-polish/specs/integration-apply-summary/spec.md b/openspec/changes/progress-truth-polish/specs/integration-apply-summary/spec.md new file mode 100644 index 0000000..62bb955 --- /dev/null +++ b/openspec/changes/progress-truth-polish/specs/integration-apply-summary/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Apply ends in an outcome summary +The TUI SHALL transition from the integration progress view to an integration summary view when the apply finishes, showing per-worktree outcomes: succeeded steps with the done indicator and failed steps with the failed indicator and their error text, plus overall counts. When any step failed, the summary's title SHALL state that the apply finished with errors (a dedicated `copy.go` const, not the success title) and the headline SHALL lead with the failed count before the applied count. + +#### Scenario: Fully successful apply +- **WHEN** an apply completes with all steps succeeding across 3 worktrees +- **THEN** the summary SHALL show each worktree with its completed steps and a success headline, and offer a key hint to return to the integration list + +#### Scenario: Partially failed apply +- **WHEN** an apply completes with failed steps in 2 of 7 worktrees +- **THEN** the summary SHALL show the failed steps with their error text under their worktrees, the title SHALL state the apply finished with errors, and the headline SHALL lead with the failed count (`✗ 2 failed, 5 applied` ordering) + +#### Scenario: State persistence failure +- **WHEN** the apply finishes but persisting the integration state to disk fails +- **THEN** the summary SHALL display the save error prominently and indicate that the shown integration set was not persisted diff --git a/openspec/changes/progress-truth-polish/specs/tui-progress/spec.md b/openspec/changes/progress-truth-polish/specs/tui-progress/spec.md new file mode 100644 index 0000000..fc6567a --- /dev/null +++ b/openspec/changes/progress-truth-polish/specs/tui-progress/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: Elapsed readout earns its place +The dim elapsed readout beside the overall bar SHALL render only once elapsed time is at least 2 seconds; its fixed layout reserve SHALL be maintained while hidden so the bar width does not change when the readout appears. + +#### Scenario: Short flow shows no elapsed +- **WHEN** a flow completes in under 2 seconds +- **THEN** no elapsed readout renders at any point and the bar occupies the same cells it would with the readout visible + +#### Scenario: Long flow gains elapsed without reflow +- **WHEN** a flow passes the 2 second mark +- **THEN** the elapsed readout appears in the reserved cells and the bar's width is unchanged from the prior frame + +### Requirement: Failed phases do not advertise percentages +A phase header whose phase contains failed steps SHALL render its counts without the percentage (`✗ /`), since the percent vocabulary elsewhere means completed work. + +#### Scenario: Failed phase header format +- **WHEN** a phase resolves with 1 done and 1 failed of 2 steps +- **THEN** its header renders the failed indicator and `2/2` with no percentage + +### Requirement: Skipped steps leave a visible trace +A step skipped by detection SHALL render as a dim step line stating it was skipped and why (`– skipped (already installed)`), during progress and on the flow summary, so detection decisions are auditable. + +#### Scenario: Skip-install is visible +- **WHEN** integration setup detects a tool already on PATH and skips its install step +- **THEN** the progress view and the apply summary both show a dim skipped line naming the step and the reason diff --git a/openspec/changes/progress-truth-polish/tasks.md b/openspec/changes/progress-truth-polish/tasks.md new file mode 100644 index 0000000..3596faa --- /dev/null +++ b/openspec/changes/progress-truth-polish/tasks.md @@ -0,0 +1,22 @@ +## 1. Elapsed suppression + +- [x] 1.1 Render the elapsed readout only at elapsed >= 2s, keeping the fixed reserve so the bar never reflows; table-driven layout tests for hidden/visible at the boundary + +## 2. Failed phase headers + +- [x] 2.1 Phase headers with failures render counts without the percentage; layout tests cover failed, mixed, and all-success phases (after `progress-declarations` merges, so semantics are stable) + +## 3. Failure summary voice + +- [x] 3.1 Add the errors title const to `copy.go` ("Apply finished with errors", sentence case) and use it when any step failed +- [x] 3.2 Headline leads with the failed count (`✗ 2 failed, 2 applied`); update integration summary tests + +## 4. Skip traces + +- [x] 4.1 Skipped steps render the dim `– skipped ()` line during progress (reusing the existing dim skipped vocabulary) and on the apply summary +- [x] 4.2 E2E: an apply with detection-skipped installs shows the skip lines in both views + +## 5. Verification + +- [x] 5.1 Full gauntlet; regenerate affected golden views with frame review; commitlint +- [x] 5.2 Update `.impeccable.md` decision log (elapsed threshold, failed-header format, errors title, skip traces) diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh index 144848f..d05324a 100755 --- a/scripts/install-hooks.sh +++ b/scripts/install-hooks.sh @@ -2,7 +2,17 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +common_dir="$(git rev-parse --git-common-dir)" +case "$common_dir" in +/*) ;; +*) common_dir="$(cd "$common_dir" && pwd -P)" ;; +esac +hooks_dir="$common_dir/sentei-hooks" -git config core.hooksPath "$SCRIPT_DIR" +mkdir -p "$hooks_dir" +for hook in pre-commit commit-msg; do + install -m 0755 "$SCRIPT_DIR/$hook" "$hooks_dir/$hook" +done +git config --local core.hooksPath "$hooks_dir" -echo "Git hooks installed (hooksPath → $SCRIPT_DIR)" +echo "Git hooks installed (hooksPath → $hooks_dir)" diff --git a/scripts/pre-commit b/scripts/pre-commit index 62536d4..c934f4e 100755 --- a/scripts/pre-commit +++ b/scripts/pre-commit @@ -3,6 +3,27 @@ set -euo pipefail REPO_ROOT="$(git rev-parse --show-toplevel)" +# Guard the effective identities Git will write, including environment +# overrides used by automation. Never require or print a contributor's +# personal identity; reject only the reserved test markers. +author_ident="$(git var GIT_AUTHOR_IDENT)" +committer_ident="$(git var GIT_COMMITTER_IDENT)" + +reject_identity() { + role="$1" + variable="$2" + echo "ERROR: effective Git $role identity uses a reserved test marker." + echo " Inspect it with: git var $variable" + exit 1 +} + +case "$author_ident" in +*sentei-test* | *"@sentei.invalid"*) reject_identity author GIT_AUTHOR_IDENT ;; +esac +case "$committer_ident" in +*sentei-test* | *"@sentei.invalid"*) reject_identity committer GIT_COMMITTER_IDENT ;; +esac + staged_go_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.go' || true) if [ -z "$staged_go_files" ]; then exit 0 diff --git a/scripts/pre-commit_test.sh b/scripts/pre-commit_test.sh new file mode 100755 index 0000000..4775a29 --- /dev/null +++ b/scripts/pre-commit_test.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/sentei-hooks.XXXXXX")" +repo="$tmp/repo" +worktree="$tmp/worktree" +trap 'rm -rf "$tmp"' EXIT + +# The fixture must not discover or mutate the caller's repository, hooks, or +# identity configuration. Establish the isolated process environment before +# the first fixture Git command. +mkdir -p "$tmp/home" "$tmp/xdg" +export HOME="$tmp/home" +export XDG_CONFIG_HOME="$tmp/xdg" +export GIT_CONFIG_GLOBAL="$tmp/gitconfig-global" +export GIT_CONFIG_SYSTEM=/dev/null +export GIT_CONFIG_NOSYSTEM=1 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY +unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_PREFIX +unset GIT_CONFIG_COUNT GIT_CONFIG_PARAMETERS +unset GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_AUTHOR_DATE +unset GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL GIT_COMMITTER_DATE + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +run_hook() { + ( + cd "$repo" + env "$@" "$SCRIPT_DIR/pre-commit" + ) +} + +expect_reject() { + role="$1" + shift + if output="$(run_hook "$@" 2>&1)"; then + fail "invalid effective $role identity was accepted" + fi + case "$output" in + *"$role"*) ;; + *) fail "$role rejection did not identify the invalid role: $output" ;; + esac +} + +resolve_common_dir() { + root="$1" + common="$(git -C "$root" rev-parse --git-common-dir)" + case "$common" in + /*) printf '%s\n' "$common" ;; + *) (cd "$root/$common" && pwd -P) ;; + esac +} + +git init -q "$repo" +git -C "$repo" config user.name "Valid Config" +git -C "$repo" config user.email "avishek.biswas.1997@gmail.com" +git -C "$repo" config core.hooksPath /dev/null +printf 'fixture\n' >"$repo/README.md" +git -C "$repo" add README.md +git -C "$repo" commit -q -m "chore: initialize fixture" + +expect_reject author \ + GIT_AUTHOR_NAME=sentei-test GIT_AUTHOR_EMAIL=test@sentei.invalid \ + GIT_COMMITTER_NAME="Valid User" GIT_COMMITTER_EMAIL=valid@example.com +expect_reject committer \ + GIT_AUTHOR_NAME="Valid User" GIT_AUTHOR_EMAIL=valid@example.com \ + GIT_COMMITTER_NAME=sentei-test GIT_COMMITTER_EMAIL=test@sentei.invalid + +git -C "$repo" config user.name sentei-test +git -C "$repo" config user.email test@sentei.invalid +if output="$(run_hook 2>&1)"; then + fail "configured reserved identity was accepted" +fi +case "$output" in +*author*) ;; +*) fail "configured identity rejection did not identify the author role: $output" ;; +esac +run_hook \ + GIT_AUTHOR_NAME="Valid Author" GIT_AUTHOR_EMAIL=author@example.com \ + GIT_COMMITTER_NAME="Valid Committer" GIT_COMMITTER_EMAIL=committer@example.com \ + >/dev/null || fail "valid environment identities did not override invalid repository config" + +git -C "$repo" config user.name "Valid Config" +git -C "$repo" config user.email valid-config@example.com +git -C "$repo" worktree add -q -b hook-test "$worktree" + +( + cd "$repo" + "$SCRIPT_DIR/install-hooks.sh" >/dev/null +) +common_dir="$(resolve_common_dir "$repo")" +expected_hooks="$common_dir/sentei-hooks" +configured_hooks="$(git -C "$repo" config --local --get core.hooksPath)" +[ "$configured_hooks" = "$expected_hooks" ] || fail "hooksPath = $configured_hooks, want $expected_hooks" +case "$configured_hooks" in +"$common_dir"/*) ;; +*) fail "hooksPath is not beneath the common Git directory: $configured_hooks" ;; +esac +cmp -s "$SCRIPT_DIR/pre-commit" "$expected_hooks/pre-commit" || fail "installed pre-commit differs from checked-in hook" +cmp -s "$SCRIPT_DIR/commit-msg" "$expected_hooks/commit-msg" || fail "installed commit-msg differs from checked-in hook" +[ -x "$expected_hooks/pre-commit" ] || fail "installed pre-commit is not executable" +[ -x "$expected_hooks/commit-msg" ] || fail "installed commit-msg is not executable" + +printf 'stale\n' >"$expected_hooks/pre-commit" +( + cd "$worktree" + "$SCRIPT_DIR/install-hooks.sh" >/dev/null +) +[ "$(git -C "$worktree" config --local --get core.hooksPath)" = "$expected_hooks" ] || fail "second worktree changed the shared hooksPath" +cmp -s "$SCRIPT_DIR/pre-commit" "$expected_hooks/pre-commit" || fail "second worktree install did not refresh hook content" + +( + cd "$worktree" + if GIT_AUTHOR_NAME=sentei-test GIT_AUTHOR_EMAIL=test@sentei.invalid \ + GIT_COMMITTER_NAME="Valid User" GIT_COMMITTER_EMAIL=valid@example.com \ + git hook run pre-commit >/dev/null 2>&1; then + fail "active installed hook accepted an invalid author" + fi + GIT_AUTHOR_NAME="Valid Author" GIT_AUTHOR_EMAIL=author@example.com \ + GIT_COMMITTER_NAME="Valid Committer" GIT_COMMITTER_EMAIL=committer@example.com \ + git hook run pre-commit >/dev/null +) + +echo "hook tests passed"