diff --git a/config/agentic-dev/config.toml.example b/config/agentic-dev/config.toml.example index 301ebee..86fd8c8 100644 --- a/config/agentic-dev/config.toml.example +++ b/config/agentic-dev/config.toml.example @@ -28,3 +28,9 @@ effort = "high" lead = "claude" build = "grok" judge = "claude" + +# [graphite] +# Handoff detects Graphite by the branches gt tracks. Override it per repo, keyed +# by the main checkout's path: true for a Graphite repo with no stack right now, false +# for a repo where gt once ran but you don't use it. +# "~/Work/some-repo" = false diff --git a/docs/config.md b/docs/config.md index 56bb602..8bc3758 100644 --- a/docs/config.md +++ b/docs/config.md @@ -19,9 +19,12 @@ effort = "high" lead = "claude" build = "grok" judge = "claude" + +[graphite] # optional, one line per repo; see below +"~/Work/some-repo" = false ``` -After you edit it, `bercail reconfigure` re-reads it, refreshes the skills and bstack, and installs the desk agent and review tool if they are missing. What the `[bstack]` keys do (pins, `effort`, roles) is in [Make it yours](bstack/08-make-it-yours.md#models-and-roles); `review` and `auto_review` in [review](review.md). +After you edit it, `bercail reconfigure` re-reads it, refreshes the skills and bstack, and installs the desk agent and review tool if they are missing. What the `[bstack]` keys do (pins, `effort`, roles) is in [Make it yours](bstack/08-make-it-yours.md#models-and-roles); `review` and `auto_review` in [review](review.md); `[graphite]` in [handoff](handoff.md#graphite). ## Files bercail manages diff --git a/docs/handoff.md b/docs/handoff.md index 8e46908..53369f2 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -24,7 +24,7 @@ bd create "Fix the login timeout" --description "..." # Shep (the Grok bot) ~/.agents/skills/handoff/scripts/handoff-spawn --branch NAME --stage cursor --issue ID [--dirty|--clean] [--space ID] [--base BRANCH] ``` -`--base BRANCH` cuts the desk branch from BRANCH instead of the default branch, so a dependent issue's desk stacks on its parent's branch (Graphite, when the repo uses it, tracks BRANCH as the parent). It works only with `--branch`, and stage `autopilot` can't take `--branch`: give it `--worktree` on the PR's checkout. +`--base BRANCH` cuts the desk branch from BRANCH instead of the default branch, so a dependent issue's desk stacks on its parent's branch ([Graphite](#graphite), when the repo uses it, tracks BRANCH as the parent). It works only with `--branch`, and stage `autopilot` can't take `--branch`: give it `--worktree` on the PR's checkout. **Prompt.** One line pointing at the issue, the same for every harness: ``Your job is beads issue . Run `bd show ` (read its comments too) and do the work it describes.`` @@ -39,3 +39,19 @@ bd create "Fix the login timeout" --description "..." # Shep (the Grok bot) **Which harnesses?** `bercail harness` reports which of claude, codex, cursor-agent, and opencode are on PATH, with a local model hint. It does not choose. Each `--branch` spawn is logged in `${XDG_STATE_HOME:-~/.local/state}/agentic-dev/handoffs.jsonl`; headless runs are not logged there. Follow a `--branch` desk through [agent status webhooks](shep.md#agent-status-webhooks) or with `hc wait `; `desk` and `space` (and `base`, when set) are in the spawn JSON. + +## graphite + +With `--branch`, a repo that uses Graphite gets `gt track` on the new branch and a line in the agent's handoff comment telling it to use `gt`. `--info` and the spawn JSON report it as `graphite`. + +Any `gt` command initializes the repo it runs in, so a stray one must not turn this on. A repo counts as Graphite when `gt` tracks a branch other than trunk. A repo that only has Graphite's config file doesn't count. The spawn prints a note because a real Graphite repo looks the same once its last stack is merged. The check reads `.graphite_metadata.db` with `sqlite3`. Without `sqlite3`, or if the file can't be read, the spawn skips Graphite and warns. Install `sqlite3` or use the override below. + +To decide for a repo yourself, add its main checkout to `~/.config/bercail/config.toml`. Linked worktrees follow it. + +```toml +[graphite] +"~/Work/leftlane_app" = true # a Graphite repo with no stack right now +"~/Work/bercail" = false # gt once ran here, but you don't use it +``` + +Tracking can't stop a spawn. Each `gt` call is killed after 30 seconds (`HANDOFF_GT_TIMEOUT`). If `--base` is untracked, bercail tracks it on trunk and retries, then falls back to trunk. On any other failure, such as a locked Graphite database, it changes nothing, so your stack stays as it was. If tracking still fails, or `gt` hangs, the spawn prints a warning with the `gt track` command to run, and the desk still starts. diff --git a/skills/handoff/resources/git-workflow.md b/skills/handoff/resources/git-workflow.md index 2aef4bb..da73b71 100644 --- a/skills/handoff/resources/git-workflow.md +++ b/skills/handoff/resources/git-workflow.md @@ -1,8 +1,10 @@ # Project git / Graphite / GitHub (handoff) Spawn (`scripts/handoff-spawn --info` then spawn) already reports `graphite` -and Graphite-tracks the sibling when that is true. Do not re-detect. The child -intro already says to use `gt` on Graphite repos. +and Graphite-tracks the sibling when that is true. Do not re-detect, and do not +run `gt` to check: any `gt` command auto-initializes a plain repo. The child +intro already says to use `gt` on Graphite repos. How detection works and how to +override it: [handoff.md](handoff.md#graphite). Handoff never auto-commits. Agents stage their work (`git add`) and leave it uncommitted: the staged diff is what the human reviews in tuicr (`tuicr -w`, diff --git a/skills/handoff/resources/handoff.md b/skills/handoff/resources/handoff.md index b9235eb..49eefdf 100644 --- a/skills/handoff/resources/handoff.md +++ b/skills/handoff/resources/handoff.md @@ -17,10 +17,10 @@ run `--info` then spawn — not assemble these steps. 3. If `--dirty` or `default_copy` is dirty (and not `--clean`): copy `git diff HEAD` plus untracked files into the sibling as a **working tree**. Never `git add`. -4. If Graphite config exists (`handoff_graphite_config`, in the shared git - dir), `gt track` a branch that has no parent yet. +4. If the repo really uses Graphite (`handoff_is_graphite`, see below), `gt + track` a branch that has no parent yet. A gt problem never stops the spawn. 5. Add the intro (worktree; stage, don't commit; Review opens on agent done; - gt on Graphite) to the beads issue as a `bd comment`. The prompt is one line + gt when Graphite is on) to the beads issue as a `bd comment`. The prompt is one line pointing at the issue, the same for every binary. No prompt file and no per-harness mode. 6. `WT_HERDR_AGENT_CMD` is the stage table's binary and `WT_HERDR_AGENT_PROMPT` @@ -98,3 +98,32 @@ Result: sibling `git diff --cached` empty; dirty files are unstaged/untracked. The script is the only spawn path. Callers must not `hc send` or type into a desk, start a desk with `hc run` themselves, or switch the user to the child's space. + +## Graphite + +Any `gt` command auto-initializes the repo it runs in, so the config file in the +shared git dir (`.graphite_repo_config`) proves nothing. `handoff_is_graphite` +decides in this order: + +1. An entry for the main checkout under `[graphite]` in bercail's config.toml + (`"~/Work/repo" = true` or `false`). Linked worktrees use their main + checkout's entry. +2. No config file: not Graphite. +3. gt tracks a branch other than trunk: a `refs/branch-metadata/*` ref (older + gt) or a row with a parent in `.graphite_metadata.db` (current gt, read with + `sqlite3 -readonly`). Auto-init only writes rows with no parent. +4. A store that can't be read (no sqlite3, busy, unknown schema) skips Graphite, + with a warning that names the override. Install sqlite3 or set the override. + +A config with nothing tracked is off, with a note that names the override: a +real repo looks the same as a stray one once its last stack is merged. An entry +in `[graphite]` silences both. + +`handoff_track_graphite` runs every `gt` call through `handoff_gt`: its own +process group, no stdin, stdout sent to stderr (stdout is the spawn's JSON), and +killed with its children after `HANDOFF_GT_TIMEOUT` seconds (default 30). It tries the parent +(`--base`, else trunk). Only when `gt branch info` says the parent is untracked +or unknown does it track that parent on trunk and retry, then fall back to trunk. +On any other failure (a tracked parent, a locked store) it changes nothing else: +re-tracking would rewrite a real stack. It never passes `--force`. A failure or a hung gt warns and the desk still +starts; after a hang it runs no more gt. diff --git a/skills/handoff/scripts/handoff-spawn b/skills/handoff/scripts/handoff-spawn index a04ff8e..5265fe9 100755 --- a/skills/handoff/scripts/handoff-spawn +++ b/skills/handoff/scripts/handoff-spawn @@ -88,8 +88,72 @@ handoff_graphite_config() { printf '%s' "$cfg" } +# This repo's entry in [graphite] of bercail's config.toml: "
" = +# true|false. A linked worktree goes by its main checkout. Fails with no entry. +handoff_graphite_override() { + local config="${BERCAIL_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/bercail/config.toml}" main key value + [[ -f "$config" ]] || return 1 + main="$(handoff_git_main "$1")" + [[ -n "$main" ]] || return 1 + main="$(handoff_realpath "$main")" + while IFS=$'\t' read -r key value; do + [[ "$key" != \~/* ]] || key="$HOME/${key#\~/}" + if [[ "$(handoff_realpath "$key")" == "$main" ]]; then + printf '%s' "$value" + return 0 + fi + done < <(awk ' + /^[[:space:]]*\[/ { sec = $0; sub(/#.*/, "", sec); gsub(/[[:space:]"\047]/, "", sec); next } + sec != "[graphite]" { next } + { + line = $0 + sub(/^[[:space:]]*/, "", line) + q = substr(line, 1, 1) + if (q != "\"" && q != "\047") next + line = substr(line, 2) + i = index(line, q) + if (!i) next + value = substr(line, i + 1) + sub(/^[[:space:]]*=[[:space:]]*/, "", value) + sub(/[[:space:]]*(#.*)?$/, "", value) + if (value == "true" || value == "false") print substr(line, 1, i - 1) "\t" value + }' "$config") + return 1 +} + +# Any gt command auto-inits a repo. Only non-trunk refs (older gt) or parented +# rows in .graphite_metadata.db (current gt) prove branches are tracked. +handoff_graphite_tracks_branches() { + local root="$1" cfg="$2" trunks refs db rows + trunks="$(jq -r '[.trunk, .trunks[]?.name] | map(select(. != null)) | .[]' "$cfg" 2>/dev/null || true)" + refs="$(git -C "$root" for-each-ref --format='%(refname:lstrip=2)' refs/branch-metadata/ 2>/dev/null || true)" + if [[ -n "$refs" ]] && grep -qvxF -e "$trunks" <<<"$refs"; then + return 0 + fi + db="$(dirname "$cfg")/.graphite_metadata.db" + if [[ -f "$db" ]]; then + if ! rows="$("${HANDOFF_SQLITE3_BIN:-sqlite3}" -readonly "$db" \ + "select 1 from branch_metadata where coalesce(parent_branch_name, '') <> '' limit 1" 2>/dev/null)"; then + printf 'handoff-spawn: cannot read %s (sqlite3 missing, database busy, or unknown schema); skipping Graphite. Set "%s" = true or false under [graphite] in bercail config.toml to override.\n' \ + "$db" "$(handoff_git_main "$root")" >&2 + return 1 + fi + [[ -z "$rows" ]] || return 0 + fi + # A real repo looks the same once its last stack is merged. + printf 'handoff-spawn: gt has a config here but tracks no branch, so not using Graphite. If this repo uses it, set "%s" = true under [graphite] in bercail config.toml.\n' \ + "$(handoff_git_main "$root")" >&2 + return 1 +} + handoff_is_graphite() { - handoff_graphite_config "$1" >/dev/null + local override cfg + if override="$(handoff_graphite_override "$1")"; then + [[ "$override" == true ]] + return + fi + cfg="$(handoff_graphite_config "$1")" || return 1 + handoff_graphite_tracks_branches "$1" "$cfg" } # Copy main's uncommitted tracked+untracked files into sibling as a working @@ -113,23 +177,103 @@ handoff_copy_dirty() { done < <(git -C "$main" ls-files --others --exclude-standard) } +# Run a command in its own process group, so its children die with it. Status +# 124 when it outlives $1 seconds. Not timeout(1): macOS has none. +handoff_bounded() { + local ticks=$(($1 * 10)) pid rc + shift + ( + # Job control only for the fork: it is what gives the command its own group. + # Left on, bash also prints a notice ("[1]+ Done") when the command ends. + set -m + "$@" & + pid=$! + set +m + # gt left the terminal's group with set -m, so Ctrl-C only reaches it from here. + # Die by the signal: on a plain exit 130 the caller would carry on to the next gt. + trap 'kill -KILL -- "-$pid" 2>/dev/null; trap - INT; kill -INT 0' INT + trap 'kill -KILL -- "-$pid" 2>/dev/null; exit 143' TERM + while kill -0 "$pid" 2>/dev/null; do + if [[ "$ticks" -le 0 ]]; then + kill -TERM -- "-$pid" 2>/dev/null || true + sleep 1 + kill -KILL -- "-$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + exit 124 + fi + ticks=$((ticks - 1)) + sleep 0.1 + done + rc=0 + wait "$pid" || rc=$? + # A child left behind would hold the caller's pipes open. + kill -KILL -- "-$pid" 2>/dev/null || true + exit "$rc" + ) +} + +handoff_gt_timeout() { + local secs="${HANDOFF_GT_TIMEOUT:-30}" + [[ "$secs" =~ ^[1-9][0-9]*$ ]] || secs=30 + printf '%s' "$secs" +} + +# No gt call can hold the spawn or answer a prompt, and stdout is the spawn's JSON. +handoff_gt() { + local sibling="$1" + shift + handoff_bounded "$(handoff_gt_timeout)" gt --cwd "$sibling" "$@" --no-interactive &2 +} + +# Repair only when gt says the parent is untracked or unknown. Other errors +# (such as a locked database) must never re-parent an existing stack. +handoff_gt_track() { + local sibling="$1" parent="$2" trunk="$3" rc=0 info + handoff_gt "$sibling" track --parent "$parent" || rc=$? + [[ "$rc" -ne 0 && "$rc" -ne 124 && "$parent" != "$trunk" ]] || return "$rc" + rc=0 + info="$(handoff_gt "$sibling" branch info --branch "$parent" 2>&1)" || rc=$? + [[ "$rc" -ne 124 ]] || return 124 + [[ "$rc" -ne 0 && ( "$info" == *"untracked branch"* || "$info" == *"Could not find branch"* ) ]] || return 1 + rc=0 + handoff_gt "$sibling" track "$parent" --parent "$trunk" || rc=$? + [[ "$rc" -ne 124 ]] || return 124 + if [[ "$rc" -eq 0 ]]; then + rc=0 + handoff_gt "$sibling" track --parent "$parent" || rc=$? + [[ "$rc" -ne 0 ]] || return 0 + [[ "$rc" -ne 124 ]] || return 124 + fi + printf 'handoff-spawn: gt could not track %s as the parent; using trunk %s.\n' "$parent" "$trunk" >&2 + handoff_gt "$sibling" track --parent "$trunk" +} + # parent: the branch the new one was cut from (--base); default is trunk. +# Never fails the spawn: the worktree and its space exist by now, so a gt problem +# is a warning and the desk still starts. handoff_track_graphite() { - local sibling="$1" parent="${2:-}" cfg trunk - cfg="$(handoff_graphite_config "$sibling")" || return 0 - command -v gt >/dev/null 2>&1 || return 0 - if gt --cwd "$sibling" parent >/dev/null 2>&1; then + local sibling="$1" parent="${2:-}" cfg trunk rc=0 + # gt would auto-init here, on a trunk it guessed. + if ! cfg="$(handoff_graphite_config "$sibling")"; then + printf 'handoff-spawn: Graphite is on for this repo but it has no gt config; not tracking %s. Run gt init, then gt track.\n' "$sibling" >&2 return 0 fi - if [[ -n "$parent" ]]; then - trunk="$parent" - else - trunk="$(jq -r '.trunk // "main"' "$cfg" 2>/dev/null || true)" - trunk="${trunk:-main}" + command -v gt >/dev/null 2>&1 || return 0 + handoff_gt "$sibling" parent >/dev/null 2>&1 || rc=$? + [[ "$rc" -ne 0 ]] || return 0 + trunk="$(jq -r '.trunk // .trunks[0].name // "main"' "$cfg" 2>/dev/null || true)" + trunk="${trunk:-main}" + parent="${parent:-$trunk}" + if [[ "$rc" -ne 124 ]]; then + rc=0 + handoff_gt_track "$sibling" "$parent" "$trunk" || rc=$? fi - gt --cwd "$sibling" track --parent "$trunk" --no-interactive \ - || gt --cwd "$sibling" track --force --no-interactive \ - || handoff_die "gt track failed for $sibling" + case "$rc" in + 0) return 0 ;; + 124) printf 'handoff-spawn: gt did not finish in %ss; ' "$(handoff_gt_timeout)" >&2 ;; + *) printf 'handoff-spawn: gt failed; ' >&2 ;; + esac + printf 'the desk starts anyway, untracked. In %s run: gt track --parent %s\n' "$sibling" "$parent" >&2 } handoff_sibling_path() { diff --git a/tests/handoff_spawn_test.sh b/tests/handoff_spawn_test.sh index bf6e4fc..f2e420e 100755 --- a/tests/handoff_spawn_test.sh +++ b/tests/handoff_spawn_test.sh @@ -25,21 +25,120 @@ git_init() { git -C "$dir" commit -qm base } -test_graphite_rev_parse_is_not_enough() { +git_dir() { git -C "$1" rev-parse --path-format=absolute --git-common-dir; } + +# What any gt command leaves behind in a repo: the config, nothing tracked. +graphite_auto_init() { + printf '{"trunk":"master","trunks":[{"name":"master"}]}\n' >"$(git_dir "$1")/.graphite_repo_config" +} + +# Current gt stores every branch it has seen. Only tracked branches have a parent. +graphite_db_row() { + local parent=null + [[ -z "${3:-}" ]] || parent="'$3'" + sqlite3 "$(git_dir "$1")/.graphite_metadata.db" \ + "create table if not exists branch_metadata (branch_name text primary key, parent_branch_name text); + insert into branch_metadata values ('$2', $parent);" +} + +test_graphite_config_file_alone_is_not_graphite() { local repo="$TMP_DIR/plain" git_init "$repo" - local printed - printed="$(git -C "$repo" rev-parse --git-path .graphite_repo_config)" - [[ -n "$printed" ]] || fail "rev-parse always prints a path" handoff_is_graphite "$repo" && fail "plain repo must not count as Graphite" - mkdir -p "$(dirname "$repo/$printed")" - if [[ "$printed" == /* ]]; then - printf '{}\n' >"$printed" - else - printf '{}\n' >"$repo/$printed" + graphite_auto_init "$repo" + handoff_is_graphite "$repo" && fail "an auto-created config must not count as Graphite" + git -C "$repo" update-ref refs/branch-metadata/master HEAD + handoff_is_graphite "$repo" && fail "metadata for trunk alone must not count as Graphite" + printf 'PASS: an auto-created Graphite config, even with a trunk ref, is not Graphite\n' +} + +test_graphite_metadata_refs_are_graphite() { + local repo="$TMP_DIR/gt-refs" sibling="$TMP_DIR/gt-refs.feat" + git_init "$repo" + git -C "$repo" worktree add -q -b feat "$sibling" + graphite_auto_init "$repo" + git -C "$repo" update-ref refs/branch-metadata/stack/one HEAD + handoff_is_graphite "$repo" || fail "a tracked branch ref must count as Graphite" + handoff_is_graphite "$sibling" || fail "a linked worktree shares the refs" + printf 'PASS: branch metadata refs for a non-trunk branch are Graphite, from any worktree\n' +} + +test_graphite_second_trunk_ref_is_not_graphite() { + local repo="$TMP_DIR/gt-trunks" + git_init "$repo" + printf '{"trunk":"main","trunks":[{"name":"main"},{"name":"sandbox"}]}\n' >"$(git_dir "$repo")/.graphite_repo_config" + git -C "$repo" update-ref refs/branch-metadata/sandbox HEAD + handoff_is_graphite "$repo" && fail "every trunk is excluded, not only the first" + printf 'PASS: metadata refs of any configured trunk do not count\n' +} + +test_graphite_metadata_db_rows() { + local repo="$TMP_DIR/gt-db" err + git_init "$repo" + graphite_auto_init "$repo" + export HANDOFF_SQLITE3_BIN="$TMP_DIR/no-sqlite3" + : >"$(git_dir "$repo")/.graphite_metadata.db" + err="$(handoff_is_graphite "$repo" 2>&1)" && fail "an unreadable store is not evidence of Graphite: $err" + printf '%s' "$err" | grep -q '\[graphite\]' || fail "unreadable store should name the override: $err" + unset HANDOFF_SQLITE3_BIN + if ! command -v sqlite3 >/dev/null 2>&1; then + printf 'SKIP: sqlite3 not found; the store rows are untested\n' + return 0 fi - handoff_is_graphite "$repo" || fail "file present must count as Graphite" - printf 'PASS: Graphite detection is test -f of git-path, not rev-parse alone\n' + err="$(handoff_is_graphite "$repo" 2>&1)" && fail "an unknown schema is not evidence of Graphite: $err" + printf '%s' "$err" | grep -q '\[graphite\]' || fail "unknown schema should name the override: $err" + rm -f "$(git_dir "$repo")/.graphite_metadata.db" + graphite_db_row "$repo" master + graphite_db_row "$repo" stray-branch + err="$(handoff_is_graphite "$repo" 2>&1)" && fail "rows with no parent are only what auto-init saw: $err" + printf '%s' "$err" | grep -q 'tracks no branch' || fail "say why Graphite is off, and how to turn it on: $err" + printf '%s' "$err" | grep -q 'cannot read' && fail "a readable store is not unreadable: $err" + graphite_db_row "$repo" feat master + handoff_is_graphite "$repo" || fail "a branch with a parent is tracked: Graphite" + printf 'PASS: the metadata store counts only rows with a parent; unreadable skips Graphite and warns\n' +} + +test_graphite_config_override() { + local plain="$TMP_DIR/ov-plain" real="$TMP_DIR/ov-real" sibling="$TMP_DIR/ov-real.feat" cfg="$TMP_DIR/ov.toml" + local home="$TMP_DIR/ov-home" + git_init "$plain" + git_init "$real" + git -C "$real" worktree add -q -b feat "$sibling" + graphite_auto_init "$real" + git -C "$real" update-ref refs/branch-metadata/feat HEAD + export BERCAIL_CONFIG="$cfg" + cat >"$cfg" <"$cfg" <"$cfg" + handoff_is_graphite "$plain" || fail "a quoted table name is the same table" + [[ -z "$(handoff_is_graphite "$plain" 2>&1)" ]] || fail "a repo that has settled it hears nothing" + mkdir -p "$home" + git_init "$home/repo" + printf '[graphite]\n"~/repo" = true\n' >"$cfg" + (HOME="$home"; handoff_is_graphite "$home/repo") || fail "~/ in a key is the home directory" + rm -f "$cfg" + handoff_is_graphite "$real" || fail "no config file falls through to detection" + unset BERCAIL_CONFIG + printf 'PASS: [graphite] in config.toml overrides detection for one repo, from any worktree\n' } test_copy_dirty_is_working_tree_not_index() { @@ -97,6 +196,7 @@ test_graphite_track_uses_resolved_config_path() { fi mkdir -p "$(dirname "$cfg")" printf '{"trunk":"master"}\n' >"$cfg" + git -C "$repo" update-ref refs/branch-metadata/feat HEAD [[ "$(handoff_graphite_config "$repo")" == "$cfg" ]] \ || fail "graphite config path should resolve relative git-path" ( @@ -118,7 +218,7 @@ test_graphite_tracks_new_branch_in_linked_worktree() { cat >"$bin/gt" <>"$TMP_DIR/gt.log" -case "\$*" in *" parent") exit 1 ;; esac +case "\$*" in *" parent "*) exit 1 ;; esac EOF chmod +x "$bin/gt" : >"$TMP_DIR/gt.log" @@ -141,6 +241,10 @@ test_info_json_reports_graphite_and_dirty() { printf '#!/bin/sh\nexit 1\n' >"$TMP_DIR/no-herdr" chmod +x "$TMP_DIR/no-herdr" unset HC_TERMINAL_ID HANDOFF_SPACE + out="$(cd "$repo" && HERDR_APP_BIN="$TMP_DIR/no-herdr" \ + "$ROOT/skills/handoff/scripts/handoff-spawn" --info)" + printf '%s' "$out" | jq -e '.graphite == false' >/dev/null || fail "info with only an auto-created config: $out" + git -C "$repo" update-ref refs/branch-metadata/feat HEAD out="$(cd "$repo" && HERDR_APP_BIN="$TMP_DIR/no-herdr" \ CURSOR_CONFIG_DIR="$TMP_DIR/no-cursor" \ "$ROOT/skills/handoff/scripts/handoff-spawn" --info)" @@ -244,7 +348,7 @@ test_graphite_tracks_base_as_parent() { cat >"$bin/gt" <>"$TMP_DIR/gt-base.log" -case "\$*" in *" parent") exit 1 ;; esac +case "\$*" in *" parent "*) exit 1 ;; esac EOF chmod +x "$bin/gt" : >"$TMP_DIR/gt-base.log" @@ -264,7 +368,11 @@ test_base_needs_branch() { printf 'PASS: --base needs --branch and is in --help\n' } -test_graphite_rev_parse_is_not_enough +test_graphite_config_file_alone_is_not_graphite +test_graphite_metadata_refs_are_graphite +test_graphite_second_trunk_ref_is_not_graphite +test_graphite_metadata_db_rows +test_graphite_config_override test_copy_dirty_is_working_tree_not_index test_usage test_issue_prompt_points_at_the_issue @@ -903,3 +1011,439 @@ test_no_session_id_is_not_invented() { } test_no_session_id_is_not_invented + +# A fake gt: $1 is the case body run on its joined argv, after logging it. +_fake_gt() { + local bin="$1" log="$2" body="$3" + mkdir -p "$bin" + : >"$log" + cat >"$bin/gt" <>"$log" +args=" \$* " +$body +EOF + chmod +x "$bin/gt" +} + +_track_fixture() { + local repo="$1" sibling="$1.feat" + git_init "$repo" + git -C "$repo" branch parent-branch + git -C "$repo" worktree add -q -b feat "$sibling" parent-branch + graphite_auto_init "$repo" +} + +test_track_already_tracked_does_nothing() { + local repo="$TMP_DIR/tr-done" bin="$TMP_DIR/tr-done-bin" log="$TMP_DIR/tr-done.log" + _track_fixture "$repo" + _fake_gt "$bin" "$log" 'exit 0' + PATH="$bin:$PATH" handoff_track_graphite "$repo.feat" parent-branch + grep -q ' track ' "$log" && fail "a branch with a parent is not tracked again: $(cat "$log")" + printf 'PASS: a branch gt already knows is left alone\n' +} + +test_track_tracks_an_untracked_parent_first() { + local repo="$TMP_DIR/tr-parent" bin="$TMP_DIR/tr-parent-bin" log="$TMP_DIR/tr-parent.log" order + _track_fixture "$repo" + # Like gt: the new branch can't sit on a parent until that parent is tracked. + _fake_gt "$bin" "$log" " +case \"\$args\" in + *' parent --no-interactive '*) exit 1 ;; + *' track parent-branch --parent master '*) : >'$TMP_DIR/tr-parent-tracked'; exit 0 ;; + *' track --parent parent-branch '*) [[ -f '$TMP_DIR/tr-parent-tracked' ]] ;; + *' branch info '*) echo 'ERROR: Cannot perform this operation on untracked branch parent-branch.' >&2; exit 1 ;; + *) exit 7 ;; +esac" + PATH="$bin:$PATH" handoff_track_graphite "$repo.feat" parent-branch + order="$(grep -c . "$log")" + [[ "$order" -eq 5 ]] || fail "parent check, try, is parent tracked, track parent, retry; log=$(cat "$log")" + [[ "$(sed -n 5p "$log")" == "--cwd $repo.feat track --parent parent-branch --no-interactive" ]] \ + || fail "the branch should end up on its real parent; log=$(cat "$log")" + grep -q -- '--force' "$log" && fail "--force must never run: $(cat "$log")" + printf 'PASS: an untracked --base is tracked on trunk first, then the branch on it\n' +} + +test_track_falls_back_to_trunk_with_a_warning() { + local repo="$TMP_DIR/tr-trunk" bin="$TMP_DIR/tr-trunk-bin" log="$TMP_DIR/tr-trunk.log" err + _track_fixture "$repo" + _fake_gt "$bin" "$log" " +case \"\$args\" in + *' track --parent master '*) exit 0 ;; + *' branch info '*) echo 'ERROR: Could not find branch parent-branch.' >&2; exit 1 ;; + *) exit 1 ;; +esac" + err="$(PATH="$bin:$PATH" handoff_track_graphite "$repo.feat" parent-branch 2>&1)" \ + || fail "fallback must not fail: $err" + [[ "$(tail -1 "$log")" == "--cwd $repo.feat track --parent master --no-interactive" ]] \ + || fail "should end on trunk; log=$(cat "$log")" + printf '%s' "$err" | grep -q 'parent-branch' || fail "warn that the parent was not used: $err" + printf '%s' "$err" | grep -qE '\[[0-9]+\]' && fail "job notices on stderr: $err" + grep -q -- '--force' "$log" && fail "--force must never run: $(cat "$log")" + printf 'PASS: when the parent cannot be tracked the branch goes on trunk, with a warning\n' +} + +test_track_leaves_a_tracked_parent_in_its_stack() { + local repo="$TMP_DIR/tr-stack" bin="$TMP_DIR/tr-stack-bin" log="$TMP_DIR/tr-stack.log" err + _track_fixture "$repo" + # The parent is tracked, so this failure is about something else. + _fake_gt "$bin" "$log" " +case \"\$args\" in + *' branch info --branch parent-branch '*) exit 0 ;; + *) exit 1 ;; +esac" + err="$(PATH="$bin:$PATH" handoff_track_graphite "$repo.feat" parent-branch 2>&1)" || fail "must not fail: $err" + grep -q ' track parent-branch ' "$log" && fail "re-tracking a tracked parent cuts it out of its stack: $(cat "$log")" + grep -q ' track --parent master ' "$log" && fail "a stacked branch must not silently move to trunk: $(cat "$log")" + printf '%s' "$err" | grep -q 'gt track --parent parent-branch' || fail "warn with the real parent: $err" + printf 'PASS: a gt failure never re-parents a tracked --base or moves the branch to trunk\n' +} + +# A locked store fails every gt call, then clears: the repair must not run on that. +test_track_transient_failure_never_touches_the_parent() { + local repo="$TMP_DIR/tr-lock" bin="$TMP_DIR/tr-lock-bin" log="$TMP_DIR/tr-lock.log" err + _track_fixture "$repo" + _fake_gt "$bin" "$log" " +case \"\$args\" in + *' track parent-branch '*) exit 0 ;; + *' track --parent master '*) exit 0 ;; + *) echo 'database is locked' >&2; exit 1 ;; +esac" + err="$(PATH="$bin:$PATH" handoff_track_graphite "$repo.feat" parent-branch 2>&1)" || fail "must not fail: $err" + grep -q ' track parent-branch ' "$log" && fail "a failure that is not 'untracked' re-parented the base: $(cat "$log")" + grep -q ' track --parent master ' "$log" && fail "a failure that is not 'untracked' moved the branch to trunk: $(cat "$log")" + printf '%s' "$err" | grep -q 'gt track --parent parent-branch' || fail "warn with the real parent: $err" + printf 'PASS: only an untracked or unknown --base is repaired; a locked store changes nothing\n' +} + +test_track_child_left_behind_does_not_hold_the_caller() { + local repo="$TMP_DIR/tr-left" bin="$TMP_DIR/tr-left-bin" log="$TMP_DIR/tr-left.log" err start + _track_fixture "$repo" + _fake_gt "$bin" "$log" " +case \"\$args\" in + *' parent --no-interactive '*) exit 1 ;; + *' track '*) sleep 300 & exit 1 ;; + *) exit 0 ;; +esac" + start=$SECONDS + err="$(PATH="$bin:$PATH" handoff_track_graphite "$repo.feat" parent-branch 2>&1)" || fail "must not fail: $err" + [[ $((SECONDS - start)) -lt 15 ]] || fail "a child gt left behind held the capture for $((SECONDS - start))s" + printf 'PASS: a child gt leaves behind cannot hold the pipes of the caller\n' +} + +test_track_bad_timeout_is_ignored() { + local repo="$TMP_DIR/tr-badt" bin="$TMP_DIR/tr-badt-bin" log="$TMP_DIR/tr-badt.log" value err + _track_fixture "$repo" + for value in abc 1.5 30s 0 ''; do + _fake_gt "$bin" "$log" "case \"\$args\" in *' parent --no-interactive '*) exit 1 ;; esac" + err="$( (set -e; PATH="$bin:$PATH" HANDOFF_GT_TIMEOUT="$value" handoff_track_graphite "$repo.feat"; printf 'still-here\n') 2>&1)" \ + || fail "HANDOFF_GT_TIMEOUT=$value aborted: $err" + printf '%s' "$err" | grep -q 'still-here' || fail "HANDOFF_GT_TIMEOUT=$value: $err" + grep -q ' track --parent master ' "$log" || fail "HANDOFF_GT_TIMEOUT=$value skipped gt: $(cat "$log")" + done + printf 'PASS: a bad HANDOFF_GT_TIMEOUT falls back to the default\n' +} + +test_track_failure_warns_and_continues() { + local repo="$TMP_DIR/tr-fail" bin="$TMP_DIR/tr-fail-bin" log="$TMP_DIR/tr-fail.log" err rc=0 + _track_fixture "$repo" + _fake_gt "$bin" "$log" 'exit 1' + err="$( (set -e; PATH="$bin:$PATH" handoff_track_graphite "$repo.feat" parent-branch; printf 'still-here\n') 2>&1)" || rc=$? + [[ "$rc" -eq 0 ]] || fail "a failed gt must not abort: rc=$rc $err" + printf '%s' "$err" | grep -q 'still-here' || fail "must keep going: $err" + printf '%s' "$err" | grep -q 'gt track' || fail "must warn with what to run: $err" + grep -q -- '--force' "$log" && fail "--force must never run: $(cat "$log")" + printf 'PASS: gt track failing warns and returns\n' +} + +test_track_timeout_is_bounded_and_kills_the_group() { + local repo="$TMP_DIR/tr-hang" bin="$TMP_DIR/tr-hang-bin" log="$TMP_DIR/tr-hang.log" err start body + _track_fixture "$repo" + # The sleep is a grandchild holding stderr: $(...) below only returns once it is dead. + for body in \ + 'sleep 300 & wait' \ + 'trap "" TERM; sleep 300 & wait; sleep 300' \ + 'case "$args" in *" parent "*) exit 1 ;; esac; sleep 300 & wait'; do + _fake_gt "$bin" "$log" "$body" + start=$SECONDS + err="$(PATH="$bin:$PATH" HANDOFF_GT_TIMEOUT=1 handoff_track_graphite "$repo.feat" parent-branch 2>&1)" \ + || fail "a hung gt must not abort: $err" + [[ $((SECONDS - start)) -lt 15 ]] || fail "hung gt held the spawn for $((SECONDS - start))s: $body" + printf '%s' "$err" | grep -q 'did not finish' || fail "must warn that gt hung: $err" + printf '%s' "$err" | grep -qE '\[[0-9]+\]' && fail "job notices on stderr: $err" + [[ "$(grep -c ' track ' "$log")" -le 1 ]] || fail "no more gt once one has hung: $(cat "$log")" + done + printf 'PASS: a hung gt is killed with its children, warned about, and not retried\n' +} + +# Spawns usually run from a terminal, where job control has a foreground to manage. +test_track_timeout_under_a_terminal() { + command -v script >/dev/null 2>&1 && script -qec true /dev/null >/dev/null 2>&1 || { + printf 'SKIP: no util-linux script for a pty\n' + return 0 + } + local repo="$TMP_DIR/tr-pty" bin="$TMP_DIR/tr-pty-bin" log="$TMP_DIR/tr-pty.log" run="$TMP_DIR/tr-pty.sh" out rc=0 + _track_fixture "$repo" + _fake_gt "$bin" "$log" 'sleep 300 & wait' + cat >"$run" </dev/null 2>&1 && script -qec true /dev/null >/dev/null 2>&1 || { + printf 'SKIP: no util-linux script for a pty\n' + return 0 + } + local repo="$TMP_DIR/tr-int" bin="$TMP_DIR/tr-int-bin" log="$TMP_DIR/tr-int.log" run="$TMP_DIR/tr-int.sh" pids="$TMP_DIR/tr-int.pid" pid + _track_fixture "$repo" + _fake_gt "$bin" "$log" "sleep 300 & echo \$! >'$pids'; wait" + cat >"$run" <"$TMP_DIR/tr-int-carried-on" +EOF + # Not handoff_bounded: its background job does not read this pipe. util-linux has timeout. + (sleep 1; printf '\003'; sleep 1) | timeout 15 script -qec "bash $run" /dev/null >/dev/null 2>&1 || true + [[ -s "$pids" ]] || fail "the fake gt never ran: $(cat "$log")" + pid="$(<"$pids")" + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" + fail "Ctrl-C left gt running with nothing to bound it" + fi + [[ "$(grep -c . "$log")" -eq 1 ]] || fail "Ctrl-C must stop the gt calls: $(cat "$log")" + [[ ! -e "$TMP_DIR/tr-int-carried-on" ]] || fail "Ctrl-C must stop the spawn" + printf 'PASS: Ctrl-C during a hung gt kills it and stops the spawn\n' +} + +test_track_output_stays_off_stdout() { + local repo="$TMP_DIR/tr-out" bin="$TMP_DIR/tr-out-bin" log="$TMP_DIR/tr-out.log" out + _track_fixture "$repo" + _fake_gt "$bin" "$log" "echo chatter; case \"\$args\" in *' parent --no-interactive '*) exit 1 ;; esac" + out="$(PATH="$bin:$PATH" handoff_track_graphite "$repo.feat" 2>/dev/null)" + [[ -z "$out" ]] || fail "spawn's stdout is the JSON result, got: $out" + printf 'PASS: gt output never reaches the JSON on stdout\n' +} + +test_track_sigterm_kills_gt() { + command -v setsid >/dev/null 2>&1 || { + printf 'SKIP: no setsid for the SIGTERM process-group test\n' + return 0 + } + local repo="$TMP_DIR/tr-term" bin="$TMP_DIR/tr-term-bin" log="$TMP_DIR/tr-term.log" + local run="$TMP_DIR/tr-term.sh" ready="$TMP_DIR/tr-term.ready" pidfile="$TMP_DIR/tr-term.pid" + local runner group pid attempt + _track_fixture "$repo" + _fake_gt "$bin" "$log" "echo \$\$ >'$pidfile'; exec sleep 300" + cat >"$run" <"$ready" +PATH="$bin:\$PATH" HANDOFF_GT_TIMEOUT=1 handoff_track_graphite "$repo.feat" +EOF + # The caller and watchdog share this new group; gt has its own. + setsid bash "$run" >"$TMP_DIR/tr-term.out" 2>&1 & + runner=$! + for ((attempt=0; attempt<50; attempt++)); do + [[ ! -s "$ready" || ! -s "$pidfile" ]] || break + sleep 0.1 + done + if [[ ! -s "$ready" || ! -s "$pidfile" ]]; then + kill -TERM "$runner" 2>/dev/null || true + wait "$runner" 2>/dev/null || true + fail "the SIGTERM fixture did not start: $(cat "$TMP_DIR/tr-term.out")" + fi + group="$(<"$ready")" + pid="$(<"$pidfile")" + kill -TERM -- "-$group" + wait "$runner" 2>/dev/null || true + for ((attempt=0; attempt<30; attempt++)); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.1 + done + if kill -0 "$pid" 2>/dev/null; then + kill -KILL -- "-$pid" 2>/dev/null || true + fail "SIGTERM killed the watchdog but left gt running" + fi + printf 'PASS: SIGTERM to the spawn process group also kills gt\n' +} + +test_track_trunk_from_trunks_list() { + local repo="$TMP_DIR/tr-list" bin="$TMP_DIR/tr-list-bin" log="$TMP_DIR/tr-list.log" + _track_fixture "$repo" + printf '{"trunks":[{"name":"develop"}]}\n' >"$(git_dir "$repo")/.graphite_repo_config" + _fake_gt "$bin" "$log" "case \"\$args\" in *' parent --no-interactive '*) exit 1 ;; esac" + PATH="$bin:$PATH" handoff_track_graphite "$repo.feat" + grep -qxF -- "--cwd $repo.feat track --parent develop --no-interactive" "$log" \ + || fail "a config with only trunks[] names trunk; log=$(cat "$log")" + printf 'PASS: trunk is read from trunks[] when trunk is absent\n' +} + +test_track_on_without_config_warns() { + local repo="$TMP_DIR/tr-nocfg" bin="$TMP_DIR/tr-nocfg-bin" log="$TMP_DIR/tr-nocfg.log" err + git_init "$repo" + _fake_gt "$bin" "$log" 'exit 0' + err="$(PATH="$bin:$PATH" handoff_track_graphite "$repo" 2>&1)" || fail "must not fail: $err" + [[ ! -s "$log" ]] || fail "gt auto-inits on any command; never run it here: $(cat "$log")" + printf '%s' "$err" | grep -q 'gt init' || fail "should say to run gt init: $err" + printf 'PASS: Graphite forced on with no gt config warns instead of letting gt auto-init\n' +} + +# A whole --branch spawn with stubs for everything but git and the gt under test. +_spawn_branch() { + local name="$1" repo="$2" bin="$3" home="$4" base="${5:-}" + mkdir -p "$home/.config/worktrunk" + cat >"$bin/wt" <<'WT' +#!/usr/bin/env bash +branch="" base=HEAD +while [[ $# -gt 0 ]]; do + case "$1" in + --create) branch="$2"; shift 2 ;; + --base) base="$2"; shift 2 ;; + *) shift ;; + esac +done +main="$(git worktree list --porcelain | awk '/^worktree / { print substr($0, 10); exit }')" +git -C "$main" worktree add -q -b "$branch" "$main.$branch" "$base" +WT + cat >"$bin/herdr-app" <<'HERDR' +#!/bin/sh +printf '%s\n' '{"space":"sp1"}' +HERDR + printf '#!/bin/sh\nexit 0\n' >"$bin/claude" + chmod +x "$bin/wt" "$bin/herdr-app" "$bin/claude" + cat >"$home/.config/worktrunk/herdr-layout.sh" <"$TMP_DIR/$name-desk-started"; } +_wt_herdr_space_by_folder() { printf 'sp1'; } +_wt_herdr_space_desk() { printf 'desk1'; } +HELPER + _bd_stub + : >"$BD_COMMENTS" + ( + cd "$repo" + HOME="$home" PATH="$bin:$PATH" HERDR_APP_BIN="$bin/herdr-app" HANDOFF_GT_TIMEOUT=1 \ + XDG_STATE_HOME="$TMP_DIR/$name-state" BERCAIL_BIN="$TMP_DIR/no-bercail" \ + "$ROOT/skills/handoff/scripts/handoff-spawn" \ + --branch feat --stage start --issue "$ISSUE" --clean ${base:+--base "$base"} + ) +} + +test_spawn_in_a_plain_repo_with_a_stray_config() { + local repo="$TMP_DIR/sp-stray" bin="$TMP_DIR/sp-stray-bin" log="$TMP_DIR/sp-stray.log" out + git_init "$repo" + graphite_auto_init "$repo" + _fake_gt "$bin" "$log" 'exit 1' + out="$(_spawn_branch sp-stray "$repo" "$bin" "$TMP_DIR/sp-stray-home")" || fail "spawn failed: $out" + printf '%s' "$out" | jq -e '.graphite == false and .agent_started == true' >/dev/null || fail "spawn json: $out" + [[ ! -s "$log" ]] || fail "gt must not run in a plain repo: $(cat "$log")" + grep -q 'Use gt' "$BD_COMMENTS" && fail "no gt intro in a plain repo: $(cat "$BD_COMMENTS")" + grep -q 'bercail handoff: stage start' "$BD_COMMENTS" || fail "intro missing: $(cat "$BD_COMMENTS")" + printf 'PASS: a plain repo with a stray config spawns with no gt and no gt intro\n' +} + +test_spawn_survives_a_hung_gt() { + local repo="$TMP_DIR/sp-hang" bin="$TMP_DIR/sp-hang-bin" log="$TMP_DIR/sp-hang.log" out + git_init "$repo" + graphite_auto_init "$repo" + git -C "$repo" update-ref refs/branch-metadata/other HEAD + _fake_gt "$bin" "$log" 'sleep 300 & wait' + out="$(_spawn_branch sp-hang "$repo" "$bin" "$TMP_DIR/sp-hang-home" 2>"$TMP_DIR/sp-hang.err")" \ + || fail "a hung gt must not abort the spawn: $out $(cat "$TMP_DIR/sp-hang.err")" + printf '%s' "$out" | jq -e '.graphite == true and .agent_started == true' >/dev/null || fail "spawn json: $out" + [[ -f "$TMP_DIR/sp-hang-desk-started" ]] || fail "the desk must still start" + grep -q 'did not finish' "$TMP_DIR/sp-hang.err" || fail "warn: $(cat "$TMP_DIR/sp-hang.err")" + grep -q 'Use gt' "$BD_COMMENTS" || fail "a real Graphite repo keeps the gt intro: $(cat "$BD_COMMENTS")" + printf 'PASS: a hung gt warns and the desk still starts\n' +} + +test_spawn_without_sqlite_skips_gt() { + local repo="$TMP_DIR/sp-nosql" bin="$TMP_DIR/sp-nosql-bin" log="$TMP_DIR/sp-nosql.log" out + git_init "$repo" + graphite_auto_init "$repo" + : >"$(git_dir "$repo")/.graphite_metadata.db" + _fake_gt "$bin" "$log" 'exit 1' + out="$(HANDOFF_SQLITE3_BIN="$TMP_DIR/no-sqlite3" \ + _spawn_branch sp-nosql "$repo" "$bin" "$TMP_DIR/sp-nosql-home" 2>"$TMP_DIR/sp-nosql.err")" \ + || fail "spawn failed: $out" + printf '%s' "$out" | jq -e '.graphite == false and .agent_started == true' >/dev/null || fail "spawn json: $out" + [[ ! -s "$log" ]] || fail "gt must not run when detection is inconclusive: $(cat "$log")" + grep -q 'Use gt' "$BD_COMMENTS" && fail "no gt intro without evidence of Graphite" + grep -q 'cannot read' "$TMP_DIR/sp-nosql.err" || fail "warn about sqlite3: $(cat "$TMP_DIR/sp-nosql.err")" + printf 'PASS: missing sqlite3 cannot turn a plain repo into Graphite or block the desk\n' +} + +test_spawn_survives_a_failed_gt() { + local repo="$TMP_DIR/sp-fail" bin="$TMP_DIR/sp-fail-bin" log="$TMP_DIR/sp-fail.log" out + git_init "$repo" + graphite_auto_init "$repo" + git -C "$repo" update-ref refs/branch-metadata/other HEAD + _fake_gt "$bin" "$log" 'exit 1' + out="$(_spawn_branch sp-fail "$repo" "$bin" "$TMP_DIR/sp-fail-home" 2>"$TMP_DIR/sp-fail.err")" \ + || fail "a failed gt must not abort the spawn: $out" + printf '%s' "$out" | jq -e '.graphite == true and .agent_started == true' >/dev/null || fail "spawn json: $out" + [[ -f "$TMP_DIR/sp-fail-desk-started" ]] || fail "the desk must still start" + grep -q 'gt failed' "$TMP_DIR/sp-fail.err" || fail "warn: $(cat "$TMP_DIR/sp-fail.err")" + printf 'PASS: a failed gt warns and the desk still starts\n' +} + +test_spawn_real_graphite_tracks_trunk() { + local repo="$TMP_DIR/sp-real" bin="$TMP_DIR/sp-real-bin" log="$TMP_DIR/sp-real.log" out + git_init "$repo" + graphite_auto_init "$repo" + git -C "$repo" update-ref refs/branch-metadata/other HEAD + _fake_gt "$bin" "$log" "case \"\$args\" in *' parent --no-interactive '*) exit 1 ;; esac" + out="$(_spawn_branch sp-real "$repo" "$bin" "$TMP_DIR/sp-real-home" 2>/dev/null)" || fail "spawn failed: $out" + printf '%s' "$out" | jq -e '.graphite == true and .agent_started == true' >/dev/null || fail "spawn json: $out" + grep -qxF -- "--cwd $repo.feat track --parent master --no-interactive" "$log" \ + || fail "a real stack still gets gt track on trunk; log=$(cat "$log")" + grep -c ' track ' "$log" | grep -qx 1 || fail "one track call is enough: $(cat "$log")" + grep -q 'Use gt' "$BD_COMMENTS" || fail "a real Graphite repo keeps the gt intro: $(cat "$BD_COMMENTS")" + printf 'PASS: a real Graphite repo spawns with gt track and the gt intro, as before\n' +} + +test_spawn_real_stack_tracks_on_its_base() { + local repo="$TMP_DIR/sp-stack" bin="$TMP_DIR/sp-stack-bin" log="$TMP_DIR/sp-stack.log" out + git_init "$repo" + graphite_auto_init "$repo" + git -C "$repo" branch stack-one + git -C "$repo" update-ref refs/branch-metadata/stack-one HEAD + _fake_gt "$bin" "$log" "case \"\$args\" in *' parent --no-interactive '*) exit 1 ;; esac" + out="$(_spawn_branch sp-stack "$repo" "$bin" "$TMP_DIR/sp-stack-home" stack-one 2>/dev/null)" || fail "spawn failed: $out" + printf '%s' "$out" | jq -e '.graphite == true and .agent_started == true and .base == "stack-one"' >/dev/null || fail "spawn json: $out" + [[ "$(grep ' track ' "$log")" == "--cwd $repo.feat track --parent stack-one --no-interactive" ]] \ + || fail "a stack gets exactly one gt track, on its base; log=$(cat "$log")" + grep -q 'Use gt' "$BD_COMMENTS" || fail "a real Graphite repo keeps the gt intro: $(cat "$BD_COMMENTS")" + printf 'PASS: a real stack spawned with --base is tracked on that base, as before\n' +} + +test_track_already_tracked_does_nothing +test_track_tracks_an_untracked_parent_first +test_track_falls_back_to_trunk_with_a_warning +test_track_leaves_a_tracked_parent_in_its_stack +test_track_transient_failure_never_touches_the_parent +test_track_child_left_behind_does_not_hold_the_caller +test_track_bad_timeout_is_ignored +test_track_ctrl_c_kills_gt +test_track_sigterm_kills_gt +test_track_failure_warns_and_continues +test_track_timeout_is_bounded_and_kills_the_group +test_track_timeout_under_a_terminal +test_track_output_stays_off_stdout +test_track_trunk_from_trunks_list +test_track_on_without_config_warns +test_spawn_in_a_plain_repo_with_a_stray_config +test_spawn_survives_a_hung_gt +test_spawn_without_sqlite_skips_gt +test_spawn_survives_a_failed_gt +test_spawn_real_graphite_tracks_trunk +test_spawn_real_stack_tracks_on_its_base