diff --git a/bstack/commands/ready.md b/bstack/commands/ready.md index f8f0036..d6bda8e 100644 --- a/bstack/commands/ready.md +++ b/bstack/commands/ready.md @@ -11,7 +11,7 @@ Verify skill: !`ls "$(git rev-parse --show-toplevel 2>/dev/null)"/.agents/skills Fix what fails at each step, then rerun that step. Note every command, its exit code, and where its evidence is. 1. Re-read the job (the beads issue when there is one) and check each part of it is done. -2. Run the tests that cover the diff. +2. Run the tests that cover the diff. Read `git` (`gh` | `gt` | `origin`) from `handoff-spawn --info`; dependent branches must use that backend's stack commands (`gh stack init/add/submit/sync`, `gt create/track/submit/get`, or `origin stack track/get/restack/push` and `origin pr create --stack-on`). Never run `gt` in a `gh` repo or hand-make a PR merely based on another PR's branch. This readiness check does not authorize submitting, pushing, or merging. 3. When the verify skill above is not `none`, read it and follow it from launch to cleanup. 4. Run any formal check whose coverage the diff touches: a `statechart` spec in `.bercail/statechart/` or committed in the repo. When none covers it, say so. 5. When a beads issue names the job, ask `bstack-judge` one question, giving it the issue and the diff: does the diff match the issue? It names what is missing or was never asked for, and it changes nothing. diff --git a/bstack/instructions.md b/bstack/instructions.md index 318c10e..bf26071 100644 --- a/bstack/instructions.md +++ b/bstack/instructions.md @@ -22,6 +22,13 @@ that is strongest at it. These rules apply to every bstack agent and subagent. options, wrappers, or compatibility shims nobody asked for. - Fix the root cause. A guard is a fix only when the state it guards is legitimate. - Get the data shape right first; the code that follows should be obvious. +- Read the repo's git backend from `handoff-spawn --info` (`git`: `gh`, `gt`, + or `origin`). Dependent branches use that backend's stack commands: + `gh stack init/add/submit/sync`, `gt create/track/submit/get`, or + `origin stack track/get/restack/push` with `origin pr create --stack-on`. + Never hand-make a PR merely based on another PR's branch, and never run `gt` + in a `gh` or `origin` repo. Stage only unless asked to publish; autopilot + may commit and push its PR branch, never merge. - Match the code around you: naming, comment density, error handling, test style. - For library docs, error messages, and anything newer than you, search the web (`websearch`, `webfetch`) instead of guessing. diff --git a/config/agentic-dev/config.toml.example b/config/agentic-dev/config.toml.example index 48e607a..d5c5d60 100644 --- a/config/agentic-dev/config.toml.example +++ b/config/agentic-dev/config.toml.example @@ -32,8 +32,11 @@ judge = "claude" # works when off: the lead reviews alone. council = false -# [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 +# [git] +# Git backend per repo: gh (git + GitHub CLI), gt (Graphite), or origin (Cursor +# Origin). Keys are main-checkout paths; linked worktrees share that choice. +# Without an override: real Graphite tracking, then Cursor Origin, else gh. +# "~/Work/some-repo" = "gh" +# "~/Work/graphite-repo" = "gt" +# "~/Work/origin-repo" = "origin" +# Legacy [graphite] true/false still maps to gt/gh; [git] takes precedence. diff --git a/docs/config.md b/docs/config.md index 8bc3758..9528c1c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -20,11 +20,11 @@ lead = "claude" build = "grok" judge = "claude" -[graphite] # optional, one line per repo; see below -"~/Work/some-repo" = false +[git] # optional, one line per main checkout +"~/Work/some-repo" = "gh" # gh | gt | origin ``` -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). +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); `[git]` in [handoff](handoff.md#git-backend). Legacy `[graphite]` booleans still work (`true` → `gt`, `false` → `gh`), but `[git]` takes precedence. ## Files bercail manages diff --git a/docs/handoff.md b/docs/handoff.md index 14ab91c..1c11a5e 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](#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. The [git backend](#git-backend) registers that parent with `gh stack`, `gt track`, or `origin stack track`. 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.`` @@ -40,9 +40,47 @@ bd create "Fix the login timeout" --description "..." # Shep (the Grok bot) 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 +## Git backend -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`. +`handoff-spawn --info` reports `"git": "gh"`, `"gt"`, or `"origin"`. +Use that backend throughout the job. `graphite` remains for one release, +derived as `git == "gt"`, so existing callers keep working. + +Detection order is: a per-repo `[git]` override, a legacy `[graphite]` override, +real Graphite tracking, a Cursor Origin repo, then `gh`. Keys are main-checkout +paths; linked worktrees share the choice. New overrides use: + +```toml +[git] +"~/Work/bercail" = "gh" +"~/Work/stacked-repo" = "gt" +"~/Work/cursor-repo" = "origin" +``` + +`[git]` takes precedence over `[graphite]`, whose `true`/`false` still maps to +`gt`/`gh`. Detection never runs `gt`. + +Origin detection first checks the remote's exact host and repo path +(`origin.cursor.com` or `origin-.cursor.com`). Only those repos +get a bounded `origin repo view --json mirrorStatus` lookup: `no-mirror` and +`outbound` select Origin; other reported statuses select GitHub. A missing CLI +or failed lookup warns and falls back to `gh`; set `[git]` to settle an unknown +result before publishing. Ordinary GitHub repos make no Origin API call. + +| Backend | Dependent branches and publishing | +|---------|-----------------------------------| +| `gh` | `gh stack init` adopts existing parent/child branches; `gh stack add` extends a stack. Publish with `gh stack submit`; update with `gh stack sync`. | +| `gt` | `gt create` or `gt track --parent` records branches; `gt submit` publishes and `gt get` fetches stack updates. | +| `origin` | `origin stack track --onto` records local parents. Publish child PRs with `origin pr create --stack-on --status open`; update with `origin stack get`, `origin stack restack`, and `origin stack push --all`. | + +Never hand-make a dependent PR that merely targets another PR's branch, and +never run `gt` in a `gh` repo. Handoff stages work without committing or +publishing; these commands do not authorize a push. Autopilot may commit and +push to its PR branch, never merge. + +### 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`. 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. diff --git a/docs/review.md b/docs/review.md index e18706e..46b1e1b 100644 --- a/docs/review.md +++ b/docs/review.md @@ -18,13 +18,14 @@ The code in a teammate's PR was likely written by an agent, and review bots alre bercail review-pr https://github.com/OWNER/REPO/pull/123 ``` -1. **Discover and gather**: Graphite-initialized repos use `gt` in an isolated clone. Other repos and cross-repo fork entries walk GitHub PR bases down to trunk and children up to the top. Calling with any PR in the stack resolves the same bottom (root) PR. PR bodies, closing issues, Linear ids (only noted), pinned commit messages, diffs, and `gh pr checks` are gathered. Checks are kept even when gh exits non-zero. Artifacts live in `${XDG_STATE_HOME:-~/.local/state}/bercail/reviews////stack-/`, with one `pr-//` per PR head. `stack.json` records order, parent SHAs and the current PR; `stack.md` gives the stack's order, bases and intent. Each PR's diff and commit range are against its parent, not trunk. +1. **Discover and gather**: The repo's `git` backend selects discovery: `gt` keeps the Graphite walk in an isolated clone; `gh` uses `gh stack` for a registered stack, otherwise walks GitHub PR bases down to trunk and children up to the top; `origin` uses `origin stack` and `origin pr`. A stray Graphite config alone never selects `gt`. Calling with any PR in the stack resolves the same bottom (root) PR. PR bodies, closing issues, Linear ids (only noted), pinned commit messages, diffs, and `gh pr checks` are gathered. Checks are kept even when gh exits non-zero. Artifacts live in `${XDG_STATE_HOME:-~/.local/state}/bercail/reviews////stack-/`, with one `pr-//` per PR head. `stack.json` records order, parent SHAs and the current PR; `stack.md` gives the stack's order, bases and intent. Each PR's diff and commit range are against its parent, not trunk. 2. **Worktree and space**: one detached sibling worktree, such as `~/Work/leftlane_app.review-123`, starts at the root PR. Bercail uses its normal worktree layout helper, so Herdr groups the desk under the reviewed repo rather than creating an unrelated state-directory review space. The main checkout's branch, files, config and other worktrees stay untouched. Graphite synchronization happens only in an isolated clone, never in your checkout. 3. **Thin check**: an empty body, a body that only restates the title, no linked issue (GitHub or Linear), or only "part N of M". The printed JSON lists flags and pass status **per PR**. Thin PRs wait; the rest start immediately. Shep asks only about flagged PRs. `bercail review-pr run N --stack DIR` explicitly reviews one, including a thin PR. `--no-run` gathers without starting any passes. 4. **The decisions passes**: with Herdr open, one read-only `bstack-pr-review` session produces the cards on the review space's desk. PR passes queue for that session instead of opening independent agents. Bercail never passes `--model`. The session runs in the stack dir, outside the reviewed repo, with immutable, SHA-pinned file context, so moving the worktree cannot change what it reviews. Its permissions allow only read, glob and grep. No bug hunting, style, nits, or tests. Validated output becomes `review.json` (`bercail.review/v1`). `run.json` records `ready`, `blocked`, or `failed` with the desk's session id. Per-PR locks prevent duplicate passes. Gathering cannot replace inputs during a live pass. Without Herdr, CLI passes still run headlessly in parallel. 5. **The cards**: the Stack tab is bercail's `bercail-review` Herdr mod. The UI needs the Herdr app. Without it `bercail review-pr ui` fails and says so. Each picker row shows the PR title, one truncated code-inferred intent line, then pass status and CI. There is no second PR list. The card has distinct section headings for Inferred intent, Clue comparison, Decisions, Unmet, Questions, Bots and CI. Intent shows confidence, evidence and whether it looks like the right thing to build. Each clue shows its source, alignment and comparison. Each decision has separate labeled lines for Kind, Summary, Evidence, Stated, Fit and Alternative. File references and code identifiers use compact, colored monospace chips. Long paths show their basename and line, with the full path on hover. The mod reads `bercail review-pr state` and reloads when the run dir changes. `bercail review-pr ui` shows the tab, or makes it. 6. **Each PR's actions**, by key or by their buttons on the card (**j**/**k** pick a PR): - **c Code review**: a `Review #N` tab on `tuicr pr ` in the review space (`bercail review-pr open N`). Each question goes in as a draft note by `bstack`, starting `[AI Agent] `, on the decision's line (the whole PR when it has none). Edit, delete, add your own, then `:submit`. One tuicr per review space: the Review tab is made fresh each time (drafts stay in tuicr's session). + - For Origin, **c Code review** opens `origin pr view --web` instead. The installed tuicr PR mode does not support Origin, so local review cards are not seeded into its browser review. - **r Re-review**: gather the stack again (heads, CI, bodies), then run this PR's decisions pass again, even if it was ready (`bercail review-pr rereview N`). - **o Shell**: the review worktree on the PR's branch and a `Shell #N` tab in it (`bercail review-pr checkout N`). A missing local branch is made at the PR head. Bercail never moves a branch of yours: when yours is elsewhere, or checked out in another worktree, the worktree goes to the head detached and the tab says why. @@ -42,7 +43,7 @@ When every current card is ready, that same session writes a stack summary: over Agents never post. You do, from tuicr's `:submit` or GitHub, and every draft you keep goes out as yours, labelled `[AI Agent]`. In opencode, `/pr-review ` runs the same pass by hand. -If no checkout is found, bercail clones to `~/Work/` (or `~/Work/-` if the first path belongs to something else), then creates its sibling review worktree. It never overwrites an existing checkout. A newly cloned repo uses the generic GitHub walk because it has no local Graphite configuration. +If no checkout is found, bercail clones to `~/Work/` (or `~/Work/-` if the first path belongs to something else), then creates its sibling review worktree. It never overwrites an existing checkout. A newly cloned GitHub repo without stack metadata uses the generic GitHub walk; a Cursor Origin repo uses Origin's stack discovery. If a process is killed during lock recovery, an empty `*.lock.reap` directory can remain and block retries. After confirming no gather, pass or open process is using that stack, remove that empty directory with `rmdir`. Do not remove a live process's lock. diff --git a/lib/config.sh b/lib/config.sh index 6030621..974b510 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -79,6 +79,7 @@ read_layout_review() { fi } +# shellcheck disable=SC2034 # Read by the installer that sources this library. RECONFIGURE=0 # The Herdr app half of bercail: the bercail layout, its mods (the desk, and the @@ -377,6 +378,8 @@ deploy_lib() { deploy_install_file "lib/$libfile" "${AGENTIC_DEV_SHARE_DIR}/lib/$libfile" done fi + # Installed lib/ has no adjacent skills tree. + deploy_install_file "skills/handoff/scripts/git-backend.sh" "${AGENTIC_DEV_SHARE_DIR}/lib/git-backend.sh" } _recorded_install_source() { diff --git a/lib/git-backend.sh b/lib/git-backend.sh new file mode 120000 index 0000000..4451b31 --- /dev/null +++ b/lib/git-backend.sh @@ -0,0 +1 @@ +../skills/handoff/scripts/git-backend.sh \ No newline at end of file diff --git a/lib/review.sh b/lib/review.sh index 6270b19..04e1f49 100644 --- a/lib/review.sh +++ b/lib/review.sh @@ -128,11 +128,16 @@ _review_herdr() { # --- source checkout ---------------------------------------------------------- _review_origin_matches() { - local url + local url host="${4:-}" url="$(git -C "$1" remote get-url origin 2>/dev/null)" || return 1 url="${url%/}" url="${url%.git}" - [[ "${url,,}" == *[/:]"${2,,}/${3,,}" ]] + [[ "${url,,}" == *[/:]"${2,,}/${3,,}" ]] || return 1 + if [[ "$host" == origin.cursor.com ]]; then + git_backend_origin_remote "$1" + else + ! git_backend_origin_remote "$1" + fi } # Written only into a checkout this command created, never into the user's repo. @@ -143,7 +148,7 @@ _review_marker_matches() { } _review_is_repo() { - _review_origin_matches "$1" "$3" "$4" || _review_marker_matches "$1" "$2" "$3" "$4" + _review_origin_matches "$1" "$3" "$4" "$2" || _review_marker_matches "$1" "$2" "$3" "$4" } _review_main_of() { @@ -196,8 +201,11 @@ _review_fallback_checkout() { fi if [[ ! -e "$dest/.git" ]]; then mkdir -p "$(dirname "$dest")" - gh repo clone "$slug" "$dest" -- --quiet >&2 \ - || die "could not clone $slug to $dest" + if [[ "${R_BACKEND:-gh}" == origin ]]; then + origin repo clone "$owner/$repo" "$dest" >&2 || die "could not clone $slug to $dest" + else + gh repo clone "$slug" "$dest" -- --quiet >&2 || die "could not clone $slug to $dest" + fi printf '%s\n' "$slug" > "$(git -C "$dest" rev-parse --path-format=absolute --git-common-dir)/bercail-review-repo" fi _review_main_of "$dest" @@ -223,7 +231,11 @@ _review_fetch_objects() { sha="$2" shift 2 if ! git -C "$main" cat-file -e "$sha^{commit}" 2>/dev/null; then - specs+=("refs/pull/$n/head") + if [[ "${R_BACKEND:-gh}" == origin ]]; then + specs+=("$sha") + else + specs+=("refs/pull/$n/head") + fi missing+=("$n" "$sha") fi done @@ -352,8 +364,34 @@ _review_snapshot() { # gh pr checks exits non-zero while checks are pending or failing (and when # there are none) but still prints the JSON. Without JSON, the rollup from gh # pr view stands in, in the same shape. +_review_pr_view() { + local n="$1" slug="$2" + if [[ "${R_BACKEND:-gh}" == origin ]]; then + local raw + raw="$(origin pr view "$n" -R "${slug#*/}" --json number,title,body,url,author,isDraft,baseRefName,headRefName,headRefOid,baseRefOid,files)" || return 1 + jq '. + {isCrossRepository:false, closingIssuesReferences:[], statusCheckRollup:[]} + | .author = (if (.author | type) == "object" then .author + {login:(.author.login // .author.displayName // .author.id // "")} + else {login:(.author // "")} end) + | .files = [.files[]? | . + {changeType:(.changeKind // "modified")} ]' <<<"$raw" + else + gh pr view "$n" -R "$slug" --json "$REVIEW_PR_FIELDS" + fi +} + _review_ci() { local n="$1" slug="$2" dir="$3" raw + if [[ "${R_BACKEND:-gh}" == origin ]]; then + raw="$(origin pr checks "$n" -R "${slug#*/}" --json name,status,conclusion,detailsUrl 2>/dev/null)" || true + if ! jq -e 'type == "array"' <<<"$raw" >/dev/null 2>&1; then + printf 'could not read Origin checks for PR #%s\n' "$n" >&2 + return 1 + fi + jq '[.[] | ((.conclusion // .status // "") | ascii_upcase) as $s | + {name, state:$s, workflow:"", link:(.detailsUrl // ""), + bucket:(if $s == "SUCCESS" then "pass" elif ($s | IN("FAILURE","FAILING","ERROR","TIMED_OUT","ACTION_REQUIRED")) then "fail" + elif $s == "CANCELLED" then "cancel" elif ($s | IN("SKIPPED","NEUTRAL")) then "skipping" else "pending" end)}]' <<<"$raw" | _review_write "$dir/ci.json" + return + fi raw="$(gh pr checks "$n" -R "$slug" --json name,bucket,state,workflow,link 2>/dev/null)" || true if jq -e 'type == "array"' <<<"$raw" >/dev/null 2>&1; then printf '%s\n' "$raw" | _review_write "$dir/ci.json" @@ -497,6 +535,7 @@ _review_load() { R_DIR="$R_STACK/pr-$R_N/$R_SHA" [[ -d "$R_DIR" ]] || die "no run dir for PR #$R_N at $R_SHA" R_WT="$(jq -r '.worktree' "$R_STACK/stack.json")" + R_BACKEND="$(jq -r '.git // (if .via == "origin" then "origin" elif .via == "graphite" then "gt" else "gh" end)' "$R_STACK/stack.json")" } _review_stack_get() { @@ -564,6 +603,15 @@ _review_space() { _review_parse_url() { local url="$1" + if [[ "$url" =~ ^https?://review\.cursor\.com/pr/([^/]+)/([^/]+)/([0-9]+) ]]; then + R_HOST=origin.cursor.com R_OWNER="${BASH_REMATCH[1]}" R_REPO="${BASH_REMATCH[2]}" R_WANT="${BASH_REMATCH[3]}" + R_BACKEND=origin + return + elif [[ "$url" =~ ^https?://cursor\.com/codebase/([^/]+)/([^/]+)/pull/([0-9]+) ]]; then + R_HOST=origin.cursor.com R_OWNER="${BASH_REMATCH[1]}" R_REPO="${BASH_REMATCH[2]}" R_WANT="${BASH_REMATCH[3]}" + R_BACKEND=origin + return + fi [[ "$url" =~ ^https?://([^/]+)/([^/]+)/([^/]+)/pull/([0-9]+) ]] \ || die "not a pull request URL: ${url:-(none)} (https://github.com/OWNER/REPO/pull/N)" R_HOST="${BASH_REMATCH[1]}" @@ -586,7 +634,8 @@ review_start() { esac done [[ -n "$url" || -n "$branch" ]] || die "usage: bercail review-pr [--no-run]" - _review_need gh jq git + _review_need jq git + R_BACKEND=gh t0="$(_review_now)" tmp="$(mktemp -d)" # shellcheck disable=SC2064 @@ -596,7 +645,10 @@ review_start() { git rev-parse --is-inside-work-tree >/dev/null 2>&1 \ || die "pass a PR url, or run review-pr from a checkout of the repo" local listed count - listed="$(gh pr list --head "$branch" --state open --limit 20 --json number,url)" \ + R_BACKEND="$(git_backend "$PWD")" + local pr_cli=gh + [[ "$R_BACKEND" != origin ]] || pr_cli=origin + listed="$("$pr_cli" pr list --head "$branch" --state open --limit 20 --json number,url)" \ || die "gh pr list --head $branch failed" count="$(jq 'length' <<<"$listed")" if [[ "$count" -gt 1 ]]; then @@ -609,23 +661,30 @@ review_start() { host="$R_HOST" owner="$R_OWNER" repo="$R_REPO" n="$R_WANT" slug="$host/$owner/$repo" - gh pr view "$n" -R "$slug" --json "$REVIEW_PR_FIELDS" >"$tmp/pr-$n.json" 2>"$tmp/view.err" \ - || die "gh pr view $n -R $slug: $(<"$tmp/view.err")" source="$(_review_find_source "$host" "$owner" "$repo" || true)" + if [[ "$R_BACKEND" != origin && -n "$source" ]]; then + R_BACKEND="$(git_backend "$source")" + fi + if [[ "$R_BACKEND" == origin ]]; then _review_need origin; else _review_need gh; fi + _review_pr_view "$n" "$slug" >"$tmp/pr-$n.json" 2>"$tmp/view.err" \ + || die "PR view $n -R $slug: $(<"$tmp/view.err")" + if [[ -z "$source" ]]; then + source="$(_review_fallback_checkout "$host" "$owner" "$repo")" || die "no checkout for $slug" + fi local sync sync="$(_review_root)/$host/$owner/$repo/gt-sync" - if [[ -n "$source" ]] && _stack_is_graphite "$source"; then + if [[ "$R_BACKEND" != origin && -n "$source" ]]; then local repo_dir repo_dir="$(_review_root)/$host/$owner/$repo" mkdir -p "$repo_dir" - _review_lock "$repo_dir/gt" || die "graphite sync is busy; try again when it ends" - if ! stack_discover "$slug" "$tmp/pr-$n.json" "$source" "$sync" >"$tmp/order.json"; then - _review_unlock "$repo_dir/gt" - die "could not discover the Graphite stack for PR #$n" + _review_lock "$repo_dir/$R_BACKEND" || die "$R_BACKEND stack discovery is busy; try again when it ends" + if ! stack_discover "$slug" "$tmp/pr-$n.json" "$source" "$sync" "$R_BACKEND" >"$tmp/order.json"; then + _review_unlock "$repo_dir/$R_BACKEND" + die "could not discover the $R_BACKEND stack for PR #$n" fi - _review_unlock "$repo_dir/gt" + _review_unlock "$repo_dir/$R_BACKEND" else - stack_discover "$slug" "$tmp/pr-$n.json" "$source" "$sync" >"$tmp/order.json" \ + stack_discover "$slug" "$tmp/pr-$n.json" "$source" "$sync" "$R_BACKEND" >"$tmp/order.json" \ || die "could not discover the stack for PR #$n" fi order="$(<"$tmp/order.json")" @@ -639,9 +698,6 @@ review_start() { root="${numbers[0]}" R_STACK="$(_review_root)/$host/$owner/$repo/stack-$root" - if [[ -z "$source" ]]; then - source="$(_review_fallback_checkout "$host" "$owner" "$repo")" || die "no checkout for $slug" - fi main="$(cd "$source" && pwd -P)" sibling="$(_review_sibling_path "$main" "$root")" @@ -656,7 +712,7 @@ review_start() { local num for num in "${numbers[@]}"; do if [[ ! -s "$tmp/pr-$num.json" ]]; then - gh pr view "$num" -R "$slug" --json "$REVIEW_PR_FIELDS" >"$tmp/pr-$num.json" 2>"$tmp/view-$num.err" & + _review_pr_view "$num" "$slug" >"$tmp/pr-$num.json" 2>"$tmp/view-$num.err" & jobs+=("$!") fi done @@ -724,10 +780,10 @@ review_start() { pr_rows+=("$tmp/row-$num.json") done - jq -n --arg host "$host" --arg owner "$owner" --arg repo "$repo" --arg via "$via" --arg trunk "$trunk" \ + jq -n --arg host "$host" --arg owner "$owner" --arg repo "$repo" --arg via "$via" --arg trunk "$trunk" --arg git "$R_BACKEND" \ --arg wt "$sibling" --arg main "$main" --argjson root "$root" --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --slurpfile prs <(cat "${pr_rows[@]}") \ - '{schema: "bercail.stack/v1", host: $host, owner: $owner, repo: $repo, via: $via, trunk: $trunk, + '{schema: "bercail.stack/v1", host: $host, owner: $owner, repo: $repo, git: $git, via: $via, trunk: $trunk, root: $root, current: $root, worktree: $wt, source: $main, space: null, gathered_at: $at, prs: $prs}' >"$tmp/stack.json" _review_stack_md "$tmp/stack.json" "$trunk" "$via" >"$tmp/stack.md" @@ -1331,6 +1387,15 @@ _review_review_tab() { review_open() { _review_load "$@" + if [[ "$R_BACKEND" == origin ]]; then + # tuicr pr supports GitHub/GitLab, not Origin. Keep this forge-native. + _review_need origin jq + origin pr view "$R_N" -R "$(_review_stack_get .owner)/$(_review_stack_get .repo)" --web >/dev/null \ + || die "could not open Origin PR #$R_N" + jq -n --argjson number "$R_N" --arg url "$(jq -r .url "$R_DIR/pr.json")" --arg context "$R_DIR" \ + '{number:$number, url:$url, context:$context, note:"Origin opened in the browser; review cards remain in the local context, not seeded into tuicr."}' + return + fi _review_need tuicr jq local owner repo space want slug="" i secs payload out id seeded="[]" _review_lock "$R_DIR/open" || die "PR #$R_N is already being opened in tuicr" diff --git a/lib/stack.sh b/lib/stack.sh index 06d8572..cf5a1e5 100644 --- a/lib/stack.sh +++ b/lib/stack.sh @@ -2,7 +2,8 @@ # shellcheck shell=bash # Stack discovery for bercail review-pr. # -# Graphite: when the source checkout's shared git dir has .graphite_repo_config. +# Backend: shared handoff detection chooses gh, gt, or origin. A Graphite +# config alone is not evidence: only tracked branches (or an override) select gt. # Local `gt log` is not the source of truth (it can omit a remote stack). Sync # with `gt get` only in a private clone (its own git dir, never a linked # worktree of the source). Never run gt against the source: even @@ -33,11 +34,10 @@ _stack_strip_ansi() { printf '%s' "$s" | sed $'s/\x1b\\[[0-9;]*[A-Za-z]//g' } -_stack_is_graphite() { - local cfg - cfg="$(git -C "$1" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)/.graphite_repo_config" || return 1 - [[ -f "$cfg" ]] -} +# Source tree: symlink to the skill script. Installed CLI: a standalone copy. +# shellcheck source=lib/git-backend.sh +source "$(dirname "${BASH_SOURCE[0]}")/git-backend.sh" + _stack_trunk_name() { jq -r '.trunk // .trunks[0].name // "main"' "$1" @@ -342,18 +342,120 @@ _stack_github() { # source may be empty (GitHub walk only). sync is the private clone path. # A fork is not fatal: review the named PR alone, in its own run dir. _stack_alone() { - local entry="$1" n + local entry="$1" via="${2:-github}" n n="$(jq -r '.number' "$entry")" printf 'branched stack; reviewing PR #%s alone\n' "$n" >&2 - jq -nc --argjson n "$n" --arg trunk "$(jq -r '.baseRefName' "$entry")" \ - '{via: "github", trunk: $trunk, numbers: [$n]}' + jq -nc --argjson n "$n" --arg via "$via" --arg trunk "$(jq -r '.baseRefName' "$entry")" \ + '{via: $via, trunk: $trunk, numbers: [$n]}' +} + +# Validate a native backend's parent graph before emitting a linear PR order. +_stack_native_order() { + local payload="$1" parent count row branch n total used=0 + # shellcheck disable=SC2034 # nameref targets of _stack_seen_add + local -A seen=() pr_seen=() + parent="$(jq -r '.trunk | sub("^refs/heads/"; "")' <<<"$payload")" + total="$(jq '.members | length' <<<"$payload")" + [[ "$total" -gt 0 && -n "$parent" && "$parent" != null ]] || return 1 + _stack_seen_add seen "$parent" || return 1 + while [[ "$used" -lt "$total" ]]; do + row="$(jq -c --arg parent "$parent" '[.members[] | select((.parent | sub("^refs/heads/"; "")) == $parent)]' <<<"$payload")" || return 1 + count="$(jq length <<<"$row")" + [[ "$count" -le 1 ]] || return 2 + [[ "$count" -eq 1 ]] || { printf 'disconnected or cyclic native stack\n' >&2; return 1; } + branch="$(jq -r '.[0].branch | sub("^refs/heads/"; "")' <<<"$row")" + n="$(jq -r '.[0].number // empty' <<<"$row")" + [[ "$n" =~ ^[1-9][0-9]*$ ]] || { printf 'stack branch %s has no PR\n' "$branch" >&2; return 1; } + _stack_seen_add seen "$branch" || return 1 + _stack_seen_add pr_seen "$n" || return 1 + printf '%s\n' "$n" + parent="$branch" + used=$((used + 1)) + done +} + +# Select by requested PR/head, not whichever stack happens to be checked out. +_stack_gh_stack() { + local source="$1" entry="$2" sync="$3" slug="$4" catalog selected=null branch remote payload common name err rc=0 remote_stack=0 + common="$(git -C "$source" rev-parse --path-format=absolute --git-common-dir)" || return 1 + catalog="$common/gh-stack" + if [[ -f "$catalog" ]]; then + selected="$(jq -c --argjson n "$(jq '.number' "$entry")" --arg head "$(jq -r .headRefName "$entry")" \ + '[.stacks[] | select(any(.branches[]; .pullRequest.number == $n or .branch == $head))] | + if length == 0 then null elif length == 1 then .[0] else error("ambiguous stack membership") end' "$catalog")" || return 1 + fi + err="$(mktemp)" + payload="$(gh api --hostname "${slug%%/*}" "repos/${slug#*/}/stacks?pull_request=$(jq '.number' "$entry")" 2>"$err")" || rc=$? + if [[ "$rc" -ne 0 ]]; then + if grep -q 'HTTP 404' "$err"; then + payload='[]' + else + printf 'GitHub stack discovery failed: %s\n' "$(<"$err")" >&2 + rm -f "$err" + return 1 + fi + fi + rm -f "$err" + jq -e 'type == "array"' <<<"$payload" >/dev/null || return 1 + case "$(jq length <<<"$payload")" in + 0) [[ "$selected" != null ]] || return 3 ;; + 1) remote_stack=1 ;; + *) return 1 ;; + esac + remote="$(git -C "$source" remote get-url origin)" || return 1 + if [[ ! -d "$sync/.git" ]]; then + git clone --quiet "$source" "$sync" >&2 || return 1 + fi + [[ "$(git -C "$sync" rev-parse --path-format=absolute --git-common-dir)" != "$common" ]] || return 1 + git -C "$sync" remote set-url origin "$remote" || return 1 + # This clone is disposable. Detach before moving its branch refs so a repeat + # gather cannot leave its index/worktree at an older branch tip. + git -C "$sync" checkout --quiet --detach --force >&2 || return 1 + if [[ "$remote_stack" -eq 1 ]]; then + # checkout prefers local membership. Discard only this private catalog so + # a previous review cannot hide newly added remote stack members. + rm -f "$sync/.git/gh-stack" + (cd "$sync" && gh stack checkout "$(jq -r '.url' "$entry")" &2) || return 1 + else + cp "$catalog" "$sync/.git/gh-stack" || return 1 + git -C "$sync" fetch --quiet origin >&2 || return 1 + while IFS= read -r name; do + if git -C "$sync" rev-parse --verify "refs/remotes/origin/$name" >/dev/null 2>&1; then + git -C "$sync" update-ref "refs/heads/$name" "refs/remotes/origin/$name" || return 1 + fi + done < <(jq -r '.branches[].branch' <<<"$selected") + branch="$(jq -r '.headRefName' "$entry")" + git -C "$sync" checkout --quiet -B "$branch" "$(jq -r '.headRefOid' "$entry")" >&2 || return 1 + fi + payload="$(cd "$sync" && gh stack view --json /dev/null; then via=graphite trunk="$(_stack_trunk_name "$(git -C "$source" rev-parse --path-format=absolute --git-common-dir)/.graphite_repo_config")" @@ -364,10 +466,22 @@ stack_discover() { printf 'could not tell the trunk branch for %s\n' "$slug" >&2 return 1 } - text="$(_stack_github "$slug" "$entry" "$trunk")" || rc=$? + if [[ -n "$source" ]] && jq -e '.isCrossRepository != true' "$entry" >/dev/null; then + payload="$(_stack_gh_stack "$source" "$entry" "$sync" "$slug")" || rc=$? + else + rc=3 + fi + if [[ "$rc" -eq 3 ]]; then + rc=0 + text="$(_stack_github "$slug" "$entry" "$trunk")" || rc=$? + elif [[ "$rc" -eq 0 ]]; then + via=gh-stack + trunk="$(jq -r .trunk <<<"$payload")" + text="$(_stack_native_order "$payload")" || rc=$? + fi fi if [[ "$rc" -eq 2 ]]; then - _stack_alone "$entry" + if [[ "$backend" == origin ]]; then _stack_alone "$entry" origin; else _stack_alone "$entry"; fi return 0 fi [[ "$rc" -eq 0 ]] || return "$rc" @@ -383,6 +497,10 @@ stack_discover() { fi fi [[ ${#numbers[@]} -gt 0 ]] || return 1 + printf '%s\n' "${numbers[@]}" | grep -qxF "$n" || { + printf 'native stack omitted requested PR #%s\n' "$n" >&2 + return 1 + } jq -nc --arg via "$via" --arg trunk "$trunk" --args \ '{via: $via, trunk: $trunk, numbers: ($ARGS.positional | map(tonumber))}' -- "${numbers[@]}" } diff --git a/skills/autopilot/SKILL.md b/skills/autopilot/SKILL.md index 69a835d..05fcae6 100644 --- a/skills/autopilot/SKILL.md +++ b/skills/autopilot/SKILL.md @@ -16,13 +16,35 @@ the PR, never pushes to the base branch, and never passes `--no-verify`. In opencode, `/autopilot` runs as the `bstack-autopilot` agent, which may commit and push without asking. Other harnesses ask at each push. -## 0. The PR +## 0. The backend and PR + +Read `git` (`gh` | `gt` | `origin`) from the handoff skill's +`scripts/handoff-spawn --info` in this checkout. Never run `gt` to detect it. +Use the same backend for checkout, stack updates, and publication: + +| Backend | Checkout and stack updates | +|---------|----------------------------| +| `gh` | For a stack, `gh stack checkout`, `gh stack sync`, and `gh stack submit`. Independent branches use `gh pr checkout` and `git push`. | +| `gt` | `gt get` to fetch the stack, then `gt submit` to publish fixes. Use `gt create` or `gt track` only when adding a branch. | +| `origin` | `origin stack get`, `origin stack restack`, and `origin stack push --all`. Local parents use `origin stack track --onto`; child PRs use `origin pr create --stack-on`, not just `--base`. | + +Never hand-make a PR merely based on another PR's branch, and never run `gt` +in a `gh` or `origin` repo. Do not rewrite someone else's stack members. If +native sync/restack needs a force-push or an unauthorized branch update, stop +and report it; autopilot's no-force-push and no-merge rules still apply. + +The snapshot helper and GitHub commands below apply to `gh` and `gt` only. +For `origin`, use `origin pr view`, `origin pr checks`, `origin pr requirements`, +`origin pr thread`, and `origin pr watch` instead. Read `origin agent prompt ci` +and `origin agent prompt review` for this installed CLI's flags and JSON fields. +Apply the same fix/check/reply loop and stopping rules; do not call GitHub +GraphQL, `gh run`, or the GitHub snapshot helper for an Origin PR. - `scripts/pr-state.sh [--pr N]` prints one JSON snapshot: `checks`, `failing` (with run ids), `pending`, `threads` (unresolved, last word not yours), `mergeable`, and `next`. - No PR for this branch: stop and say so. Open one only when asked. -- Check out the PR's branch in this worktree (`gh pr checkout N` when it is not). +- Check out the PR's branch in this worktree through the backend above. Uncommitted work that is not yours: stop, do not stash it. ## 1. Loop on `next` @@ -31,7 +53,7 @@ Run `pr-state.sh`, do what `next` says, push, then run it again. | `next` | Do | |--------|----| -| `conflicts` | `git fetch origin && git merge origin/`. Resolve each file from both sides' intent, run the tests, commit the merge, push. No rebase: it needs a force-push. | +| `conflicts` | For an independent `gh` branch, `git fetch origin && git merge origin/`; resolve from both sides' intent, test, commit, push. For a stack, use the backend's stack update commands above, not a plain merge into one layer. Stop if publication requires a force-push. | | `fix-ci` | Take one failing check. `gh run view --log-failed`. Reproduce locally with the same command. Fix the cause, run it again, commit, push. | | `answer-threads` | Section 2. | | `wait-ci` | `pr-state.sh --wait 900`. It returns when a check finishes, a thread arrives, or the head moves (exit 124 on timeout: run it again). | diff --git a/skills/handoff/MANIFEST b/skills/handoff/MANIFEST index 7d0dc04..82e9960 100644 --- a/skills/handoff/MANIFEST +++ b/skills/handoff/MANIFEST @@ -2,6 +2,7 @@ SKILL.md MANIFEST scripts/handoff-spawn scripts/handoff-stages.sh +scripts/git-backend.sh resources/handoff.md resources/babysit.md resources/herdr-control.md diff --git a/skills/handoff/SKILL.md b/skills/handoff/SKILL.md index b23096e..25315a8 100644 --- a/skills/handoff/SKILL.md +++ b/skills/handoff/SKILL.md @@ -25,7 +25,8 @@ Do not inspect git, Graphite, the Herdr app, or worktrees yourself. Run `--info` That JSON is the only context you need: `herdr` (the Herdr app answers), `herdr_env` (this shell is an hc terminal), `socket`, `main_checkout`, `cwd`, -`branch`, `dirty`, `graphite`, `default_copy`, `helper`, `space` (the +`branch`, `dirty`, `git` (`gh` | `gt` | `origin`), `graphite` (legacy, +true only for `gt`), `default_copy`, `helper`, `space` (the app space the user is looking at), `beads`. - If `herdr` is false and you need `--branch`, report that the Herdr app is not open and stop. @@ -126,7 +127,7 @@ working directory to `cwd` from `--info`. Flags only. - `--space ` is optional (`--workspace` is the same flag): the app space to keep on screen. By default the user stays on whatever space they are looking at. - `--dirty` / `--clean` override `default_copy`. -- `--base ` is optional: dependent issues stack on the parent's branch via `--base` (`wt switch --create --base`; Graphite tracks that branch as parent). Without it the branch is cut from the default branch. +- `--base ` is optional: dependent issues stack on the parent's branch via `--base` (`wt switch --create --base`), then register the parent with the `git` backend from `--info`: `gh stack`, `gt track`, or `origin stack track`. Without it the branch is cut from the default branch. Publish and update through that backend's stack commands, never a hand-made PR merely based on another PR's branch. See [git-workflow.md](resources/git-workflow.md). Never run `gt` unless `git` is `gt`. - Plan-only work is a line in the issue, not a flag. ## The desk (`--branch`) diff --git a/skills/handoff/resources/git-workflow.md b/skills/handoff/resources/git-workflow.md index da73b71..930c526 100644 --- a/skills/handoff/resources/git-workflow.md +++ b/skills/handoff/resources/git-workflow.md @@ -1,18 +1,26 @@ -# Project git / Graphite / GitHub (handoff) +# Project git backend (handoff) -Spawn (`scripts/handoff-spawn --info` then spawn) already reports `graphite` -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). +Read `git` from `scripts/handoff-spawn --info`: `gh`, `gt`, or `origin`. +Use that backend throughout the job; do not probe with `gt`, because any `gt` +command auto-initializes a plain repo. `graphite` remains for one release as +`git == "gt"`; new callers use `git`. Detection and per-repo overrides: +[handoff.md](handoff.md#git-backend). 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`, uncommitted changes only). When the user later asks to commit/submit: -| Spawn JSON | Tooling | -|------------|---------| -| `graphite: true` | `gt create` / `gt modify` / `gt submit` (or Graphite MCP `run_gt_cmd`) | -| otherwise | `git` + `gh` via `git-master` / PR workflows | +| `git` | Stacked branches and publishing | +|-------|--------------------------------| +| `gh` | `gh stack init ` adopts existing branches; `gh stack add ` extends a stack from its tip. Publish with `gh stack submit`, update with `gh stack sync`. | +| `gt` | `gt create` for a new branch, `gt track --parent ` for an existing branch, `gt submit`, and `gt get` for remote stack updates. | +| `origin` | `origin stack track --onto ` before publication. Open the child with `origin pr create --stack-on --status open`; update with `origin stack get`, `origin stack restack`, and `origin stack push --all`. | -Do not commit/submit unless the user asked. +`--base ` dependents are stacks, not hand-made PRs that merely target +another PR's branch. Keep the parent relation in the selected backend. Never +run `gt` in a `gh` or `origin` repo. For an independent `gh` branch, plain +`git` and `gh pr` remain appropriate. + +Do not commit/submit unless the user asked. Autopilot may commit and push to +its PR branch, but never merges. Stop if updating a stack would rewrite a +branch you do not own or require a force-push. diff --git a/skills/handoff/resources/handoff.md b/skills/handoff/resources/handoff.md index 49eefdf..7ec8c4d 100644 --- a/skills/handoff/resources/handoff.md +++ b/skills/handoff/resources/handoff.md @@ -7,7 +7,8 @@ run `--info` then spawn — not assemble these steps. ## Sequence 1. `--info` prints JSON facts (`herdr`, `herdr_env`, `main_checkout`, `dirty`, - `graphite`, `default_copy`, `space`, …). `--branch` itself requires the + `git` (`gh` | `gt` | `origin`), `graphite` (legacy: `git == "gt"`), + `default_copy`, `space`, …). `--branch` itself requires the Herdr app to answer (`herdr-app status`) and a **main** git checkout. 2. `wt switch --create --no-cd`. Worktrunk `post-start` opens a Herdr app space on the sibling (no desk agent yet: handoff-spawn sets @@ -17,10 +18,11 @@ 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 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. +4. Register the sibling with the selected backend: `gt track` for Graphite; + for `--base` dependents, `gh stack init/add` or `origin stack track --onto`. + Keep the parent relation in that backend; publishing uses its stack commands. 5. Add the intro (worktree; stage, don't commit; Review opens on agent done; - gt when Graphite is on) to the beads issue as a `bd comment`. The prompt is one line + selected git backend and stack commands) 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` @@ -99,25 +101,52 @@ 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 +## Git backend + +`--info` reports one `git` backend for the repo. Linked worktrees use their +main checkout's choice. Detection never invokes `gt`: + +1. A `[git]` entry in bercail's config.toml, keyed by main-checkout path, + chooses `"gh"`, `"gt"`, or `"origin"`. +2. A legacy `[graphite]` entry maps `true` to `gt` and `false` to `gh`. + `[git]` wins when both are set. +3. Real Graphite tracking (below) selects `gt`. +4. A remote on an exact `origin.cursor.com` or `origin-.cursor.com` + host and `/org/repo` or `/git/org/repo` path is an Origin candidate. A bounded + `origin repo view --json mirrorStatus` selects `origin` only for `no-mirror` + or `outbound`. Other reported statuses select `gh`. A missing CLI or failed + lookup warns and falls back to `gh`; use `[git]` to settle an unknown result + before publishing. GitHub remotes never make this probe. + +For example: + +```toml +[git] +"~/Work/github-repo" = "gh" +"~/Work/graphite-repo" = "gt" +"~/Work/cursor-repo" = "origin" +``` + +`graphite` stays in `--info` for one release, derived from `git == "gt"`. +Consumers should read `git`, not re-detect. See +[git-workflow.md](git-workflow.md) for publishing and updating each stack. + +### Graphite tracking 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 +shared git dir (`.graphite_repo_config`) proves nothing. After the config +overrides above, tracking evidence decides: + +1. No Graphite config file: not Graphite. +2. 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, +3. 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. +in `[git]` (or legacy `[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 diff --git a/skills/handoff/scripts/git-backend.sh b/skills/handoff/scripts/git-backend.sh new file mode 100644 index 0000000..a2f0f15 --- /dev/null +++ b/skills/handoff/scripts/git-backend.sh @@ -0,0 +1,269 @@ +#!/usr/bin/env bash +# Shared git backend for one repo: gh, gt, or origin. +# Sourced by handoff-spawn (beside this file) and by the bercail CLI (the +# install copies this file to lib/git-backend.sh). Detection never runs gt. +# shellcheck shell=bash + +# Primary worktree of ROOT, canonicalized. Empty output and status 1 when ROOT +# is not a git checkout. Config keys use this path. +git_backend_main() { + local path + path="$(git -C "$1" worktree list --porcelain 2>/dev/null \ + | awk '/^worktree / { print substr($0, 10); exit }')" || true + [[ -n "$path" && -d "$path" ]] || return 1 + (cd "$path" && pwd -P) +} + +# Absolute .graphite_repo_config in the shared git dir, or status 1. +# A linked worktree's own git dir never holds it; --git-path would miss it. +git_backend_graphite_config() { + local cfg + cfg="$(git -C "$1" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" || return 1 + [[ -n "$cfg" ]] || return 1 + cfg="$cfg/.graphite_repo_config" + [[ -f "$cfg" ]] || return 1 + printf '%s' "$cfg" +} + +_git_backend_config_file() { + printf '%s' "${BERCAIL_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/bercail/config.toml}" +} + +_git_backend_realpath() { + local path="$1" + if [[ -d "$path" ]]; then + (cd "$path" && pwd -P) + else + printf '%s' "$path" + fi +} + +# This repo's [git] entry (gh|gt|origin), else its legacy [graphite] bool +# (true → gt, false → gh). Keyed by the main checkout. Status 1 with no entry. +# A value that is not an enum or a bool is ignored. [git] wins when both match. +git_backend_override() { + local config main key value section git_val="" graphite_val="" + config="$(_git_backend_config_file)" + [[ -f "$config" ]] || return 1 + main="$(git_backend_main "$1")" || return 1 + while IFS=$'\t' read -r section key value; do + [[ "$key" != \~/* ]] || key="$HOME/${key#\~/}" + [[ "$(_git_backend_realpath "$key")" == "$main" ]] || continue + case "$section" in + git) + [[ -n "$git_val" ]] || git_val="$value" + ;; + graphite) + [[ -n "$graphite_val" ]] || graphite_val="$value" + ;; + esac + done < <(awk ' + /^[[:space:]]*\[/ { + sec = $0 + sub(/#.*/, "", sec) + gsub(/[[:space:]"\047]/, "", sec) + next + } + sec != "[git]" && 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 + key = substr(line, 1, i - 1) + value = substr(line, i + 1) + sub(/^[[:space:]]*=[[:space:]]*/, "", value) + sub(/[[:space:]]*(#.*)?$/, "", value) + if (sec == "[graphite]" && (value == "true" || value == "false")) { + print "graphite\t" key "\t" value + next + } + if (sec == "[git]") { + v = value + gsub(/^["\047]|["\047]$/, "", v) + if (v == "gh" || v == "gt" || v == "origin") print "git\t" key "\t" v + } + }' "$config") + if [[ -n "$git_val" ]]; then + printf '%s' "$git_val" + return 0 + fi + case "$graphite_val" in + true) printf 'gt'; return 0 ;; + false) printf 'gh'; return 0 ;; + esac + return 1 +} + +# v0.11: a config file alone is not Graphite. A non-trunk refs/branch-metadata +# ref, or a parented row in .graphite_metadata.db, is. No gt command. +# Warns when the file is there but nothing is tracked, or the store cannot be read. +git_backend_graphite_tracks() { + local root="$1" cfg trunks refs db rows main + cfg="$(git_backend_graphite_config "$root")" || return 1 + main="$(git_backend_main "$root" 2>/dev/null || true)" + 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" "$main" >&2 + return 1 + fi + [[ -z "$rows" ]] || return 0 + fi + 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' \ + "$main" >&2 + return 1 +} + +# Offline: origin remote URL is an Origin git host. Not an ownership decision. +# Host is origin.cursor.com or origin-.cursor.com. Path is /org/repo or +# /git/org/repo (https or scp). A GitHub remote is not a candidate. +git_backend_origin_remote() { + local url host path + url="$(git -C "$1" remote get-url origin 2>/dev/null)" || return 1 + url="${url%"${url##*[![:space:]]}"}" + url="${url#"${url%%[![:space:]]*}"}" + url="${url%.git}" + url="${url%/}" + host="" + path="" + if [[ "$url" != *://* && "$url" =~ ^([^@:/]+@)?([^:/]+):(.+)$ ]]; then + host="${BASH_REMATCH[2]}" + path="${BASH_REMATCH[3]}" + elif [[ "$url" =~ ^[a-zA-Z][a-zA-Z0-9+.-]*://([^/@]+@)?([^/:]+)(:[0-9]+)?(/.*)?$ ]]; then + host="${BASH_REMATCH[2]}" + path="${BASH_REMATCH[4]}" + else + return 1 + fi + host="${host,,}" + [[ "$host" =~ ^origin(-[a-z0-9]+)?\.cursor\.com$ ]] || return 1 + path="${path#/}" + path="${path%.git}" + path="${path%/}" + [[ -n "$path" ]] || return 1 + local -a segs=() + local seg + IFS=/ read -r -a segs <<<"$path" + local -a kept=() + for seg in "${segs[@]}"; do + [[ -n "$seg" ]] && kept+=("$seg") + done + if [[ ${#kept[@]} -eq 3 && "${kept[0],,}" == git ]]; then + return 0 + fi + [[ ${#kept[@]} -eq 2 && "${kept[0],,}" != git ]] +} + +# Kill a command that outlives $1 seconds. Status 124 on timeout. The command +# gets its own process group; only that group is signaled. Not timeout(1): +# macOS has none. Do not kill -INT 0: this file is sourced by the desk, and +# process group 0 is the lead. +_git_backend_bounded() { + local ticks=$(($1 * 10)) pid rc + shift + ( + set -m + "$@" & + pid=$! + set +m + trap 'kill -KILL -- "-$pid" 2>/dev/null; exit 130' 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=$? + kill -KILL -- "-$pid" 2>/dev/null || true + exit "$rc" + ) +} + +_git_backend_origin_unknown() { + printf 'handoff-spawn: could not tell whether Origin owns this repo (%s); using gh. Set "%s" = "origin" or "gh" under [git] in bercail config.toml.\n' \ + "$1" "$2" >&2 +} + +# Ownership, only after git_backend_origin_remote. no-mirror or outbound → 0. +# A reported other mirrorStatus → 1 (GitHub owns it; the probe succeeded). +# No CLI, a failed lookup, a timeout, or no mirrorStatus → 1, with a warning +# that names the [git] override. A matched host is not ownership. +# Never called for a non-Origin remote, so --info does not probe GitHub repos. +git_backend_origin_owns() { + local root="$1" raw status rc=0 main + main="$(git_backend_main "$root" 2>/dev/null || printf '%s' "$root")" + if ! command -v origin >/dev/null 2>&1; then + _git_backend_origin_unknown "origin is not on PATH" "$main" + return 1 + fi + raw="$( + cd "$root" || exit 1 + _git_backend_bounded 10 origin repo view --json mirrorStatus /dev/null || true)" + case "$status" in + no-mirror|outbound) return 0 ;; + "") + _git_backend_origin_unknown "mirrorStatus missing" "$main" + return 1 + ;; + *) return 1 ;; + esac +} + +# Print gh, gt, or origin. +# 1. [git] enum, else legacy [graphite] bool. +# 2. Graphite tracking evidence (v0.11). No gt. +# 3. Origin remote whose mirrorStatus is no-mirror or outbound. +# A matched host with unknown ownership stays gh and warns. +# 4. gh. +git_backend() { + local override + if override="$(git_backend_override "$1")"; then + printf '%s' "$override" + return 0 + fi + if git_backend_graphite_tracks "$1"; then + printf 'gt' + return 0 + fi + if git_backend_origin_remote "$1"; then + local owns=0 + git_backend_origin_owns "$1" || owns=$? + if [[ "$owns" -eq 0 ]]; then + printf 'origin' + return 0 + fi + # Ctrl-C during the probe must not become a backend guess. + [[ "$owns" -ne 130 ]] || return 130 + fi + printf 'gh' +} diff --git a/skills/handoff/scripts/handoff-spawn b/skills/handoff/scripts/handoff-spawn index 5265fe9..23200ee 100755 --- a/skills/handoff/scripts/handoff-spawn +++ b/skills/handoff/scripts/handoff-spawn @@ -10,8 +10,10 @@ handoff_die() { } _HANDOFF_SPAWN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=handoff-stages.sh +# shellcheck source=skills/handoff/scripts/handoff-stages.sh source "$_HANDOFF_SPAWN_DIR/handoff-stages.sh" +# shellcheck source=skills/handoff/scripts/git-backend.sh +source "$_HANDOFF_SPAWN_DIR/git-backend.sh" handoff_usage() { cat >&2 <<'EOF' @@ -46,7 +48,8 @@ usage: handoff-spawn --info --workspace is the same flag) --base BRANCH --branch only: cut the new branch from BRANCH instead of the default branch (wt switch --create --base), so a dependent - issue stacks on its parent's branch. Graphite tracks it on BRANCH. + issue stacks on its parent's branch. The repo's git backend + records that parent (gh stack, gt track, or origin stack track). Shep writes the beads issue first and owns the workflow. There are no prompt files and no per-harness modes. Each real handoff adds a bd comment to the @@ -77,83 +80,17 @@ handoff_main_is_dirty() { [[ -n "$(git -C "$1" status --porcelain --untracked-files=all 2>/dev/null)" ]] } -# Graphite keeps its config in the shared git dir; --git-path would name a -# linked worktree's own dir, where it never is. +# Wrappers so callers written against the Graphite bool keep working. +# Detection lives in git-backend.sh (no gt). handoff_graphite_config() { - local root="$1" cfg - cfg="$(git -C "$root" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" || return 1 - [[ -n "$cfg" ]] || return 1 - cfg="$cfg/.graphite_repo_config" - [[ -f "$cfg" ]] || return 1 - 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 + git_backend_graphite_config "$1" } handoff_is_graphite() { - 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" + local name rc=0 + name="$(git_backend "$1")" || rc=$? + [[ "$rc" -ne 130 ]] || exit 130 + [[ "$name" == gt ]] } # Copy main's uncommitted tracked+untracked files into sibling as a working @@ -276,6 +213,112 @@ handoff_track_graphite() { printf 'the desk starts anyway, untracked. In %s run: gt track --parent %s\n' "$sibling" "$parent" >&2 } +handoff_trunk_name() { + local root="$1" ref name + ref="$(git -C "$root" symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null || true)" + if [[ "$ref" == refs/remotes/origin/* ]]; then + printf '%s' "${ref#refs/remotes/origin/}" + return 0 + fi + name="$(git -C "$root" config --get init.defaultBranch 2>/dev/null || true)" + if [[ -n "$name" ]] && git -C "$root" show-ref --verify --quiet "refs/heads/$name"; then + printf '%s' "$name" + return 0 + fi + if git -C "$root" show-ref --verify --quiet refs/heads/master; then + printf 'master' + return 0 + fi + printf 'main' +} + +handoff_worktree_of() { + local root="$1" branch="$2" path + path="$(git -C "$root" worktree list --porcelain 2>/dev/null | awk -v b="refs/heads/$branch" ' + /^worktree / { p = substr($0, 10) } + /^branch / && $2 == b { print p; exit } + ')" + [[ -n "$path" && -d "$path" ]] || return 1 + printf '%s' "$path" +} + +handoff_gh_catalog() { + local dir + dir="$(git -C "$1" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" || return 1 + printf '%s/gh-stack' "$dir" +} + +# gh stack add only appends at the current top, and it uses the cwd's branch. +# Run it where that parent is checked out. A missing checkout gets a temp +# worktree so main is not switched. Never -A/-u/-m: adoption is metadata-only. +# A parent that is in a stack but not the top is refused: init would reject it, +# and add would attach the child to the wrong branch. +handoff_track_gh() { + local sibling="$1" branch="$2" parent="$3" main catalog trunk wt tmp="" rc=0 + command -v gh >/dev/null 2>&1 || { + printf 'handoff-spawn: gh is not on PATH; not recording the stack. In %s run: gh stack init %s %s\n' \ + "$sibling" "$parent" "$branch" >&2 + return 0 + } + main="$(git_backend_main "$sibling")" || main="$sibling" + catalog="$(handoff_gh_catalog "$sibling")" + trunk="$(handoff_trunk_name "$main")" + if [[ -f "$catalog" ]] && jq -e --arg p "$parent" \ + 'any(.stacks[]?; any(.branches[]?; .branch == $p))' "$catalog" >/dev/null 2>&1; then + if ! jq -e --arg p "$parent" 'any(.stacks[]?; .branches[-1].branch == $p)' "$catalog" >/dev/null 2>&1; then + printf 'handoff-spawn: %s is not the top of its gh stack; not attaching %s. gh stack add only extends the top.\n' \ + "$parent" "$branch" >&2 + return 0 + fi + wt="$(handoff_worktree_of "$main" "$parent" || true)" + if [[ -z "$wt" ]]; then + tmp="$(mktemp -d)" + if ! git -C "$main" worktree add --quiet "$tmp" "$parent" >&2; then + printf 'handoff-spawn: could not check out %s to run gh stack add; the desk starts anyway.\n' "$parent" >&2 + rm -rf "$tmp" + return 0 + fi + wt="$tmp" + fi + ( + cd "$wt" || exit 1 + handoff_bounded "$(handoff_gt_timeout)" gh stack add "$branch" &2 + ) || rc=$? + if [[ -n "$tmp" ]]; then + git -C "$main" worktree remove --force "$tmp" >&2 || true + rm -rf "$tmp" + fi + elif [[ "$parent" == "$trunk" ]]; then + ( + cd "$sibling" || exit 1 + handoff_bounded "$(handoff_gt_timeout)" gh stack init --base "$trunk" "$branch" &2 + ) || rc=$? + else + ( + cd "$sibling" || exit 1 + handoff_bounded "$(handoff_gt_timeout)" gh stack init --base "$trunk" "$parent" "$branch" &2 + ) || rc=$? + fi + [[ "$rc" -eq 0 ]] || printf 'handoff-spawn: gh stack did not record %s on %s; the desk starts anyway.\n' \ + "$branch" "$parent" >&2 +} + +# origin stack track is local. It does not publish and does not need a PR. +handoff_track_origin() { + local sibling="$1" branch="$2" parent="$3" rc=0 + command -v origin >/dev/null 2>&1 || { + printf 'handoff-spawn: origin is not on PATH; not recording the stack. In %s run: origin stack track %s --onto %s\n' \ + "$sibling" "$branch" "$parent" >&2 + return 0 + } + ( + cd "$sibling" || exit 1 + handoff_bounded "$(handoff_gt_timeout)" origin stack track "$branch" --onto "$parent" --json &2 + ) || rc=$? + [[ "$rc" -eq 0 ]] || printf 'handoff-spawn: origin stack track did not record %s on %s; the desk starts anyway. In %s run: origin stack track %s --onto %s\n' \ + "$branch" "$parent" "$sibling" "$branch" "$parent" >&2 +} + handoff_sibling_path() { local main="$1" branch="$2" path path="$(git -C "$main" worktree list --porcelain 2>/dev/null | awk -v b="refs/heads/$branch" ' @@ -360,7 +403,7 @@ handoff_result_json() { } handoff_info_json() { - local cwd branch helper default_copy + local cwd branch helper default_copy backend=gh local herdr_env=0 socket=0 herdr=0 main_ok=0 dirty=0 graphite=0 helper_present=0 beads=0 cwd="$(handoff_realpath "$PWD")" [[ -n "${HC_TERMINAL_ID:-}" ]] && herdr_env=1 @@ -376,8 +419,11 @@ handoff_info_json() { if command -v git >/dev/null 2>&1 && handoff_main_is_dirty "$cwd"; then dirty=1 fi - if command -v git >/dev/null 2>&1 && handoff_is_graphite "$cwd"; then - graphite=1 + if command -v git >/dev/null 2>&1; then + local info_rc=0 + backend="$(git_backend "$cwd")" || info_rc=$? + [[ "$info_rc" -ne 130 ]] || exit 130 + [[ "$backend" == gt ]] && graphite=1 fi branch="$(git -C "$cwd" branch --show-current 2>/dev/null || true)" helper="${HOME}/.config/worktrunk/herdr-layout.sh" @@ -402,6 +448,7 @@ handoff_info_json() { --argjson socket "$(handoff_bool_json "$socket")" \ --argjson main_checkout "$(handoff_bool_json "$main_ok")" \ --argjson dirty "$(handoff_bool_json "$dirty")" \ + --arg git "$backend" \ --argjson graphite "$(handoff_bool_json "$graphite")" \ --argjson helper_present "$(handoff_bool_json "$helper_present")" \ --argjson beads "$(handoff_bool_json "$beads")" \ @@ -413,6 +460,7 @@ handoff_info_json() { cwd:$cwd, branch:$branch, dirty:$dirty, + git:$git, graphite:$graphite, default_copy:$default_copy, helper:$helper, @@ -425,7 +473,7 @@ handoff_info_json() { } handoff_spawn_main() { - local branch="" dirty="auto" issue="" prompt main sibling label intro graphite=0 copied=0 + local branch="" dirty="auto" issue="" prompt main sibling label intro graphite=0 copied=0 backend=gh local helper="${HOME:-}/.config/worktrunk/herdr-layout.sh" local json keep_space info=0 started=0 space="" desk="" local stage="" worktree="" resume="" dry=0 mode="" binary="" binary_override="" desk_bin="" @@ -579,10 +627,21 @@ handoff_spawn_main() { fi fi - if handoff_is_graphite "$sibling"; then - graphite=1 - handoff_track_graphite "$sibling" "$base" - fi + local track_rc=0 + backend="$(git_backend "$sibling")" || track_rc=$? + [[ "$track_rc" -ne 130 ]] || exit 130 + case "$backend" in + gt) + graphite=1 + handoff_track_graphite "$sibling" "$base" + ;; + gh) + [[ -z "$base" ]] || handoff_track_gh "$sibling" "$branch" "$base" + ;; + origin) + [[ -z "$base" ]] || handoff_track_origin "$sibling" "$branch" "$base" + ;; + esac label="$(_wt_generate_session_name "$sibling")" @@ -594,6 +653,13 @@ handoff_spawn_main() { "$stage" "$binary" "$sibling" "$branch" "$issue")" if [[ "$graphite" -eq 1 ]]; then intro="$(printf '%s\nUse gt, not raw git commit/push, unless asked.' "$intro")" + else + intro="$(printf '%s\nGit backend is %s. Do not run gt.' "$intro" "$backend")" + if [[ -n "$base" && "$backend" == gh ]]; then + intro="$(printf '%s Stack %s on %s with gh stack init or gh stack add, then gh stack submit. Do not open a PR that only targets %s.' "$intro" "$branch" "$base" "$base")" + elif [[ -n "$base" && "$backend" == origin ]]; then + intro="$(printf '%s Stack %s on %s with origin stack track --onto and origin pr create --stack-on. Do not open a PR that only targets %s.' "$intro" "$branch" "$base" "$base")" + fi fi handoff_issue_comment "$sibling" "$issue" "$intro" diff --git a/tests/git_backend_review_test.sh b/tests/git_backend_review_test.sh new file mode 100755 index 0000000..8535504 --- /dev/null +++ b/tests/git_backend_review_test.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +set -euo pipefail +unset BASH_ENV HC_TERMINAL_ID BERCAIL_REVIEW_STACK +export __MISE_BASH_ENV_LOADED=1 +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +export HOME="$TMP/home" XDG_CONFIG_HOME="$TMP/config" XDG_STATE_HOME="$TMP/state" +export BERCAIL_CONFIG="$TMP/config.toml" +export GIT_AUTHOR_NAME=test GIT_AUTHOR_EMAIL=test@example.com GIT_COMMITTER_NAME=test GIT_COMMITTER_EMAIL=test@example.com +export FIX="$TMP/fixtures" LOG="$TMP/log" +mkdir -p "$HOME" "$FIX" "$LOG" "$TMP/bin" +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } +# shellcheck source=lib/review.sh +source "$ROOT/lib/review.sh" + +# Two truly dependent branches, published only as heads (not GitHub pull refs). +export REMOTE="$TMP/remote.git" +git init -q --bare -b main "$REMOTE" +git init -q -b main "$TMP/seed" +git -C "$TMP/seed" config user.name test +git -C "$TMP/seed" config user.email test@example.com +printf 'base\n' >"$TMP/seed/file" +git -C "$TMP/seed" add file && git -C "$TMP/seed" commit -qm base +BASE="$(git -C "$TMP/seed" rev-parse HEAD)" +git -C "$TMP/seed" switch -qc one +printf 'one\n' >>"$TMP/seed/file" +git -C "$TMP/seed" commit -qam one +ONE="$(git -C "$TMP/seed" rev-parse HEAD)" +git -C "$TMP/seed" switch -qc two +printf 'two\n' >>"$TMP/seed/file" +git -C "$TMP/seed" commit -qam two +TWO="$(git -C "$TMP/seed" rev-parse HEAD)" +git -C "$TMP/seed" push -q "$TMP/remote.git" main one two +git clone -q "$TMP/remote.git" "$TMP/work" +WORK="$TMP/work" +export WORK +for n in 1 2; do + head=one parent=main sha="$ONE" base="$BASE" + if [[ "$n" == 2 ]]; then head=two parent=one sha="$TWO" base="$ONE"; fi + jq -n --argjson n "$n" --arg head "$head" --arg parent "$parent" --arg sha "$sha" --arg base "$base" \ + '{number:$n,title:("Change " + ($n|tostring)),body:"An independently useful slice.",url:("https://review.cursor.com/pr/acme/widgets/"+($n|tostring)), + author:{id:"actor-1",kind:"user",displayName:"Test Author",avatarUrl:""},isDraft:false,baseRefName:$parent,headRefName:$head,headRefOid:$sha,baseRefOid:$base, + files:[{path:"file",changeKind:"modified",isBinary:false,additions:1,deletions:0}]}' >"$FIX/pr-$n.json" +done +cat >"$FIX/native.json" <<'JSON' +{"source":"remote","trunk":"main","members":[{"branch":"two","parent":"one","number":2},{"branch":"one","parent":"main","number":1}]} +JSON +cat >"$TMP/bin/origin" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$LOG/origin" +case "$1 $2" in + 'stack view') + git rev-parse --git-dir >/dev/null 2>&1 && exit 9 + [[ "$*" == *'--json source,trunk,members'* && "$*" == *'-R acme/widgets'* ]] || exit 9 + cat "$FIX/native.json" ;; + 'pr view') + [[ "$*" != *closingIssuesReferences* && "$*" != *statusCheckRollup* && "$*" != *'-R origin.cursor.com/'* ]] || exit 9 + cat "$FIX/pr-$3.json" ;; + 'pr checks') printf '[{"name":"test","status":"completed","conclusion":"success","detailsUrl":"https://ci/test"}]\n' ;; + 'pr list') printf '[{"number":2,"url":"https://cursor.com/codebase/acme/widgets/pull/2"}]\n' ;; + 'repo clone') git clone -q "$REMOTE" "$4" ;; + *) exit 9 ;; +esac +SH +cat >"$TMP/bin/gh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$PWD $*" >>"$LOG/gh" +case "$1 $2" in + 'stack view') + [[ "$PWD" != "$WORK" ]] || exit 9 + printf '{"trunk":"main","branches":[{"name":"one","base":"SHA-NOT-BRANCH","pr":{"number":1}},{"name":"two","base":"SHA-NOT-BRANCH","pr":{"number":2}}]}\n' ;; + 'repo view') printf '{"defaultBranchRef":{"name":"main"}}\n' ;; + 'api --hostname') + case "${FAKE_STACK_API:-}" in + found) printf '[{"pull_requests":[{"number":1},{"number":2}]}]\n' ;; + missing) echo 'gh: Not Found (HTTP 404)' >&2; exit 1 ;; + broken) echo 'gh: Unauthorized (HTTP 401)' >&2; exit 1 ;; + *) echo '[]' ;; + esac ;; + 'stack checkout') [[ "$PWD" != "$WORK" && "$3" == https://* && ! -e .git/gh-stack ]] || exit 9 ;; + *) exit 9 ;; +esac +SH +printf '#!/usr/bin/env bash\nexit 1\n' >"$TMP/bin/herdr-app" +printf '#!/usr/bin/env bash\necho "gt must not run" >&2; exit 99\n' >"$TMP/bin/gt" +chmod +x "$TMP/bin/"* +export PATH="$TMP/bin:$PATH" HERDR_APP="$TMP/bin/herdr-app" + +# The selected PR can be in a different stack than the source's current branch. +cat >"$WORK/.git/gh-stack" <<'JSON' +{"schemaVersion":1,"repository":"github.com/acme/widgets","stacks":[{"trunk":{"branch":"main"},"branches":[{"branch":"one","pullRequest":{"number":1}},{"branch":"two","pullRequest":{"number":2}}]}]} +JSON +jq '.url = "https://github.com/acme/widgets/pull/2"' "$FIX/pr-2.json" >"$FIX/gh-entry.json" +before="$(git -C "$WORK" status --porcelain);$(git -C "$WORK" rev-parse HEAD);$(cksum "$WORK/.git/gh-stack")" +order="$(stack_discover github.com/acme/widgets "$FIX/gh-entry.json" "$WORK" "$TMP/gh-sync" gh)" +[[ "$(jq -r '.via' <<<"$order")" == gh-stack && "$(jq -c .numbers <<<"$order")" == '[1,2]' ]] || fail "gh stack order: $order" +[[ "$before" == "$(git -C "$WORK" status --porcelain);$(git -C "$WORK" rev-parse HEAD);$(cksum "$WORK/.git/gh-stack")" ]] || fail 'gh stack modified source' +printf 'PASS: gh stack selects requested PR and refreshes only a private clone\n' +printf 'advance\n' >>"$TMP/seed/file" +git -C "$TMP/seed" commit -qam advance +git -C "$TMP/seed" push -q "$TMP/remote.git" two +ADVANCED="$(git -C "$TMP/seed" rev-parse HEAD)" +jq --arg sha "$ADVANCED" '.headRefOid = $sha' "$FIX/gh-entry.json" >"$FIX/gh-advanced.json" +stack_discover github.com/acme/widgets "$FIX/gh-advanced.json" "$WORK" "$TMP/gh-sync" gh >/dev/null +[[ -z "$(git -C "$TMP/gh-sync" status --porcelain)" ]] || fail 'repeat gather left private index dirty' +[[ "$(git -C "$TMP/gh-sync" rev-parse HEAD)" == "$ADVANCED" ]] || fail 'stale source hid latest remote head' +[[ "$(git -C "$WORK" rev-parse HEAD)" == "$BASE" ]] || fail 'repeat gather changed source' +printf 'PASS: repeat gh gather fetches missing heads and keeps its private index clean\n' +mv "$WORK/.git/gh-stack" "$TMP/saved-catalog" +export FAKE_STACK_API=found +order="$(stack_discover github.com/acme/widgets "$FIX/gh-entry.json" "$WORK" "$TMP/remote-sync" gh)" +[[ "$(jq -r .via <<<"$order")" == gh-stack ]] || fail 'remote-only native stack missed' +[[ ! -e "$WORK/.git/gh-stack" ]] || fail 'remote discovery wrote source catalog' +cp "$TMP/saved-catalog" "$TMP/remote-sync/.git/gh-stack" +stack_discover github.com/acme/widgets "$FIX/gh-entry.json" "$WORK" "$TMP/remote-sync" gh >/dev/null +FAKE_STACK_API=missing +rc=0 +_stack_gh_stack "$WORK" "$FIX/pr-2.json" "$TMP/missing-sync" github.com/acme/widgets >/dev/null || rc=$? +[[ "$rc" == 3 ]] || fail '404 should fall back to GitHub walk' +FAKE_STACK_API=broken +rc=0 +_stack_gh_stack "$WORK" "$FIX/pr-2.json" "$TMP/broken-sync" github.com/acme/widgets >/dev/null 2>&1 || rc=$? +[[ "$rc" == 1 ]] || fail 'auth failure should not masquerade as no native stack' +unset FAKE_STACK_API +printf 'PASS: remote-only gh stacks use native checkout; only empty/404 falls back\n' + +# Origin is explicit here so the live forge is never contacted by the fixture. +printf '[git]\n"%s" = "origin"\n' "$WORK" >"$BERCAIL_CONFIG" +printf 'origin.cursor.com/acme/widgets\n' >"$WORK/.git/bercail-review-repo" +: >"$LOG/gh" +for url in https://review.cursor.com/pr/acme/widgets/2 https://cursor.com/codebase/acme/widgets/pull/2; do + out="$(cd "$WORK" && "$ROOT/bin/bercail" review-pr "$url" --no-run)" + stack="$(jq -r .stack <<<"$out")" + [[ "$(jq -c '[.prs[].number]' "$stack/stack.json")" == '[1,2]' ]] || fail 'Origin order' + [[ "$(jq -r .git "$stack/stack.json")" == origin ]] || fail 'Origin enum persisted' + [[ "$(jq -r '.prs[0].author' "$stack/stack.json")" == 'Test Author' ]] || fail 'Origin author normalization' + [[ "$(jq -r '.[0].bucket' "$stack/pr-2/$TWO/ci.json")" == pass ]] || fail 'Origin CI normalization' + [[ "$(git -C "$WORK" branch --show-current)" == main ]] || fail 'Origin moved source branch' +done +[[ ! -s "$LOG/gh" ]] || fail 'Origin review called gh' +"$ROOT/bin/bercail" review-pr open 2 --stack "$stack" >/dev/null +grep -q 'pr view 2 -R acme/widgets --web' "$LOG/origin" || fail 'Origin review open must use native browser, not tuicr pr' +(cd "$WORK" && "$ROOT/bin/bercail" review-pr two --no-run) >/dev/null +printf 'PASS: Origin review accepts both URL formats and branches, gathers native stack/PR/checks without gh\n' +(cd "$TMP" && "$ROOT/bin/bercail" review-pr https://review.cursor.com/pr/acme/widgets/2 --no-run) >/dev/null +[[ -d "$HOME/Work/widgets/.git" ]] || fail 'Origin fallback did not clone' +[[ ! -s "$LOG/gh" ]] || fail 'Origin fallback called gh' +git -C "$WORK" remote set-url origin https://origin-staging.cursor.com/git/acme/widgets.git +_review_origin_matches "$WORK" acme widgets origin.cursor.com || fail 'Origin environment remote rejected' +git -C "$WORK" remote set-url origin https://origin.cursor.com.evil/acme/widgets.git +_review_origin_matches "$WORK" acme widgets origin.cursor.com && fail 'lookalike Origin host accepted' +printf 'PASS: Origin fallback clone and exact environment-host matching\n' + +# Refuse graph ambiguity rather than choosing a partial path. +linear='{"trunk":"main","members":[{"branch":"a","parent":"main","number":1},{"branch":"b","parent":"main","number":2}]}' +rc=0 +_stack_native_order "$linear" >/dev/null || rc=$? +[[ "$rc" == 2 ]] || fail 'native fork must signal ambiguity' +cycle='{"trunk":"main","members":[{"branch":"a","parent":"b","number":1},{"branch":"b","parent":"a","number":2}]}' +_stack_native_order "$cycle" >/dev/null 2>&1 && fail 'native cycle accepted' +trunk_cycle='{"trunk":"main","members":[{"branch":"a","parent":"main","number":1},{"branch":"main","parent":"a","number":2}]}' +_stack_native_order "$trunk_cycle" >/dev/null 2>&1 && fail 'cycle through trunk accepted' +duplicate='{"trunk":"main","members":[{"branch":"a","parent":"main","number":1},{"branch":"b","parent":"a","number":1}]}' +_stack_native_order "$duplicate" >/dev/null 2>&1 && fail 'duplicate PR accepted' +printf 'PASS: native stack forks, cycles, and duplicate PRs are refused\n' diff --git a/tests/handoff_spawn_test.sh b/tests/handoff_spawn_test.sh index 945efd7..91ef57e 100755 --- a/tests/handoff_spawn_test.sh +++ b/tests/handoff_spawn_test.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash # shellcheck shell=bash +# Fixtures contain shell code whose variables must expand only when executed. +# shellcheck disable=SC2016 set -euo pipefail unset BASH_ENV @@ -134,13 +136,147 @@ EOF 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" + (HOME="$home"; handoff_is_graphite "$home/repo") || fail "home-relative config keys expand to 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_git_enum_override_beats_graphite_and_detection() { + local plain="$TMP_DIR/git-plain" tracked="$TMP_DIR/git-tracked" cfg="$TMP_DIR/git.toml" home="$TMP_DIR/git-home" + git_init "$plain" + git_init "$tracked" + graphite_auto_init "$tracked" + git -C "$tracked" update-ref refs/branch-metadata/feat HEAD + export BERCAIL_CONFIG="$cfg" + cat >"$cfg" <&1 >/dev/null)" ]] || fail "a settled [git] entry is silent" + cat >"$cfg" <"$cfg" + mkdir -p "$home" + git_init "$home/repo" + [[ "$(HOME="$home" git_backend "$home/repo")" == gt ]] || fail "home-relative [git] keys expand to the home directory" + unset BERCAIL_CONFIG + printf 'PASS: [git] enum overrides [graphite] and detection; bad values are ignored\n' +} + +_origin_remote() { + git -C "$1" remote remove origin 2>/dev/null || true + git -C "$1" remote add origin "$2" +} + +test_origin_remote_is_offline_until_the_host_matches() { + local repo="$TMP_DIR/ori-off" bin="$TMP_DIR/ori-off-bin" log="$TMP_DIR/ori-off.log" + git_init "$repo" + mkdir -p "$bin" + cat >"$bin/origin" <>"$log" +printf '%s\n' '{"mirrorStatus":"no-mirror"}' +EOF + chmod +x "$bin/origin" + : >"$log" + _origin_remote "$repo" https://github.com/acme/widgets.git + [[ "$(PATH="$bin:$PATH" git_backend "$repo")" == gh ]] || fail "a GitHub remote is gh" + [[ ! -s "$log" ]] || fail "origin must not be probed for a GitHub remote: $(cat "$log")" + git_backend_origin_remote "$repo" && fail "github.com is not an Origin git host" + _origin_remote "$repo" git@origin.cursor.com:acme/widgets.git + git_backend_origin_remote "$repo" || fail "scp origin.cursor.com is an Origin git host" + _origin_remote "$repo" https://origin.cursor.com/git/acme/widgets.git + git_backend_origin_remote "$repo" || fail "/git/org/repo is an Origin git host" + _origin_remote "$repo" https://origin-staging.cursor.com/acme/widgets + git_backend_origin_remote "$repo" || fail "origin-.cursor.com is an Origin git host" + _origin_remote "$repo" https://github.com/origin.cursor.com/acme/widgets.git + git_backend_origin_remote "$repo" && fail "a lookalike path on github.com is not Origin" + printf 'PASS: Origin host match is offline; a GitHub remote is never probed\n' +} + +test_origin_ownership_uses_mirror_status() { + local repo="$TMP_DIR/ori-own" bin="$TMP_DIR/ori-own-bin" log="$TMP_DIR/ori-own.log" status + git_init "$repo" + _origin_remote "$repo" https://origin.cursor.com/acme/widgets.git + mkdir -p "$bin" + cat >"$bin/origin" <>"$log" +if [[ "\$*" == *"repo view --json mirrorStatus"* ]]; then + printf '%s\n' "{\"mirrorStatus\":\"$TMP_DIR/ori-status\"}" + # status file holds the value; the JSON is written below by the test via env + printf '%s\n' "{\"mirrorStatus\":\"\${ORIGIN_STATUS}\"}" + exit 0 +fi +exit 1 +EOF + # The fake above prints two JSON lines when ORIGIN_STATUS is set. Rewrite it cleanly. + cat >"$bin/origin" <<'EOF' +#!/bin/sh +printf '%s\n' "$*" >>"$BERCAIL_ORIGIN_LOG" +if [ "$1" = "repo" ] && [ "$2" = "view" ]; then + printf '%s\n' "{\"mirrorStatus\":\"${ORIGIN_STATUS}\"}" + exit 0 +fi +exit 1 +EOF + chmod +x "$bin/origin" + export BERCAIL_ORIGIN_LOG="$log" + for status in no-mirror outbound; do + : >"$log" + [[ "$(PATH="$bin:$PATH" ORIGIN_STATUS="$status" git_backend "$repo")" == origin ]] \ + || fail "$status must be origin" + grep -q 'repo view --json mirrorStatus' "$log" || fail "ownership probe missing: $(cat "$log")" + done + : >"$log" + [[ "$(PATH="$bin:$PATH" ORIGIN_STATUS=inbound git_backend "$repo")" == gh ]] \ + || fail "inbound mirror is GitHub-owned" + [[ "$(PATH="$bin:$PATH" ORIGIN_STATUS=other git_backend "$repo")" == gh ]] \ + || fail "any other mirrorStatus is gh" + : >"$log" + [[ "$(PATH="/usr/bin:/bin" git_backend "$repo" 2>"$TMP_DIR/ori-missing.err")" == gh ]] \ + || fail "missing origin CLI is unknown ownership, so gh" + grep -q '\[git\]' "$TMP_DIR/ori-missing.err" || fail "missing CLI should name the override: $(cat "$TMP_DIR/ori-missing.err")" + cat >"$bin/origin" <<'EOF' +#!/bin/sh +printf 'origin: not logged in\n' >&2 +exit 1 +EOF + chmod +x "$bin/origin" + [[ "$(PATH="$bin:$PATH" git_backend "$repo" 2>"$TMP_DIR/ori-fail.err")" == gh ]] \ + || fail "a failed ownership probe is gh" + grep -q 'origin repo view failed' "$TMP_DIR/ori-fail.err" || fail "failed probe should say why: $(cat "$TMP_DIR/ori-fail.err")" + grep -q '\[git\]' "$TMP_DIR/ori-fail.err" || fail "failed probe should name the override: $(cat "$TMP_DIR/ori-fail.err")" + cat >"$bin/origin" <<'EOF' +#!/bin/sh +exit 130 +EOF + chmod +x "$bin/origin" + local rc=0 out + out="$(PATH="$bin:$PATH" git_backend "$repo" 2>"$TMP_DIR/ori-int.err")" || rc=$? + [[ "$rc" -eq 130 ]] || fail "an interrupted probe must exit 130, not guess gh (rc=$rc out=$out)" + [[ -z "$out" ]] || fail "an interrupted probe must not print a backend: $out" + grep -q 'using gh' "$TMP_DIR/ori-int.err" && fail "interrupt is not an unknown-ownership fallback: $(cat "$TMP_DIR/ori-int.err")" + unset BERCAIL_ORIGIN_LOG ORIGIN_STATUS + printf 'PASS: Origin ownership is mirrorStatus; unknown ownership warns and stays gh\n' +} + test_copy_dirty_is_working_tree_not_index() { local main="$TMP_DIR/main" sibling="$TMP_DIR/sibling" git_init "$main" @@ -243,13 +379,13 @@ test_info_json_reports_graphite_and_dirty() { 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" + printf '%s' "$out" | jq -e '.graphite == false and .git == "gh"' >/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)" printf '%s' "$out" | jq -e '.dirty == true' >/dev/null || fail "info dirty: $out" - printf '%s' "$out" | jq -e '.graphite == true' >/dev/null || fail "info graphite: $out" + printf '%s' "$out" | jq -e '.graphite == true and .git == "gt"' >/dev/null || fail "info graphite: $out" printf '%s' "$out" | jq -e '.default_copy == "dirty"' >/dev/null || fail "info default_copy: $out" printf '%s' "$out" | jq -e '.herdr == false' >/dev/null || fail "info herdr: $out" printf '%s' "$out" | jq -e '.herdr_env == false' >/dev/null || fail "info herdr_env: $out" @@ -369,6 +505,9 @@ test_base_needs_branch() { } test_graphite_config_file_alone_is_not_graphite +test_git_enum_override_beats_graphite_and_detection +test_origin_remote_is_offline_until_the_host_matches +test_origin_ownership_uses_mirror_status test_graphite_metadata_refs_are_graphite test_graphite_second_trunk_ref_is_not_graphite test_graphite_metadata_db_rows @@ -458,7 +597,7 @@ JSON printf 'PASS: opus-5.5 resolves to the catalog id, never a *-max slug\n' } -ISSUE=bercail-0s1 +ISSUE='bercail-0s1' # Stub bd: knows $BD_KNOWN ids, logs comments, reports $BD_BEADS_DIR. _bd_stub() { @@ -1238,6 +1377,7 @@ test_track_timeout_is_bounded_and_kills_the_group() { # Spawns usually run from a terminal, where job control has a foreground to manage. test_track_timeout_under_a_terminal() { + # shellcheck disable=SC2015 # Either an absent script or an unsupported -qec skips. 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 @@ -1260,6 +1400,7 @@ EOF } test_track_ctrl_c_kills_gt() { + # shellcheck disable=SC2015 # Either an absent script or an unsupported -qec skips. 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 @@ -1486,6 +1627,87 @@ test_spawn_real_stack_tracks_on_its_base() { printf 'PASS: a real stack spawned with --base is tracked on that base, as before\n' } +_fake_logger() { + local bin="$1" name="$2" log="$3" + mkdir -p "$bin" + : >"$log" + cat >"$bin/$name" <>"$log" +exit 0 +EOF + chmod +x "$bin/$name" +} + +test_gh_base_inits_a_new_stack_and_refuses_a_middle_parent() { + local repo="$TMP_DIR/gh-init" bin="$TMP_DIR/gh-init-bin" log="$TMP_DIR/gh-init.log" trunk + git_init "$repo" + trunk="$(git -C "$repo" branch --show-current)" + git -C "$repo" branch parent-branch + git -C "$repo" worktree add -q -b feat "$repo.feat" parent-branch + _fake_logger "$bin" gh "$log" + _fake_logger "$bin" gt "$TMP_DIR/gh-init-gt.log" + PATH="$bin:$PATH" handoff_track_gh "$repo.feat" feat parent-branch + grep -q "stack init --base $trunk parent-branch feat" "$log" \ + || fail "a new dependent is gh stack init; log=$(cat "$log")" + [[ "$(awk '{ print $1 }' "$log")" == "$repo.feat" ]] || fail "init runs in the child worktree: $(cat "$log")" + [[ ! -s "$TMP_DIR/gh-init-gt.log" ]] || fail "gt must not run for gh: $(cat "$TMP_DIR/gh-init-gt.log")" + local catalog + catalog="$(git_dir "$repo")/gh-stack" + jq -n --arg p parent-branch '{stacks:[{trunk:{branch:"main"},branches:[{branch:"bottom"},{branch:$p},{branch:"tip"}]}]}' >"$catalog" + : >"$log" + local err + err="$(PATH="$bin:$PATH" handoff_track_gh "$repo.feat" feat parent-branch 2>&1)" || fail "a middle parent must not fail the spawn: $err" + [[ ! -s "$log" ]] || fail "a middle parent must not call gh stack: $(cat "$log")" + printf '%s' "$err" | grep -q 'not the top' || fail "say why it was not attached: $err" + printf 'PASS: gh --base inits a stack, and a middle parent is not attached\n' +} + +test_gh_base_adds_from_the_parent_worktree() { + local repo="$TMP_DIR/gh-add" bin="$TMP_DIR/gh-add-bin" log="$TMP_DIR/gh-add.log" + git_init "$repo" + git -C "$repo" branch parent-branch + git -C "$repo" worktree add -q "$repo.parent" parent-branch + git -C "$repo" worktree add -q -b feat "$repo.feat" parent-branch + jq -n '{stacks:[{trunk:{branch:"main"},branches:[{branch:"parent-branch"}]}]}' >"$(git_dir "$repo")/gh-stack" + _fake_logger "$bin" gh "$log" + PATH="$bin:$PATH" handoff_track_gh "$repo.feat" feat parent-branch + [[ "$(cat "$log")" == "$repo.parent stack add feat" ]] \ + || fail "add runs in the worktree that has the top checked out; log=$(cat "$log")" + printf 'PASS: gh stack add uses the parent worktree and does not switch main\n' +} + +test_origin_base_tracks_locally() { + local repo="$TMP_DIR/ori-track" bin="$TMP_DIR/ori-track-bin" log="$TMP_DIR/ori-track.log" + git_init "$repo" + git -C "$repo" branch parent-branch + git -C "$repo" worktree add -q -b feat "$repo.feat" parent-branch + _fake_logger "$bin" origin "$log" + _fake_logger "$bin" gt "$TMP_DIR/ori-track-gt.log" + PATH="$bin:$PATH" handoff_track_origin "$repo.feat" feat parent-branch + [[ "$(cat "$log")" == "$repo.feat stack track feat --onto parent-branch --json" ]] \ + || fail "origin stack track --onto; log=$(cat "$log")" + [[ ! -s "$TMP_DIR/ori-track-gt.log" ]] || fail "gt must not run for origin" + printf 'PASS: origin --base is origin stack track, local, not gt\n' +} + +test_spawn_gh_base_does_not_call_gt() { + local repo="$TMP_DIR/sp-gh" bin="$TMP_DIR/sp-gh-bin" log="$TMP_DIR/sp-gh.log" gtlog="$TMP_DIR/sp-gh-gt.log" out + git_init "$repo" + git -C "$repo" branch parent-branch + _fake_logger "$bin" gh "$log" + _fake_logger "$bin" gt "$gtlog" + out="$(_spawn_branch sp-gh "$repo" "$bin" "$TMP_DIR/sp-gh-home" parent-branch 2>"$TMP_DIR/sp-gh.err")" \ + || fail "spawn failed: $out $(cat "$TMP_DIR/sp-gh.err")" + printf '%s' "$out" | jq -e '.graphite == false and .agent_started == true and .base == "parent-branch" and (has("git")|not)' >/dev/null \ + || fail "spawn json keeps the old contract: $out" + grep -q 'stack init' "$log" || fail "spawn --base on gh records a stack; log=$(cat "$log")" + [[ ! -s "$gtlog" ]] || fail "gt must not run: $(cat "$gtlog")" + grep -q 'Use gt' "$BD_COMMENTS" && fail "no gt intro on gh: $(cat "$BD_COMMENTS")" + grep -q 'gh stack' "$BD_COMMENTS" || fail "the desk is told to use gh stack: $(cat "$BD_COMMENTS")" + printf 'PASS: a gh --base spawn uses gh stack, not gt, and does not change the result JSON\n' +} + test_track_already_tracked_does_nothing test_track_tracks_an_untracked_parent_first test_track_falls_back_to_trunk_with_a_warning @@ -1507,3 +1729,7 @@ 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 +test_gh_base_inits_a_new_stack_and_refuses_a_middle_parent +test_gh_base_adds_from_the_parent_worktree +test_origin_base_tracks_locally +test_spawn_gh_base_does_not_call_gt diff --git a/tests/review_pr_test.sh b/tests/review_pr_test.sh index 0092b8c..7910404 100644 --- a/tests/review_pr_test.sh +++ b/tests/review_pr_test.sh @@ -125,6 +125,7 @@ case "$1 $2" in if [[ " $* " == *" --bare "* ]]; then git clone -q --bare "$REMOTE" "$4" else git clone -q "$REMOTE" "$4"; fi ;; "repo view") echo '{"defaultBranchRef":{"name":"main"}}' ;; + "api --hostname") echo '[]' ;; "pr list") base="" head="" shift 2 @@ -1288,6 +1289,7 @@ EOF done local gcfg gcfg="$(git -C "$WORK" rev-parse --path-format=absolute --git-common-dir)/.graphite_repo_config" + git -C "$WORK" update-ref refs/branch-metadata/g1 HEAD printf '%s\n' '{"trunk":"main"}' >"$gcfg" : >"$LOG/gt" out="$(cd "$WORK" && "$BERCAIL" review-pr https://github.com/acme/widgets/pull/33 --no-run)" @@ -1326,6 +1328,7 @@ EOF assert_eq "$wreviews/stack-61" "$(jq -r .stack <<<"$fork_out")" "graphite fork run dir is the entry" grep -q 'get groot ' "$LOG/gt" || fail "gt get of the graphite root after a leaf entry" rm -f "$gcfg" + git -C "$WORK" update-ref -d refs/branch-metadata/g1 local sync="$XDG_STATE_HOME/bercail/reviews/github.com/acme/widgets/gt-sync" [[ "$(git -C "$sync" rev-parse --path-format=absolute --git-common-dir)" != "$(git -C "$WORK" rev-parse --path-format=absolute --git-common-dir)" ]] \ || fail "private clone must not share the source git dir" diff --git a/tests/skills_deploy_test.sh b/tests/skills_deploy_test.sh index 2149980..c72f586 100755 --- a/tests/skills_deploy_test.sh +++ b/tests/skills_deploy_test.sh @@ -167,6 +167,8 @@ test_handoff_spawn_is_the_recipe() { || fail "SKILL.md must forbid prompt-on-argv" grep -q 'handoff-spawn' "$ROOT/skills/handoff/MANIFEST" \ || fail "MANIFEST must list handoff-spawn" + grep -q 'scripts/git-backend.sh' "$ROOT/skills/handoff/MANIFEST" \ + || fail "MANIFEST must list git-backend.sh" grep -qi 'poteto\|pstack' "$ROOT/skills/handoff/SKILL.md" \ && fail "SKILL.md must not keep a cursor-only poteto/pstack mode" printf 'PASS: handoff skill recipe is the spawn script\n' @@ -179,5 +181,15 @@ test_opencode_reads_agents_skills test_custom_agent_canonical_only test_grok_gets_extra_skill_link test_pi_gets_extra_skill_link +test_deploy_lib_copies_git_backend() { + deploy_lib >/dev/null + cmp -s "$ROOT/skills/handoff/scripts/git-backend.sh" "$AGENTIC_DEV_SHARE_DIR/lib/git-backend.sh" \ + || fail "installed lib/git-backend.sh must match the skill script" + cmp -s "$ROOT/skills/handoff/scripts/git-backend.sh" "$ROOT/lib/git-backend.sh" \ + || fail "repo lib/git-backend.sh must be the skill script" + printf 'PASS: deploy_lib copies git-backend.sh next to the other libs\n' +} + test_handoff_spawn_is_the_recipe test_review_skill_recipe +test_deploy_lib_copies_git_backend