From 0c58724fc3cbefe9ed2d2ec645a15bb555df88f4 Mon Sep 17 00:00:00 2001 From: Sandesh Devaraju Date: Tue, 28 Jul 2026 14:16:36 -0700 Subject: [PATCH 1/6] Add Graphite workflow attribution suite plan (540 scenarios) Empirically-grounded scenario matrix for proving AI attribution survives every Graphite workflow permutation: workflow catalog from ordered debug-log traces (gt sync's 107-command anatomy incl. reset -q --keep for checked-out moves and the synthetic-base commit-tree/merge-tree rewrite core), fleet command-shape summaries from 7 engineers, gt binary string mining, and the restack-undo repro. generate_matrix.py is the single source of truth and emits scenarios.json for the Rust harness. Co-Authored-By: Claude Fable 5 --- tests/integration/graphite_workflows/PLAN.md | 181 + .../graphite_workflows/generate_matrix.py | 206 + .../graphite_workflows/scenarios.json | 4913 +++++++++++++++++ 3 files changed, 5300 insertions(+) create mode 100644 tests/integration/graphite_workflows/PLAN.md create mode 100644 tests/integration/graphite_workflows/generate_matrix.py create mode 100644 tests/integration/graphite_workflows/scenarios.json diff --git a/tests/integration/graphite_workflows/PLAN.md b/tests/integration/graphite_workflows/PLAN.md new file mode 100644 index 0000000000..ce397e9dd1 --- /dev/null +++ b/tests/integration/graphite_workflows/PLAN.md @@ -0,0 +1,181 @@ +# Graphite Workflow Attribution Suite — Plan + +Goal: an exhaustive, empirically-grounded scenario suite proving that AI +attribution survives (or documenting exactly where it does not survive) +**every** Graphite workflow permutation. 540 scenarios across 19 families, +generated from `generate_matrix.py` (single source of truth; IDs are stable +and append-only) into `scenarios.json`. + +Assertion convention: every scenario writes files whose content is **100% +AI-authored** via the `mock_ai` checkpoint harness, so the invariant is +maximally simple — *after the workflow, `git-ai blame` must attribute every +line of every AI file to the AI*, asserted with +`TestFile::assert_committed_lines(lines![...all .ai()])` (plus +`read_authorship_note` presence checks on every rewritten commit). Any +deviation is either an application bug or a harness bug; the triage protocol +below distinguishes them. **We do not expect all scenarios to pass** — at +this point a failure is at least as likely to be a real attribution bug as a +test bug. + +## Part 1 — Empirical workflow catalog + +Evidence sources: (a) raw Graphite debug logs from this machine (exact +ordered sequences; gt 1.8.6), (b) redacted command-shape summaries from six +more engineers (1,971 invocations; incl. gt 1.7.13), (c) string mining of +the gt 1.8.6 binary, (d) the upstream restack-undo repro +(`scripts/repro/daemon-restack-undo-mapping-bomb`, PRs #1976/#1978/#1983). +Confidence tags: [E] fully empirical ordered sequence; [S] shape-set +reconstruction (commands certain, exact order inferred); [B] binary-derived. + +### gt sync (restack variant) — [E], 107-command trace + +Anatomy (attribution-relevant core only): + +``` +branch --show-current ; rev-parse HEAD +stash create # refless snapshot commit +ls-files --others --exclude-standard # untracked inventory +fetch --no-write-fetch-head --no-tags -f origin +update-ref refs/heads/ # trunk move (not checked out) +update-index --refresh ; status -z +reset -q --keep -- # trunk move (CHECKED OUT) ⚠ +# per stack branch, bottom-up: +cat-file -p ~ # read old parent +commit-tree ^{tree} -p ~ -m _ # SYNTHETIC base commit +merge-tree --allow-unrelated-histories +commit-tree -p -m +update-ref refs/heads/ # branch move +# epilogue: +stash create ; ls-files --others # second snapshot +``` + +Key facts: **no `git rebase` anywhere**; the checked-out branch moves via +`reset -q --keep` (the exact #1976 forward-reset shape) — in EVERY sync +where trunk advanced, not only undo; rewritten commits are minted with +`commit-tree` (no commit hook, no HEAD movement until update-ref); a +mid-restack failure was captured live (`cleanRebaseMergeTree` exit 1) +after which gt proceeded with remaining branches. + +### gt restack — [S] same rewrite core, no fetch/trunk-move prologue. + +### gt create — [S] stash create → branch creation → `commit [-m] [-q]` +(fleet shapes: `commit -m + -q`, `commit -q`, `commit -p -q`) → +metadata refs. Stash-wrapped commit hazard fires here in fleet data. + +### gt modify — [S] amend flow: `commit --amend`-shape on HEAD (fleet: +`commit` bare/`-a`/`--no-interactive` variants) followed by the restack +core for all descendants. Heaviest users in fleet data are the engineers +from the empty-note incident. + +### gt submit — [S] restack-if-needed, then per branch: +`push --force-with-lease[=]… --progress --no-verify --atomic`. +100% of fleet submits carry `--no-verify` — hooks NEVER run on submit. + +### Conflict fallback: gt continue / gt abort — [S]+[B] +When `merge-tree` output is conflicted (`cleanRebaseMergeTree` fails), gt +falls back to a REAL `git rebase` for that branch; binary strings confirm +`rebaseInProgress`, "Hit a conflict during rebase", +`git rebase --abort` (gt abort), and continue flows. Fleet: `gt continue` +used by 3/7 engineers, `gt abort` by 1/7. + +### gt undo (restack undo) — [B]+repro +`git reset -q --keep ` moving the branch BACKWARD across +the fork point (the #1978/#1983 mapping-bomb shape; #1976 working-log +orphan shape forward). + +### gt move --onto — [S] rewrite core with a non-trunk `onto`; descendants +restacked. + +### Housekeeping (checkout/co/track/trunk/info/log) — [E for info] read-only ++ metadata refs; control group. + +### gt 1.7.13 flavor — [S] hash-object -w --stdin storms (4,023 in one +engineer's week) + `for-each-ref --sort` + eager metadata `update-ref` +bursts fired even from read commands. + +### update-ref --stdin — [B] batch ref moves in one git process +(`updateRefs`): the daemon sees ONE command moving MANY refs. + +### Error/undo semantics observed +- merge-tree conflict → per-branch fallback (rebase) or skip; sync + continues with remaining branches [E: exit-1 capture]. +- dirty-worktree refusal: "stash your unstaged changes" guidance; + `stash --keep-index` suggested [B]. +- queue-eviction recovery: gt instructs `gt checkout; git reset; git stash + pop` [B] — i.e. user-driven reset+stash-pop is a sanctioned flow. +- `gt abort`: `git rebase --abort` + gt snapshot restore [B]. + +## Part 2 — Harness design + +`tests/integration/graphite_workflows/` (module registered in +`tests/integration/main.rs`): + +- `gt_sim.rs` — the simulation layer. One function per workflow family + replaying the EXACT command sequences above via `TestRepo::git` (traced) + or `git_og_with_env` + TRACE2_DISABLED (blind phases), parameterized by + `Scenario`. Synthetic-base restack implemented faithfully: + `cat-file`/`commit-tree -m _`/`merge-tree`/`commit-tree`/`update-ref` + (individual or `--stdin` per the `refstyle`), `reset -q --keep` for + checked-out moves, `stash create` snapshots at the boundaries. +- `scenario.rs` — `Scenario` struct + loader for `scenarios.json` + (serde, `include_str!`). +- `stackbuilder.rs` — constructs the five stack shapes with `mock_ai` + checkpoints so every content line is AI-attributed; returns a + `StackState` (branch → (files, expected lines, commit sha)). +- `assertions.rs` — post-workflow invariant checker: for every AI file on + every surviving branch, `assert_committed_lines(all .ai())`; for every + rewritten commit, authorship note exists with non-empty attestations; + for `blind` scenarios, run the daemon-restart + traced-poke recovery + step first (reconciliation window), then assert. +- Family test files (`sync_ff.rs`, `sync_restack.rs`, …): one `#[test]` + per (family × observation × repeat) bucket iterating its scenario rows; + failures collected per scenario ID and reported in one panic message + per bucket, e.g. `GT-SYNC_RESTACK-042: feature.txt line 3 blamed human, + expected ai`. This keeps test-symbol count ~45 while preserving + per-scenario triage. + +Runtime budget: fresh `TestRepo` (shared-daemon scope) per scenario, +~5-15 s each → 540 scenarios ≈ 1.5-2.5 h wall with `--test-threads=4`. +Run load-gated; never overlap with other cargo runs (see triage log from +2026-07-26: overloaded-host runs produce mass false failures). + +## Part 3 — Triage protocol + +For each failing scenario ID: +1. Re-run its bucket alone on a calm machine; discard load-flakes. +2. Reproduce manually at minimal scale; inspect `git-ai blame`, the note + content, and the daemon log. +3. Classify: **(A) genuine attribution loss** — file issue reference and + suspected mechanism (empty-note / stranded translation / working-log + orphan / mapping fabrication / blind-window); mark the scenario in + `EXPECTED_FAILURES.md` with the classification, keep the test failing + in a dedicated `#[ignore = "known: "]` bucket variant; + **(B) harness bug** — fix the harness, re-run. +4. Group A-failures by mechanism for the PR report. + +## Part 4 — Scenario matrix + +Counts by family (total **540**; full table = `generate_matrix.py +--markdown`, canonical data = `scenarios.json`): + +| family | n | core risk it probes | +|---|---|---| +| SYNC_FF | 32 | forward `reset --keep` on checked-out trunk (#1976) | +| SYNC_RESTACK | 160 | merge-tree/commit-tree/update-ref rewrite + note translation | +| RESTACK | 40 | rewrite core without fetch noise | +| CREATE | 16 | stash-wrapped fresh commits | +| MODIFY | 48 | amend + descendant restack (empty-note incident shape) | +| SUBMIT | 12 | --no-verify --atomic pushes; note push/sync behavior | +| CONFLICT_CONTINUE | 24 | rebase fallback mid-gt-flow, resolve, continue | +| CONFLICT_ABORT | 16 | rebase --abort + snapshot restore round-trip | +| UNDO | 24 | backward reset --keep across fork point (#1978/#1983) | +| MOVE_ONTO | 8 | non-trunk onto + descendant moves | +| HOUSEKEEPING | 4 | control: metadata flows must be attribution-inert | +| FLAVOR_17X | 4 | 1.7.x hash-object/metadata storms | +| WORKTREE | 40 | linked-worktree execution (per-worktree logs/reflogs) | +| REFSTDIN | 40 | single update-ref --stdin moving many refs | +| RENAME | 12 | rename-following through merge-tree rewrites | +| MERGE_IN_STACK | 4 | merge-commit mapping in restacks | +| PARTIAL_STAGE | 16 | staged/unstaged/untracked mix at stash boundaries | +| LIFECYCLE | 32 | create→modify→sync→submit chains end-to-end | +| NESTED_CONFLICT | 8 | two conflicts in one sync | diff --git a/tests/integration/graphite_workflows/generate_matrix.py b/tests/integration/graphite_workflows/generate_matrix.py new file mode 100644 index 0000000000..f57fb45206 --- /dev/null +++ b/tests/integration/graphite_workflows/generate_matrix.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +"""Generate the exhaustive Graphite-workflow scenario matrix. + +Single source of truth for the scenario suite: emits + - scenarios.json (consumed by the Rust harness via include_str!) + - the scenario table appended to PLAN.md (run with --markdown) + +Scenario IDs are stable: GT--. Do not renumber; append only. +""" +import itertools +import json +import sys + +# Dimension vocabularies ----------------------------------------------------- + +STACKS = { + "single": "one branch off trunk, 1 AI commit", + "stack2_bot": "2-branch stack, change under test on the BOTTOM branch", + "stack2_top": "2-branch stack, change under test on the TOP branch", + "stack3_mid": "3-branch stack, change under test on the MIDDLE branch", + "stack3_all": "3-branch stack, AI commits on ALL branches", +} + +ATTRIBUTION = { + "committed": "AI work committed with notes before the workflow", + "uncommitted": "AI checkpoint in the working log, not yet committed", + "mixed": "committed AI commit plus uncommitted AI edits on top", + "multifile": "one commit touching 3 AI files (rename-adjacent shapes)", +} + +TRUNK = { + "ff": "trunk unchanged or clean fast-forward", + "diverged": "trunk advanced with non-overlapping changes (real restack)", + "overlap": "trunk advanced touching the same file regions (conflict fuel)", +} + +OBSERVATION = { + "traced": "daemon observes every command (normal laptop)", + "blind": "rewrite phase executed raw (trace2 off), daemon restarted after " + "-- reconciliation/replay territory", +} + +REPEAT = { + "once": "run the workflow once", + "twice": "run the workflow twice back-to-back (idempotency; second run " + "is usually a no-op and must not disturb notes)", +} + +# Workflow families and their applicable dimensions --------------------------- +# (workflow, stacks, attribution, trunk, observation, repeat) + +FAMILIES = [ + # fam, description, stacks, attrs, trunks, observations, repeats + ("SYNC_FF", + "gt sync trunk fast-forward: fetch; update-ref trunk (or `reset -q --keep` " + "when trunk/branch is checked out); stash create/ls-files snapshot around it", + ["single", "stack2_bot"], list(ATTRIBUTION), ["ff"], list(OBSERVATION), + list(REPEAT)), + ("SYNC_RESTACK", + "gt sync with restack: fetch + trunk move, then per-branch synthetic-base " + "commit-tree -> merge-tree --allow-unrelated-histories -> commit-tree(real " + "msg) -> update-ref, bottom-up through the stack", + list(STACKS), list(ATTRIBUTION), ["diverged", "overlap"], + list(OBSERVATION), list(REPEAT)), + ("RESTACK", + "gt restack without fetch: same rewrite core as SYNC_RESTACK, no remote", + list(STACKS), list(ATTRIBUTION), ["diverged"], list(OBSERVATION), + ["once"]), + ("CREATE", + "gt create: stash-wrapped branch creation + commit (stash create; " + "checkout -b; commit; metadata refs)", + ["single", "stack2_top"], list(ATTRIBUTION), ["ff"], list(OBSERVATION), + ["once"]), + ("MODIFY", + "gt modify: amend HEAD (commit --amend shape) then restack descendants " + "via the rewrite core", + ["single", "stack2_bot", "stack3_mid"], list(ATTRIBUTION), ["ff"], + list(OBSERVATION), list(REPEAT)), + ("SUBMIT", + "gt submit: restack if needed, then push --force-with-lease... " + "--no-verify --atomic per branch", + ["single", "stack2_bot", "stack3_all"], ["committed", "mixed"], + ["ff", "diverged"], ["traced"], ["once"]), + ("CONFLICT_CONTINUE", + "restack hits a real conflict (merge-tree unresolvable / " + "cleanRebaseMergeTree failure) -> gt falls back to real git rebase -> " + "user resolves -> gt continue", + ["single", "stack2_bot", "stack3_mid"], list(ATTRIBUTION), ["overlap"], + list(OBSERVATION), ["once"]), + ("CONFLICT_ABORT", + "same conflict entry, then gt abort -> git rebase --abort + gt state " + "restore; branch must return to pre-workflow tip with notes intact", + ["single", "stack2_bot"], list(ATTRIBUTION), ["overlap"], + list(OBSERVATION), ["once"]), + ("UNDO", + "gt undo of a completed restack: reset -q --keep " + "moving the branch backward (fork-point-spanning ranges)", + ["single", "stack2_bot", "stack3_mid"], ["committed", "mixed"], + ["diverged"], list(OBSERVATION), list(REPEAT)), + ("MOVE_ONTO", + "gt move --onto: rewrite core applied with a different parent (not " + "trunk); descendants restacked", + ["stack2_bot", "stack3_mid"], ["committed", "uncommitted"], ["ff"], + list(OBSERVATION), ["once"]), + ("HOUSEKEEPING", + "gt checkout/track/trunk/info: metadata-only flows -- control group, " + "must never touch attribution", + ["single", "stack3_all"], ["committed", "uncommitted"], ["ff"], + ["traced"], ["twice"]), + ("FLAVOR_17X", + "gt 1.7.x shapes: hash-object -w --stdin storms + eager metadata " + "update-refs interleaved into the SYNC_RESTACK core", + ["single", "stack2_bot"], ["committed", "uncommitted"], ["diverged"], + ["traced"], ["once"]), + # --- appended families (IDs stable; append-only) --- + ("WORKTREE", + "SYNC_RESTACK and MODIFY cores executed from a LINKED WORKTREE " + "(.claude/worktrees pattern observed in production logs): per-worktree " + "HEAD reflog, per-worktree working log, shared refs", + list(STACKS), list(ATTRIBUTION), ["diverged"], list(OBSERVATION), + ["once"]), + ("REFSTDIN", + "SYNC_RESTACK core but all branch moves applied via a single " + "`update-ref --stdin` batch (binary-evidenced flavor): the daemon sees " + "ONE command move many refs", + list(STACKS), list(ATTRIBUTION), ["diverged"], ["traced", "blind"], + ["once"]), + ("RENAME", + "AI commit renames a file (and edits it) then goes through the restack " + "core; rename-following in note translation under merge-tree rewrites", + ["single", "stack2_bot", "stack3_mid"], ["committed", "multifile"], + ["diverged"], list(OBSERVATION), ["once"]), + ("MERGE_IN_STACK", + "a merge commit inside the stack (pull-merge shape) goes through the " + "restack core; exercises derive_merge_commit_mappings", + ["stack2_bot", "stack3_mid"], ["committed"], ["diverged"], + list(OBSERVATION), ["once"]), + ("PARTIAL_STAGE", + "workflow starts with a working tree mixing staged AI edits, unstaged " + "AI edits, and untracked files (`stash create` + ls-files snapshot " + "boundary conditions)", + ["single", "stack2_bot"], ["uncommitted", "mixed"], ["ff", "diverged"], + list(OBSERVATION), ["once"]), + ("LIFECYCLE", + "full-chain composites: create -> modify -> sync(restack) -> submit; " + "create -> sync(conflict->continue) -> modify -> submit; " + "create -> sync -> undo -> sync again; attribution must survive the " + "entire chain end-to-end", + ["stack2_bot", "stack3_all"], list(ATTRIBUTION), ["diverged"], + list(OBSERVATION), list(REPEAT)), + ("NESTED_CONFLICT", + "conflicts at TWO stack levels in one sync: resolve+continue the first, " + "then hit and resolve/abort the second", + ["stack2_bot", "stack3_mid"], ["committed", "mixed"], ["overlap"], + list(OBSERVATION), ["once"]), +] + + +def enumerate_scenarios(): + scenarios = [] + for fam, desc, stacks, attrs, trunks, observations, repeats in FAMILIES: + n = 0 + for stack, attr, trunk, obs, rep in itertools.product( + stacks, attrs, trunks, observations, repeats): + n += 1 + scenarios.append({ + "id": f"GT-{fam}-{n:03d}", + "family": fam, + "stack": stack, + "attribution": attr, + "trunk": trunk, + "observation": obs, + "repeat": rep, + }) + return scenarios + + +def main(): + scenarios = enumerate_scenarios() + if "--markdown" in sys.argv: + fams = {} + for s in scenarios: + fams.setdefault(s["family"], []).append(s) + print(f"Total scenarios: {len(scenarios)}\n") + for fam, items in fams.items(): + print(f"### {fam} ({len(items)} scenarios)\n") + print("| id | stack | attribution | trunk | observation | repeat |") + print("|---|---|---|---|---|---|") + for s in items: + print(f"| {s['id']} | {s['stack']} | {s['attribution']} " + f"| {s['trunk']} | {s['observation']} | {s['repeat']} |") + print() + else: + json.dump({ + "dimensions": { + "stack": STACKS, "attribution": ATTRIBUTION, "trunk": TRUNK, + "observation": OBSERVATION, "repeat": REPEAT, + }, + "families": {f[0]: f[1] for f in FAMILIES}, + "scenarios": scenarios, + }, sys.stdout, indent=1) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() diff --git a/tests/integration/graphite_workflows/scenarios.json b/tests/integration/graphite_workflows/scenarios.json new file mode 100644 index 0000000000..e0b229392e --- /dev/null +++ b/tests/integration/graphite_workflows/scenarios.json @@ -0,0 +1,4913 @@ +{ + "dimensions": { + "stack": { + "single": "one branch off trunk, 1 AI commit", + "stack2_bot": "2-branch stack, change under test on the BOTTOM branch", + "stack2_top": "2-branch stack, change under test on the TOP branch", + "stack3_mid": "3-branch stack, change under test on the MIDDLE branch", + "stack3_all": "3-branch stack, AI commits on ALL branches" + }, + "attribution": { + "committed": "AI work committed with notes before the workflow", + "uncommitted": "AI checkpoint in the working log, not yet committed", + "mixed": "committed AI commit plus uncommitted AI edits on top", + "multifile": "one commit touching 3 AI files (rename-adjacent shapes)" + }, + "trunk": { + "ff": "trunk unchanged or clean fast-forward", + "diverged": "trunk advanced with non-overlapping changes (real restack)", + "overlap": "trunk advanced touching the same file regions (conflict fuel)" + }, + "observation": { + "traced": "daemon observes every command (normal laptop)", + "blind": "rewrite phase executed raw (trace2 off), daemon restarted after -- reconciliation/replay territory" + }, + "repeat": { + "once": "run the workflow once", + "twice": "run the workflow twice back-to-back (idempotency; second run is usually a no-op and must not disturb notes)" + } + }, + "families": { + "SYNC_FF": "gt sync trunk fast-forward: fetch; update-ref trunk (or `reset -q --keep` when trunk/branch is checked out); stash create/ls-files snapshot around it", + "SYNC_RESTACK": "gt sync with restack: fetch + trunk move, then per-branch synthetic-base commit-tree -> merge-tree --allow-unrelated-histories -> commit-tree(real msg) -> update-ref, bottom-up through the stack", + "RESTACK": "gt restack without fetch: same rewrite core as SYNC_RESTACK, no remote", + "CREATE": "gt create: stash-wrapped branch creation + commit (stash create; checkout -b; commit; metadata refs)", + "MODIFY": "gt modify: amend HEAD (commit --amend shape) then restack descendants via the rewrite core", + "SUBMIT": "gt submit: restack if needed, then push --force-with-lease... --no-verify --atomic per branch", + "CONFLICT_CONTINUE": "restack hits a real conflict (merge-tree unresolvable / cleanRebaseMergeTree failure) -> gt falls back to real git rebase -> user resolves -> gt continue", + "CONFLICT_ABORT": "same conflict entry, then gt abort -> git rebase --abort + gt state restore; branch must return to pre-workflow tip with notes intact", + "UNDO": "gt undo of a completed restack: reset -q --keep moving the branch backward (fork-point-spanning ranges)", + "MOVE_ONTO": "gt move --onto: rewrite core applied with a different parent (not trunk); descendants restacked", + "HOUSEKEEPING": "gt checkout/track/trunk/info: metadata-only flows -- control group, must never touch attribution", + "FLAVOR_17X": "gt 1.7.x shapes: hash-object -w --stdin storms + eager metadata update-refs interleaved into the SYNC_RESTACK core", + "WORKTREE": "SYNC_RESTACK and MODIFY cores executed from a LINKED WORKTREE (.claude/worktrees pattern observed in production logs): per-worktree HEAD reflog, per-worktree working log, shared refs", + "REFSTDIN": "SYNC_RESTACK core but all branch moves applied via a single `update-ref --stdin` batch (binary-evidenced flavor): the daemon sees ONE command move many refs", + "RENAME": "AI commit renames a file (and edits it) then goes through the restack core; rename-following in note translation under merge-tree rewrites", + "MERGE_IN_STACK": "a merge commit inside the stack (pull-merge shape) goes through the restack core; exercises derive_merge_commit_mappings", + "PARTIAL_STAGE": "workflow starts with a working tree mixing staged AI edits, unstaged AI edits, and untracked files (`stash create` + ls-files snapshot boundary conditions)", + "LIFECYCLE": "full-chain composites: create -> modify -> sync(restack) -> submit; create -> sync(conflict->continue) -> modify -> submit; create -> sync -> undo -> sync again; attribution must survive the entire chain end-to-end", + "NESTED_CONFLICT": "conflicts at TWO stack levels in one sync: resolve+continue the first, then hit and resolve/abort the second" + }, + "scenarios": [ + { + "id": "GT-SYNC_FF-001", + "family": "SYNC_FF", + "stack": "single", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_FF-002", + "family": "SYNC_FF", + "stack": "single", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_FF-003", + "family": "SYNC_FF", + "stack": "single", + "attribution": "committed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_FF-004", + "family": "SYNC_FF", + "stack": "single", + "attribution": "committed", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_FF-005", + "family": "SYNC_FF", + "stack": "single", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_FF-006", + "family": "SYNC_FF", + "stack": "single", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_FF-007", + "family": "SYNC_FF", + "stack": "single", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_FF-008", + "family": "SYNC_FF", + "stack": "single", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_FF-009", + "family": "SYNC_FF", + "stack": "single", + "attribution": "mixed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_FF-010", + "family": "SYNC_FF", + "stack": "single", + "attribution": "mixed", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_FF-011", + "family": "SYNC_FF", + "stack": "single", + "attribution": "mixed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_FF-012", + "family": "SYNC_FF", + "stack": "single", + "attribution": "mixed", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_FF-013", + "family": "SYNC_FF", + "stack": "single", + "attribution": "multifile", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_FF-014", + "family": "SYNC_FF", + "stack": "single", + "attribution": "multifile", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_FF-015", + "family": "SYNC_FF", + "stack": "single", + "attribution": "multifile", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_FF-016", + "family": "SYNC_FF", + "stack": "single", + "attribution": "multifile", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_FF-017", + "family": "SYNC_FF", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_FF-018", + "family": "SYNC_FF", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_FF-019", + "family": "SYNC_FF", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_FF-020", + "family": "SYNC_FF", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_FF-021", + "family": "SYNC_FF", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_FF-022", + "family": "SYNC_FF", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_FF-023", + "family": "SYNC_FF", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_FF-024", + "family": "SYNC_FF", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_FF-025", + "family": "SYNC_FF", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_FF-026", + "family": "SYNC_FF", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_FF-027", + "family": "SYNC_FF", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_FF-028", + "family": "SYNC_FF", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_FF-029", + "family": "SYNC_FF", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_FF-030", + "family": "SYNC_FF", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_FF-031", + "family": "SYNC_FF", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_FF-032", + "family": "SYNC_FF", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-001", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-002", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-003", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-004", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-005", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "committed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-006", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "committed", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-007", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "committed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-008", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "committed", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-009", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-010", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-011", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-012", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-013", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-014", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-015", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-016", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-017", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-018", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-019", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-020", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-021", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "mixed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-022", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "mixed", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-023", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "mixed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-024", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "mixed", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-025", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-026", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-027", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-028", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-029", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "multifile", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-030", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "multifile", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-031", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "multifile", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-032", + "family": "SYNC_RESTACK", + "stack": "single", + "attribution": "multifile", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-033", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-034", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-035", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-036", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-037", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-038", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-039", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-040", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-041", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-042", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-043", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-044", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-045", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-046", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-047", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-048", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-049", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-050", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-051", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-052", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-053", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-054", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-055", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-056", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-057", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-058", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-059", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-060", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-061", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-062", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-063", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-064", + "family": "SYNC_RESTACK", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-065", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-066", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-067", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-068", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-069", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "committed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-070", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "committed", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-071", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "committed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-072", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "committed", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-073", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-074", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-075", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-076", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-077", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-078", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-079", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-080", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-081", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-082", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-083", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-084", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-085", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "mixed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-086", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "mixed", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-087", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "mixed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-088", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "mixed", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-089", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-090", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-091", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-092", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-093", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "multifile", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-094", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "multifile", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-095", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "multifile", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-096", + "family": "SYNC_RESTACK", + "stack": "stack2_top", + "attribution": "multifile", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-097", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-098", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-099", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-100", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-101", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-102", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-103", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-104", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-105", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-106", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-107", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-108", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-109", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-110", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-111", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-112", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-113", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-114", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-115", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-116", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-117", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-118", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-119", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-120", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-121", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-122", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-123", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-124", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-125", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-126", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-127", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-128", + "family": "SYNC_RESTACK", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-129", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-130", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-131", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-132", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-133", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-134", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-135", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-136", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-137", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-138", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-139", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-140", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-141", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-142", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-143", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-144", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-145", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-146", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-147", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-148", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-149", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-150", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-151", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-152", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-153", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-154", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-155", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-156", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-157", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-158", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "overlap", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-SYNC_RESTACK-159", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-SYNC_RESTACK-160", + "family": "SYNC_RESTACK", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "overlap", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-RESTACK-001", + "family": "RESTACK", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-002", + "family": "RESTACK", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-003", + "family": "RESTACK", + "stack": "single", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-004", + "family": "RESTACK", + "stack": "single", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-005", + "family": "RESTACK", + "stack": "single", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-006", + "family": "RESTACK", + "stack": "single", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-007", + "family": "RESTACK", + "stack": "single", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-008", + "family": "RESTACK", + "stack": "single", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-009", + "family": "RESTACK", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-010", + "family": "RESTACK", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-011", + "family": "RESTACK", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-012", + "family": "RESTACK", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-013", + "family": "RESTACK", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-014", + "family": "RESTACK", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-015", + "family": "RESTACK", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-016", + "family": "RESTACK", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-017", + "family": "RESTACK", + "stack": "stack2_top", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-018", + "family": "RESTACK", + "stack": "stack2_top", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-019", + "family": "RESTACK", + "stack": "stack2_top", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-020", + "family": "RESTACK", + "stack": "stack2_top", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-021", + "family": "RESTACK", + "stack": "stack2_top", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-022", + "family": "RESTACK", + "stack": "stack2_top", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-023", + "family": "RESTACK", + "stack": "stack2_top", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-024", + "family": "RESTACK", + "stack": "stack2_top", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-025", + "family": "RESTACK", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-026", + "family": "RESTACK", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-027", + "family": "RESTACK", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-028", + "family": "RESTACK", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-029", + "family": "RESTACK", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-030", + "family": "RESTACK", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-031", + "family": "RESTACK", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-032", + "family": "RESTACK", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-033", + "family": "RESTACK", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-034", + "family": "RESTACK", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-035", + "family": "RESTACK", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-036", + "family": "RESTACK", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-037", + "family": "RESTACK", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-038", + "family": "RESTACK", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RESTACK-039", + "family": "RESTACK", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RESTACK-040", + "family": "RESTACK", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CREATE-001", + "family": "CREATE", + "stack": "single", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CREATE-002", + "family": "CREATE", + "stack": "single", + "attribution": "committed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CREATE-003", + "family": "CREATE", + "stack": "single", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CREATE-004", + "family": "CREATE", + "stack": "single", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CREATE-005", + "family": "CREATE", + "stack": "single", + "attribution": "mixed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CREATE-006", + "family": "CREATE", + "stack": "single", + "attribution": "mixed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CREATE-007", + "family": "CREATE", + "stack": "single", + "attribution": "multifile", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CREATE-008", + "family": "CREATE", + "stack": "single", + "attribution": "multifile", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CREATE-009", + "family": "CREATE", + "stack": "stack2_top", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CREATE-010", + "family": "CREATE", + "stack": "stack2_top", + "attribution": "committed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CREATE-011", + "family": "CREATE", + "stack": "stack2_top", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CREATE-012", + "family": "CREATE", + "stack": "stack2_top", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CREATE-013", + "family": "CREATE", + "stack": "stack2_top", + "attribution": "mixed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CREATE-014", + "family": "CREATE", + "stack": "stack2_top", + "attribution": "mixed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CREATE-015", + "family": "CREATE", + "stack": "stack2_top", + "attribution": "multifile", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CREATE-016", + "family": "CREATE", + "stack": "stack2_top", + "attribution": "multifile", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MODIFY-001", + "family": "MODIFY", + "stack": "single", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MODIFY-002", + "family": "MODIFY", + "stack": "single", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-003", + "family": "MODIFY", + "stack": "single", + "attribution": "committed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MODIFY-004", + "family": "MODIFY", + "stack": "single", + "attribution": "committed", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-005", + "family": "MODIFY", + "stack": "single", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MODIFY-006", + "family": "MODIFY", + "stack": "single", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-007", + "family": "MODIFY", + "stack": "single", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MODIFY-008", + "family": "MODIFY", + "stack": "single", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-009", + "family": "MODIFY", + "stack": "single", + "attribution": "mixed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MODIFY-010", + "family": "MODIFY", + "stack": "single", + "attribution": "mixed", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-011", + "family": "MODIFY", + "stack": "single", + "attribution": "mixed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MODIFY-012", + "family": "MODIFY", + "stack": "single", + "attribution": "mixed", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-013", + "family": "MODIFY", + "stack": "single", + "attribution": "multifile", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MODIFY-014", + "family": "MODIFY", + "stack": "single", + "attribution": "multifile", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-015", + "family": "MODIFY", + "stack": "single", + "attribution": "multifile", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MODIFY-016", + "family": "MODIFY", + "stack": "single", + "attribution": "multifile", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-017", + "family": "MODIFY", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MODIFY-018", + "family": "MODIFY", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-019", + "family": "MODIFY", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MODIFY-020", + "family": "MODIFY", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-021", + "family": "MODIFY", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MODIFY-022", + "family": "MODIFY", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-023", + "family": "MODIFY", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MODIFY-024", + "family": "MODIFY", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-025", + "family": "MODIFY", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MODIFY-026", + "family": "MODIFY", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-027", + "family": "MODIFY", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MODIFY-028", + "family": "MODIFY", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-029", + "family": "MODIFY", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MODIFY-030", + "family": "MODIFY", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-031", + "family": "MODIFY", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MODIFY-032", + "family": "MODIFY", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-033", + "family": "MODIFY", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MODIFY-034", + "family": "MODIFY", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-035", + "family": "MODIFY", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MODIFY-036", + "family": "MODIFY", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-037", + "family": "MODIFY", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MODIFY-038", + "family": "MODIFY", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-039", + "family": "MODIFY", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MODIFY-040", + "family": "MODIFY", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-041", + "family": "MODIFY", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MODIFY-042", + "family": "MODIFY", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-043", + "family": "MODIFY", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MODIFY-044", + "family": "MODIFY", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-045", + "family": "MODIFY", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MODIFY-046", + "family": "MODIFY", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-MODIFY-047", + "family": "MODIFY", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MODIFY-048", + "family": "MODIFY", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "ff", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-SUBMIT-001", + "family": "SUBMIT", + "stack": "single", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SUBMIT-002", + "family": "SUBMIT", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SUBMIT-003", + "family": "SUBMIT", + "stack": "single", + "attribution": "mixed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SUBMIT-004", + "family": "SUBMIT", + "stack": "single", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SUBMIT-005", + "family": "SUBMIT", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SUBMIT-006", + "family": "SUBMIT", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SUBMIT-007", + "family": "SUBMIT", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SUBMIT-008", + "family": "SUBMIT", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SUBMIT-009", + "family": "SUBMIT", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SUBMIT-010", + "family": "SUBMIT", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SUBMIT-011", + "family": "SUBMIT", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-SUBMIT-012", + "family": "SUBMIT", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-001", + "family": "CONFLICT_CONTINUE", + "stack": "single", + "attribution": "committed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-002", + "family": "CONFLICT_CONTINUE", + "stack": "single", + "attribution": "committed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-003", + "family": "CONFLICT_CONTINUE", + "stack": "single", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-004", + "family": "CONFLICT_CONTINUE", + "stack": "single", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-005", + "family": "CONFLICT_CONTINUE", + "stack": "single", + "attribution": "mixed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-006", + "family": "CONFLICT_CONTINUE", + "stack": "single", + "attribution": "mixed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-007", + "family": "CONFLICT_CONTINUE", + "stack": "single", + "attribution": "multifile", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-008", + "family": "CONFLICT_CONTINUE", + "stack": "single", + "attribution": "multifile", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-009", + "family": "CONFLICT_CONTINUE", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-010", + "family": "CONFLICT_CONTINUE", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-011", + "family": "CONFLICT_CONTINUE", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-012", + "family": "CONFLICT_CONTINUE", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-013", + "family": "CONFLICT_CONTINUE", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-014", + "family": "CONFLICT_CONTINUE", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-015", + "family": "CONFLICT_CONTINUE", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-016", + "family": "CONFLICT_CONTINUE", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-017", + "family": "CONFLICT_CONTINUE", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-018", + "family": "CONFLICT_CONTINUE", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-019", + "family": "CONFLICT_CONTINUE", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-020", + "family": "CONFLICT_CONTINUE", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-021", + "family": "CONFLICT_CONTINUE", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-022", + "family": "CONFLICT_CONTINUE", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-023", + "family": "CONFLICT_CONTINUE", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_CONTINUE-024", + "family": "CONFLICT_CONTINUE", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_ABORT-001", + "family": "CONFLICT_ABORT", + "stack": "single", + "attribution": "committed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_ABORT-002", + "family": "CONFLICT_ABORT", + "stack": "single", + "attribution": "committed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_ABORT-003", + "family": "CONFLICT_ABORT", + "stack": "single", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_ABORT-004", + "family": "CONFLICT_ABORT", + "stack": "single", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_ABORT-005", + "family": "CONFLICT_ABORT", + "stack": "single", + "attribution": "mixed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_ABORT-006", + "family": "CONFLICT_ABORT", + "stack": "single", + "attribution": "mixed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_ABORT-007", + "family": "CONFLICT_ABORT", + "stack": "single", + "attribution": "multifile", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_ABORT-008", + "family": "CONFLICT_ABORT", + "stack": "single", + "attribution": "multifile", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_ABORT-009", + "family": "CONFLICT_ABORT", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_ABORT-010", + "family": "CONFLICT_ABORT", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_ABORT-011", + "family": "CONFLICT_ABORT", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_ABORT-012", + "family": "CONFLICT_ABORT", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_ABORT-013", + "family": "CONFLICT_ABORT", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_ABORT-014", + "family": "CONFLICT_ABORT", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_ABORT-015", + "family": "CONFLICT_ABORT", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-CONFLICT_ABORT-016", + "family": "CONFLICT_ABORT", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-UNDO-001", + "family": "UNDO", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-UNDO-002", + "family": "UNDO", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-UNDO-003", + "family": "UNDO", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-UNDO-004", + "family": "UNDO", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-UNDO-005", + "family": "UNDO", + "stack": "single", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-UNDO-006", + "family": "UNDO", + "stack": "single", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-UNDO-007", + "family": "UNDO", + "stack": "single", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-UNDO-008", + "family": "UNDO", + "stack": "single", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-UNDO-009", + "family": "UNDO", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-UNDO-010", + "family": "UNDO", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-UNDO-011", + "family": "UNDO", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-UNDO-012", + "family": "UNDO", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-UNDO-013", + "family": "UNDO", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-UNDO-014", + "family": "UNDO", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-UNDO-015", + "family": "UNDO", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-UNDO-016", + "family": "UNDO", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-UNDO-017", + "family": "UNDO", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-UNDO-018", + "family": "UNDO", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-UNDO-019", + "family": "UNDO", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-UNDO-020", + "family": "UNDO", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-UNDO-021", + "family": "UNDO", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-UNDO-022", + "family": "UNDO", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-UNDO-023", + "family": "UNDO", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-UNDO-024", + "family": "UNDO", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-MOVE_ONTO-001", + "family": "MOVE_ONTO", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MOVE_ONTO-002", + "family": "MOVE_ONTO", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MOVE_ONTO-003", + "family": "MOVE_ONTO", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MOVE_ONTO-004", + "family": "MOVE_ONTO", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MOVE_ONTO-005", + "family": "MOVE_ONTO", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MOVE_ONTO-006", + "family": "MOVE_ONTO", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MOVE_ONTO-007", + "family": "MOVE_ONTO", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MOVE_ONTO-008", + "family": "MOVE_ONTO", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-HOUSEKEEPING-001", + "family": "HOUSEKEEPING", + "stack": "single", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-HOUSEKEEPING-002", + "family": "HOUSEKEEPING", + "stack": "single", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-HOUSEKEEPING-003", + "family": "HOUSEKEEPING", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-HOUSEKEEPING-004", + "family": "HOUSEKEEPING", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-FLAVOR_17X-001", + "family": "FLAVOR_17X", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-FLAVOR_17X-002", + "family": "FLAVOR_17X", + "stack": "single", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-FLAVOR_17X-003", + "family": "FLAVOR_17X", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-FLAVOR_17X-004", + "family": "FLAVOR_17X", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-001", + "family": "WORKTREE", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-002", + "family": "WORKTREE", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-003", + "family": "WORKTREE", + "stack": "single", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-004", + "family": "WORKTREE", + "stack": "single", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-005", + "family": "WORKTREE", + "stack": "single", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-006", + "family": "WORKTREE", + "stack": "single", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-007", + "family": "WORKTREE", + "stack": "single", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-008", + "family": "WORKTREE", + "stack": "single", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-009", + "family": "WORKTREE", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-010", + "family": "WORKTREE", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-011", + "family": "WORKTREE", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-012", + "family": "WORKTREE", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-013", + "family": "WORKTREE", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-014", + "family": "WORKTREE", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-015", + "family": "WORKTREE", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-016", + "family": "WORKTREE", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-017", + "family": "WORKTREE", + "stack": "stack2_top", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-018", + "family": "WORKTREE", + "stack": "stack2_top", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-019", + "family": "WORKTREE", + "stack": "stack2_top", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-020", + "family": "WORKTREE", + "stack": "stack2_top", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-021", + "family": "WORKTREE", + "stack": "stack2_top", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-022", + "family": "WORKTREE", + "stack": "stack2_top", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-023", + "family": "WORKTREE", + "stack": "stack2_top", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-024", + "family": "WORKTREE", + "stack": "stack2_top", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-025", + "family": "WORKTREE", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-026", + "family": "WORKTREE", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-027", + "family": "WORKTREE", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-028", + "family": "WORKTREE", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-029", + "family": "WORKTREE", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-030", + "family": "WORKTREE", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-031", + "family": "WORKTREE", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-032", + "family": "WORKTREE", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-033", + "family": "WORKTREE", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-034", + "family": "WORKTREE", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-035", + "family": "WORKTREE", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-036", + "family": "WORKTREE", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-037", + "family": "WORKTREE", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-038", + "family": "WORKTREE", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-039", + "family": "WORKTREE", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-WORKTREE-040", + "family": "WORKTREE", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-001", + "family": "REFSTDIN", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-002", + "family": "REFSTDIN", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-003", + "family": "REFSTDIN", + "stack": "single", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-004", + "family": "REFSTDIN", + "stack": "single", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-005", + "family": "REFSTDIN", + "stack": "single", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-006", + "family": "REFSTDIN", + "stack": "single", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-007", + "family": "REFSTDIN", + "stack": "single", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-008", + "family": "REFSTDIN", + "stack": "single", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-009", + "family": "REFSTDIN", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-010", + "family": "REFSTDIN", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-011", + "family": "REFSTDIN", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-012", + "family": "REFSTDIN", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-013", + "family": "REFSTDIN", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-014", + "family": "REFSTDIN", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-015", + "family": "REFSTDIN", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-016", + "family": "REFSTDIN", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-017", + "family": "REFSTDIN", + "stack": "stack2_top", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-018", + "family": "REFSTDIN", + "stack": "stack2_top", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-019", + "family": "REFSTDIN", + "stack": "stack2_top", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-020", + "family": "REFSTDIN", + "stack": "stack2_top", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-021", + "family": "REFSTDIN", + "stack": "stack2_top", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-022", + "family": "REFSTDIN", + "stack": "stack2_top", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-023", + "family": "REFSTDIN", + "stack": "stack2_top", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-024", + "family": "REFSTDIN", + "stack": "stack2_top", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-025", + "family": "REFSTDIN", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-026", + "family": "REFSTDIN", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-027", + "family": "REFSTDIN", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-028", + "family": "REFSTDIN", + "stack": "stack3_mid", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-029", + "family": "REFSTDIN", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-030", + "family": "REFSTDIN", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-031", + "family": "REFSTDIN", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-032", + "family": "REFSTDIN", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-033", + "family": "REFSTDIN", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-034", + "family": "REFSTDIN", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-035", + "family": "REFSTDIN", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-036", + "family": "REFSTDIN", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-037", + "family": "REFSTDIN", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-038", + "family": "REFSTDIN", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-039", + "family": "REFSTDIN", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-REFSTDIN-040", + "family": "REFSTDIN", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RENAME-001", + "family": "RENAME", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RENAME-002", + "family": "RENAME", + "stack": "single", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RENAME-003", + "family": "RENAME", + "stack": "single", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RENAME-004", + "family": "RENAME", + "stack": "single", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RENAME-005", + "family": "RENAME", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RENAME-006", + "family": "RENAME", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RENAME-007", + "family": "RENAME", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RENAME-008", + "family": "RENAME", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RENAME-009", + "family": "RENAME", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RENAME-010", + "family": "RENAME", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-RENAME-011", + "family": "RENAME", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-RENAME-012", + "family": "RENAME", + "stack": "stack3_mid", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MERGE_IN_STACK-001", + "family": "MERGE_IN_STACK", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MERGE_IN_STACK-002", + "family": "MERGE_IN_STACK", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-MERGE_IN_STACK-003", + "family": "MERGE_IN_STACK", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-MERGE_IN_STACK-004", + "family": "MERGE_IN_STACK", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-PARTIAL_STAGE-001", + "family": "PARTIAL_STAGE", + "stack": "single", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-PARTIAL_STAGE-002", + "family": "PARTIAL_STAGE", + "stack": "single", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-PARTIAL_STAGE-003", + "family": "PARTIAL_STAGE", + "stack": "single", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-PARTIAL_STAGE-004", + "family": "PARTIAL_STAGE", + "stack": "single", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-PARTIAL_STAGE-005", + "family": "PARTIAL_STAGE", + "stack": "single", + "attribution": "mixed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-PARTIAL_STAGE-006", + "family": "PARTIAL_STAGE", + "stack": "single", + "attribution": "mixed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-PARTIAL_STAGE-007", + "family": "PARTIAL_STAGE", + "stack": "single", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-PARTIAL_STAGE-008", + "family": "PARTIAL_STAGE", + "stack": "single", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-PARTIAL_STAGE-009", + "family": "PARTIAL_STAGE", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-PARTIAL_STAGE-010", + "family": "PARTIAL_STAGE", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-PARTIAL_STAGE-011", + "family": "PARTIAL_STAGE", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-PARTIAL_STAGE-012", + "family": "PARTIAL_STAGE", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-PARTIAL_STAGE-013", + "family": "PARTIAL_STAGE", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "ff", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-PARTIAL_STAGE-014", + "family": "PARTIAL_STAGE", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "ff", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-PARTIAL_STAGE-015", + "family": "PARTIAL_STAGE", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-PARTIAL_STAGE-016", + "family": "PARTIAL_STAGE", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-LIFECYCLE-001", + "family": "LIFECYCLE", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-LIFECYCLE-002", + "family": "LIFECYCLE", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-LIFECYCLE-003", + "family": "LIFECYCLE", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-LIFECYCLE-004", + "family": "LIFECYCLE", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-LIFECYCLE-005", + "family": "LIFECYCLE", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-LIFECYCLE-006", + "family": "LIFECYCLE", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-LIFECYCLE-007", + "family": "LIFECYCLE", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-LIFECYCLE-008", + "family": "LIFECYCLE", + "stack": "stack2_bot", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-LIFECYCLE-009", + "family": "LIFECYCLE", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-LIFECYCLE-010", + "family": "LIFECYCLE", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-LIFECYCLE-011", + "family": "LIFECYCLE", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-LIFECYCLE-012", + "family": "LIFECYCLE", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-LIFECYCLE-013", + "family": "LIFECYCLE", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-LIFECYCLE-014", + "family": "LIFECYCLE", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-LIFECYCLE-015", + "family": "LIFECYCLE", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-LIFECYCLE-016", + "family": "LIFECYCLE", + "stack": "stack2_bot", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-LIFECYCLE-017", + "family": "LIFECYCLE", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-LIFECYCLE-018", + "family": "LIFECYCLE", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-LIFECYCLE-019", + "family": "LIFECYCLE", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-LIFECYCLE-020", + "family": "LIFECYCLE", + "stack": "stack3_all", + "attribution": "committed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-LIFECYCLE-021", + "family": "LIFECYCLE", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-LIFECYCLE-022", + "family": "LIFECYCLE", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-LIFECYCLE-023", + "family": "LIFECYCLE", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-LIFECYCLE-024", + "family": "LIFECYCLE", + "stack": "stack3_all", + "attribution": "uncommitted", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-LIFECYCLE-025", + "family": "LIFECYCLE", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-LIFECYCLE-026", + "family": "LIFECYCLE", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-LIFECYCLE-027", + "family": "LIFECYCLE", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-LIFECYCLE-028", + "family": "LIFECYCLE", + "stack": "stack3_all", + "attribution": "mixed", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-LIFECYCLE-029", + "family": "LIFECYCLE", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-LIFECYCLE-030", + "family": "LIFECYCLE", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "diverged", + "observation": "traced", + "repeat": "twice" + }, + { + "id": "GT-LIFECYCLE-031", + "family": "LIFECYCLE", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-LIFECYCLE-032", + "family": "LIFECYCLE", + "stack": "stack3_all", + "attribution": "multifile", + "trunk": "diverged", + "observation": "blind", + "repeat": "twice" + }, + { + "id": "GT-NESTED_CONFLICT-001", + "family": "NESTED_CONFLICT", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-NESTED_CONFLICT-002", + "family": "NESTED_CONFLICT", + "stack": "stack2_bot", + "attribution": "committed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-NESTED_CONFLICT-003", + "family": "NESTED_CONFLICT", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-NESTED_CONFLICT-004", + "family": "NESTED_CONFLICT", + "stack": "stack2_bot", + "attribution": "mixed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-NESTED_CONFLICT-005", + "family": "NESTED_CONFLICT", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-NESTED_CONFLICT-006", + "family": "NESTED_CONFLICT", + "stack": "stack3_mid", + "attribution": "committed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + }, + { + "id": "GT-NESTED_CONFLICT-007", + "family": "NESTED_CONFLICT", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "overlap", + "observation": "traced", + "repeat": "once" + }, + { + "id": "GT-NESTED_CONFLICT-008", + "family": "NESTED_CONFLICT", + "stack": "stack3_mid", + "attribution": "mixed", + "trunk": "overlap", + "observation": "blind", + "repeat": "once" + } + ] +} From 3b20191547772f1500ffb4b39ec649cb7fb4d71e Mon Sep 17 00:00:00 2001 From: Sandesh Devaraju Date: Tue, 28 Jul 2026 15:01:41 -0700 Subject: [PATCH 2/6] graphite_workflows: harness core (scenario loader, stackbuilder, gt_sim, assertions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harness core for the Graphite workflow attribution suite (PLAN.md): - scenario.rs: serde loader for the 540-scenario scenarios.json manifest with typed dimension enums (StackShape/Attribution/TrunkState/ Observation/Repeat) and family/by_id filters. - stackbuilder.rs: builds the five stack shapes with 100%-AI files (mock_ai checkpoints + traced commits), optional bare origin, and the trunk ff/diverged/overlap fuel (raw, trace2-off); returns StackState with per-branch expected lines + note commits. - gt_sim.rs: faithful replays of gt's command sequences — synthetic-base restack (cat-file/commit-tree -m _/merge-tree/commit-tree/update-ref or update-ref --stdin batch), reset -q --keep checked-out moves, stash create snapshots, conflict fallback to real rebase with continue/abort, undo, create/modify/submit, move --onto, housekeeping, 1.7.x hash-object storms, linked-worktree execution, partial staging, and lifecycle composites; traced vs blind Exec layer with blind recovery (daemon restart + traced poke). - assertions.rs: non-panicking invariant checker (all AI-file lines blame to AI; every surviving AI commit has a note with attestations), returning per-violation descriptions keyed by scenario ID. - smoke.rs: GT-SYNC_RESTACK-001 end-to-end through the pipeline (passes). - test_repo.rs: git_with_stdin (traced twin of git_with_env) and git_og_with_stdin_and_env (raw twin) for update-ref --stdin / hash-object -w --stdin flows. Co-Authored-By: Claude Fable 5 --- .../graphite_workflows/assertions.rs | 286 ++++++ .../integration/graphite_workflows/gt_sim.rs | 902 ++++++++++++++++++ tests/integration/graphite_workflows/mod.rs | 30 + .../graphite_workflows/scenario.rs | 144 +++ tests/integration/graphite_workflows/smoke.rs | 20 + .../graphite_workflows/stackbuilder.rs | 489 ++++++++++ tests/integration/main.rs | 1 + tests/integration/repos/test_repo.rs | 104 ++ 8 files changed, 1976 insertions(+) create mode 100644 tests/integration/graphite_workflows/assertions.rs create mode 100644 tests/integration/graphite_workflows/gt_sim.rs create mode 100644 tests/integration/graphite_workflows/mod.rs create mode 100644 tests/integration/graphite_workflows/scenario.rs create mode 100644 tests/integration/graphite_workflows/smoke.rs create mode 100644 tests/integration/graphite_workflows/stackbuilder.rs diff --git a/tests/integration/graphite_workflows/assertions.rs b/tests/integration/graphite_workflows/assertions.rs new file mode 100644 index 0000000000..a555cdaa23 --- /dev/null +++ b/tests/integration/graphite_workflows/assertions.rs @@ -0,0 +1,286 @@ +//! Post-workflow invariant checker. Every scenario writes files whose content +//! is 100% AI-authored, so the invariant is: after the workflow, `git-ai +//! blame` must attribute every committed line of every AI file to the AI, and +//! every surviving AI commit must carry an authorship note with non-empty +//! attestations. +//! +//! Nothing here panics on an attribution mismatch — violations are collected +//! as descriptions (prefixed with the scenario ID) so family test files can +//! aggregate them per bucket. Harness bugs (e.g. a checkout that cannot run) +//! are also reported as violations rather than panics, keeping one scenario's +//! breakage from hiding another's result. + +use super::stackbuilder::{BranchState, StackState}; +use crate::repos::test_repo::TestRepo; +use git_ai::authorship::authorship_log_serialization::AuthorshipLog; + +/// Expected committed lines for one file, checked out on `branch`. +struct FileExpectation { + branch: String, + path: String, + lines: Vec, +} + +/// A commit that must carry an authorship note with non-empty attestations. +struct NoteExpectation { + branch: String, + commit: String, +} + +/// Check the final state against `state`'s expectations; returns one +/// human-readable description per violation (empty = pass). +pub fn assert_attribution(repo: &TestRepo, state: &StackState) -> Vec { + let sid = &state.scenario_id; + let mut violations = Vec::new(); + repo.sync_daemon_force(); + + // 1. Commit pending (working-log-only) AI edits with a traced commit so + // the invariant reduces to committed-line blame everywhere. The pending + // edits live in the working tree of whichever branch is checked out. + let pending = commit_pending_edits(repo, state, &mut violations); + + // 2. Build per-branch expectations, folding pending lines into the branch + // that received the assertion commit. + let (files, notes) = build_expectations(state, &pending); + + // 3. Verify, branch by branch (one traced checkout per branch). + let mut all_branches: Vec<&BranchState> = state.branches.iter().collect(); + if let Some(side) = &state.side_branch { + all_branches.push(side); + } + for branch in all_branches { + if let Err(error) = repo.git(&["checkout", &branch.name]) { + violations.push(format!( + "{sid}: checkout of branch {} failed: {error}", + branch.name + )); + continue; + } + repo.sync_daemon_force(); + for expectation in files.iter().filter(|f| f.branch == branch.name) { + check_file(repo, sid, expectation, &mut violations); + } + for expectation in notes.iter().filter(|n| n.branch == branch.name) { + check_note(repo, sid, expectation, &mut violations); + } + } + + violations +} + +/// Branch that received the pending-edit assertion commit, plus its sha. +struct PendingCommit { + branch: String, + commit: String, +} + +fn commit_pending_edits( + repo: &TestRepo, + state: &StackState, + violations: &mut Vec, +) -> Option { + let has_pending = state.branches.iter().any(|branch| { + branch + .files + .iter() + .any(|f| !f.uncommitted_ai_lines.is_empty()) + }); + if !has_pending { + return None; + } + + let branch = repo.current_branch(); + match repo.stage_all_and_commit("gt-sim: commit pending ai edits for assertion") { + Ok(commit) => { + repo.sync_daemon_force(); + Some(PendingCommit { + branch, + commit: commit.commit_sha, + }) + } + Err(error) => { + violations.push(format!( + "{}: committing pending AI edits for assertion failed: {}", + state.scenario_id, error + )); + None + } + } +} + +fn build_expectations( + state: &StackState, + pending: &Option, +) -> (Vec, Vec) { + let mut files = Vec::new(); + let mut notes = Vec::new(); + + let mut all_branches: Vec<&BranchState> = state.branches.iter().collect(); + if let Some(side) = &state.side_branch { + all_branches.push(side); + } + + for branch in &all_branches { + for file in &branch.files { + if !file.committed_ai_lines.is_empty() { + files.push(FileExpectation { + branch: branch.name.clone(), + path: file.path.clone(), + lines: file.committed_ai_lines.clone(), + }); + } + } + if let Some(commit) = &branch.ai_commit_sha { + notes.push(NoteExpectation { + branch: branch.name.clone(), + commit: commit.clone(), + }); + } + } + + // Pending lines were committed on `pending.branch` (typically the branch + // checked out at workflow end); expect them there, appended after the + // file's committed lines. + if let Some(pending) = pending { + for branch in &all_branches { + for file in &branch.files { + if file.uncommitted_ai_lines.is_empty() { + continue; + } + let mut lines = file.committed_ai_lines.clone(); + lines.extend(file.uncommitted_ai_lines.iter().cloned()); + if let Some(existing) = files + .iter_mut() + .find(|f| f.branch == pending.branch && f.path == file.path) + { + existing.lines = lines; + } else { + files.push(FileExpectation { + branch: pending.branch.clone(), + path: file.path.clone(), + lines, + }); + } + } + } + notes.push(NoteExpectation { + branch: pending.branch.clone(), + commit: pending.commit.clone(), + }); + } + + (files, notes) +} + +fn check_file( + repo: &TestRepo, + sid: &str, + expectation: &FileExpectation, + violations: &mut Vec, +) { + let FileExpectation { + branch, + path, + lines: expected, + } = expectation; + let blame = match repo.git_ai(&["blame", path]) { + Ok(blame) => blame, + Err(error) => { + violations.push(format!( + "{sid}: [{branch}] git-ai blame {path} failed: {error}" + )); + return; + } + }; + + // Committed lines only ("Not Committed Yet" filtered), mirroring + // TestFile::assert_committed_lines without the panics. + let committed: Vec<(String, String)> = blame + .lines() + .filter(|line| !line.trim().is_empty()) + .map(parse_blame_line) + .filter(|(author, _)| author != "Not Committed Yet") + .collect(); + + if committed.len() != expected.len() { + violations.push(format!( + "{sid}: [{branch}] {path}: {} committed lines, expected {}\nblame:\n{}", + committed.len(), + expected.len(), + blame.trim_end() + )); + return; + } + + for (index, ((author, content), expected_line)) in + committed.iter().zip(expected.iter()).enumerate() + { + let line_number = index + 1; + if content.trim() != expected_line.trim() { + violations.push(format!( + "{sid}: [{branch}] {path} line {line_number}: content {:?}, expected {:?}", + content, expected_line + )); + } else if !is_ai_author(author) { + violations.push(format!( + "{sid}: [{branch}] {path} line {line_number} ({:?}) blamed {:?}, expected ai", + content, author + )); + } + } +} + +fn check_note( + repo: &TestRepo, + sid: &str, + expectation: &NoteExpectation, + violations: &mut Vec, +) { + let NoteExpectation { branch, commit } = expectation; + let Some(note) = repo.read_authorship_note(commit) else { + violations.push(format!( + "{sid}: [{branch}] commit {commit} has no authorship note" + )); + return; + }; + match AuthorshipLog::deserialize_from_string(¬e) { + Err(error) => violations.push(format!( + "{sid}: [{branch}] commit {commit} authorship note failed to parse: {error}" + )), + Ok(log) => { + if !log + .attestations + .iter() + .any(|attestation| !attestation.entries.is_empty()) + { + violations.push(format!( + "{sid}: [{branch}] commit {commit} authorship note has empty attestations" + )); + } + } + } +} + +/// Parse `sha (author date line) content` — the same format +/// `TestFile::parse_blame_line` handles (reimplemented here because TestFile +/// re-runs blame on construction and panics on mismatch). +fn parse_blame_line(line: &str) -> (String, String) { + if let Some(start_paren) = line.find('(') + && let Some(end_paren) = line.find(')') + { + let author_section = &line[start_paren + 1..end_paren]; + let content = line[end_paren + 1..].trim(); + let author = author_section + .split_whitespace() + .take_while(|part| !part.chars().next().unwrap_or('a').is_ascii_digit()) + .collect::>() + .join(" "); + return (author, content.to_string()); + } + ("unknown".to_string(), line.to_string()) +} + +/// The harness authors all AI content as `mock_ai`. +fn is_ai_author(author: &str) -> bool { + author.to_lowercase().contains("mock_ai") +} diff --git a/tests/integration/graphite_workflows/gt_sim.rs b/tests/integration/graphite_workflows/gt_sim.rs new file mode 100644 index 0000000000..2d04236982 --- /dev/null +++ b/tests/integration/graphite_workflows/gt_sim.rs @@ -0,0 +1,902 @@ +//! Faithful replays of the Graphite CLI's git command sequences, per the +//! empirical catalog in `PLAN.md` Part 1. One function per workflow family; +//! all git traffic goes through [`Exec`], which runs either traced (real git +//! wired to the per-test daemon via trace2, daemon-synced after every tracked +//! mutating command) or blind (`git_og_with_env` + trace2 disabled) for the +//! rewrite phase of `observation = blind` scenarios. +//! +//! Key shapes implemented here: +//! - synthetic-base restack: `cat-file -p ~` / `commit-tree +//! ^{tree} -p ~ -m _` / `merge-tree +//! --allow-unrelated-histories ` / `commit-tree +//! -p -m ` / ref move (`update-ref`, one +//! `update-ref --stdin` batch, or `reset -q --keep` for the checked-out +//! branch) — no `git rebase` anywhere on the happy path; +//! - conflict fallback: real `git rebase `, then either +//! resolve-by-taking-AI-content + `rebase --continue` or `rebase --abort`; +//! - `stash create` + `ls-files --others` snapshots at flow boundaries; +//! - `gt undo`: `reset -q --keep` / `update-ref` back to pre-restack tips. + +use super::assertions; +use super::scenario::{Attribution, Observation, Scenario}; +use super::stackbuilder::{ + self, AiFileState, BranchState, StackState, TRACE2_DISABLED_ENV, TRUNK, ai_lines, + write_ai_file, write_file, +}; +use crate::repos::test_repo::{DaemonTestScope, TestRepo}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RefStyle { + /// One `update-ref refs/heads/ ` per moved ref. + Individual, + /// All ref moves of a flow batched into a single `update-ref --stdin`. + Stdin, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConflictResolution { + /// Resolve every conflicted AI file by taking the branch's AI content, + /// then `git rebase --continue` (gt continue). + TakeAiContinue, + /// `git rebase --abort` (gt abort): the branch returns to its old tip. + Abort, +} + +/// Options for the restack-style flows. +#[derive(Debug, Clone)] +pub struct FlowOpts { + pub refstyle: RefStyle, + /// Per-conflict resolutions, consumed in stack order; conflicts beyond the + /// list reuse the last entry (empty list means TakeAiContinue). + pub policies: Vec, + /// FLAVOR_17X: interleave `hash-object -w --stdin` storms and eager + /// metadata `update-ref`s into the flow. + pub storm: bool, +} + +impl Default for FlowOpts { + fn default() -> Self { + Self { + refstyle: RefStyle::Individual, + policies: Vec::new(), + storm: false, + } + } +} + +impl FlowOpts { + fn for_scenario(scenario: &Scenario) -> Self { + Self { + refstyle: if scenario.family == "REFSTDIN" { + RefStyle::Stdin + } else { + RefStyle::Individual + }, + policies: if scenario.family == "CONFLICT_ABORT" { + vec![ConflictResolution::Abort] + } else { + Vec::new() + }, + storm: scenario.family == "FLAVOR_17X", + } + } + + fn policy_for(&self, conflict_index: usize) -> ConflictResolution { + self.policies + .get(conflict_index) + .or(self.policies.last()) + .copied() + .unwrap_or(ConflictResolution::TakeAiContinue) + } +} + +// --------------------------------------------------------------------------- +// Execution layer +// --------------------------------------------------------------------------- + +/// Runs the workflow's git commands either traced or blind, optionally from a +/// linked worktree (`cwd`). +pub struct Exec<'a> { + repo: &'a TestRepo, + blind: bool, + cwd: Option, +} + +impl<'a> Exec<'a> { + pub fn for_scenario(repo: &'a TestRepo, scenario: &Scenario) -> Self { + Self { + repo, + blind: scenario.observation_mode() == Observation::Blind, + cwd: None, + } + } + + pub fn traced(repo: &'a TestRepo) -> Self { + Self { + repo, + blind: false, + cwd: None, + } + } + + pub fn repo(&self) -> &'a TestRepo { + self.repo + } + + /// Same mode, executed from `cwd` (linked-worktree scenarios). + pub fn at_cwd(&self, cwd: PathBuf) -> Exec<'a> { + Exec { + repo: self.repo, + blind: self.blind, + cwd: Some(cwd), + } + } + + /// Directory the workflow's files live in. + pub fn workdir(&self) -> &Path { + self.cwd.as_deref().unwrap_or_else(|| self.repo.path()) + } + + pub fn git(&self, args: &[&str]) -> String { + self.try_git(args) + .unwrap_or_else(|error| panic!("gt-sim git {:?} failed: {}", args, error)) + } + + pub fn try_git(&self, args: &[&str]) -> Result { + self.try_git_env(args, &[]) + } + + pub fn try_git_env(&self, args: &[&str], envs: &[(&str, &str)]) -> Result { + if self.blind { + let mut env: Vec<(&str, &str)> = TRACE2_DISABLED_ENV.to_vec(); + env.extend_from_slice(envs); + let cwd_arg; + let mut full: Vec<&str> = Vec::new(); + if let Some(cwd) = &self.cwd { + cwd_arg = cwd + .to_str() + .expect("worktree path should be utf-8") + .to_string(); + full.push("-C"); + full.push(&cwd_arg); + } + full.extend_from_slice(args); + return self.repo.git_og_with_env(&full, &env); + } + + let result = self.repo.git_with_env(args, envs, self.cwd.as_deref()); + if self + .repo + .git_command_affects_daemon_for_tracking(args, self.cwd.as_deref()) + { + // Traced scenarios keep the daemon caught up after every tracked + // mutating command (the run_traced_git pattern). + self.repo.sync_daemon_force(); + } + result + } + + pub fn git_stdin(&self, args: &[&str], input: &[u8]) -> String { + let result = if self.blind { + self.repo + .git_og_with_stdin_and_env(args, &TRACE2_DISABLED_ENV, input) + } else { + let result = self.repo.git_with_stdin(args, input); + if self + .repo + .git_command_affects_daemon_for_tracking(args, None) + { + self.repo.sync_daemon_force(); + } + result + }; + result.unwrap_or_else(|error| panic!("gt-sim git stdin {:?} failed: {}", args, error)) + } + + pub fn rev_parse(&self, rev: &str) -> String { + self.git(&["rev-parse", rev]).trim().to_string() + } + + pub fn current_branch(&self) -> String { + self.git(&["branch", "--show-current"]).trim().to_string() + } +} + +// --------------------------------------------------------------------------- +// Shared building blocks +// --------------------------------------------------------------------------- + +/// gt's refless snapshot at flow boundaries: `stash create` + untracked-file +/// inventory. +pub fn stash_snapshot(x: &Exec) { + x.git(&["stash", "create"]); + x.git(&["ls-files", "--others", "--exclude-standard"]); +} + +fn prologue(x: &Exec) { + x.git(&["branch", "--show-current"]); + x.git(&["rev-parse", "HEAD"]); + stash_snapshot(x); +} + +fn branch_ref(name: &str) -> String { + format!("refs/heads/{name}") +} + +/// `gt sync`'s fetch prologue; returns the fetched `origin/main` tip. +fn fetch_trunk(x: &Exec) -> String { + x.git(&[ + "fetch", + "--no-write-fetch-head", + "--no-tags", + "-f", + "origin", + &format!("refs/heads/{TRUNK}:refs/remotes/origin/{TRUNK}"), + ]); + x.rev_parse(&format!("refs/remotes/origin/{TRUNK}")) +} + +/// Move the checked-out branch: `update-index --refresh; status -z; +/// reset -q --keep --` (the #1976 shape). Returns false on a gt-style +/// refusal (e.g. an untracked file in the way); the flow then proceeds, like +/// gt does after a mid-restack failure. +pub fn checked_out_move(x: &Exec, target: &str) -> bool { + let _ = x.try_git(&["update-index", "--refresh"]); + let _ = x.try_git(&["status", "-z"]); + x.try_git(&["reset", "-q", "--keep", target, "--"]).is_ok() +} + +/// Deferred ref moves for `RefStyle::Stdin`: one `update-ref --stdin` +/// invocation applies every queued move. +#[derive(Default)] +pub struct RefBatch { + lines: Vec, +} + +impl RefBatch { + fn push(&mut self, branch: &str, new_sha: &str) { + self.lines + .push(format!("update {} {}\n", branch_ref(branch), new_sha)); + } + + fn flush(&mut self, x: &Exec) { + if self.lines.is_empty() { + return; + } + let payload = self.lines.concat(); + x.git_stdin(&["update-ref", "--stdin"], payload.as_bytes()); + self.lines.clear(); + } +} + +fn move_ref(x: &Exec, batch: &mut RefBatch, refstyle: RefStyle, branch: &str, target: &str) { + match refstyle { + RefStyle::Stdin => batch.push(branch, target), + RefStyle::Individual => { + x.git(&["update-ref", &branch_ref(branch), target]); + } + } +} + +/// gt 1.7.x metadata write: `hash-object -w --stdin` + a metadata +/// `update-ref` pointing at the blob. +pub fn write_metadata_ref(x: &Exec, branch: &str) { + let payload = format!("{{\"branch\":\"{branch}\"}}\n"); + let oid = x.git_stdin(&["hash-object", "-w", "--stdin"], payload.as_bytes()); + x.git(&[ + "update-ref", + &format!("refs/branch-metadata/{branch}"), + oid.trim(), + ]); +} + +/// gt 1.7.x `hash-object -w --stdin` storm. +pub fn hash_object_storm(x: &Exec, count: usize) { + for index in 0..count { + let payload = format!("gt 1.7.x hash-object storm payload {index}\n"); + x.git_stdin(&["hash-object", "-w", "--stdin"], payload.as_bytes()); + } +} + +// --------------------------------------------------------------------------- +// Synthetic-base restack core +// --------------------------------------------------------------------------- + +/// Restack `state.branches[first..]` bottom-up onto `new_parent`, replaying +/// each branch commit via the synthetic-base merge-tree core. +/// `old_parent_of_first` is the tip the first branch was previously based on +/// (used only to recognize pending-only/empty branches, which gt moves by ref +/// alone). Deferred `RefStyle::Stdin` moves accumulate in `batch`; the caller +/// flushes. +fn restack_range( + x: &Exec, + state: &mut StackState, + first: usize, + old_parent_of_first: &str, + new_parent_of_first: &str, + opts: &FlowOpts, + batch: &mut RefBatch, +) { + let current_branch = x.current_branch(); + let mut old_parent = old_parent_of_first.to_string(); + let mut new_parent = new_parent_of_first.to_string(); + let mut conflict_index = 0usize; + + for index in first..state.branches.len() { + let name = state.branches[index].name.clone(); + let old_tip = state.branches[index].tip_sha.clone(); + let is_checked_out = current_branch == name; + if opts.storm { + hash_object_storm(x, 20); + } + + let new_tip = if old_tip == new_parent { + // Already exactly at the new parent — nothing to do. + old_tip.clone() + } else if old_tip == old_parent { + // Pending-only branch (no commits of its own): gt moves the ref + // straight to the new parent. + if is_checked_out { + if checked_out_move(x, &new_parent) { + new_parent.clone() + } else { + old_tip.clone() + } + } else { + move_ref(x, batch, opts.refstyle, &name, &new_parent); + new_parent.clone() + } + } else if x.rev_parse(&format!("{old_tip}~")) == new_parent { + // Already based on the new parent (idempotent second run): skip. + old_tip.clone() + } else { + match replay_branch_commit(x, &old_tip, &new_parent) { + Ok(minted) => { + if is_checked_out { + if checked_out_move(x, &minted) { + minted + } else { + old_tip.clone() + } + } else { + move_ref(x, batch, opts.refstyle, &name, &minted); + minted + } + } + Err(()) => { + // merge-tree conflict (the cleanRebaseMergeTree failure): + // gt falls back to a real rebase for this branch. + let policy = opts.policy_for(conflict_index); + conflict_index += 1; + rebase_fallback(x, state, index, &new_parent, policy, ¤t_branch) + } + } + }; + + if opts.storm { + write_metadata_ref(x, &name); + } + + let rewritten = new_tip != old_tip && old_tip != old_parent; + let branch = &mut state.branches[index]; + if rewritten && branch.ai_commit_sha.is_some() { + branch.ai_commit_sha = Some(new_tip.clone()); + } + branch.tip_sha = new_tip.clone(); + old_parent = old_tip; + new_parent = new_tip; + } + + if first == 0 { + state.stack_base_sha = new_parent_of_first.to_string(); + } +} + +/// Mint the restacked commit for a single-commit branch via the observed +/// plumbing sequence. Returns Err on a merge-tree conflict. +fn replay_branch_commit(x: &Exec, old_tip: &str, new_parent: &str) -> Result { + x.git(&["cat-file", "-p", &format!("{old_tip}~")]); + let synthetic = x + .git(&[ + "commit-tree", + &format!("{new_parent}^{{tree}}"), + "-p", + &format!("{old_tip}~"), + "-m", + "_", + ]) + .trim() + .to_string(); + let merged = x + .try_git(&[ + "merge-tree", + "--allow-unrelated-histories", + &synthetic, + old_tip, + ]) + .map_err(|_| ())?; + let tree = merged + .lines() + .next() + .expect("merge-tree should print a tree oid") + .trim() + .to_string(); + let message = x + .git(&["log", "-1", "--format=%B", old_tip]) + .trim() + .to_string(); + Ok( + x.git(&["commit-tree", &tree, "-p", new_parent, "-m", &message]) + .trim() + .to_string(), + ) +} + +/// Real `git rebase ` fallback, then continue or abort. +/// Returns the branch tip after the fallback. +fn rebase_fallback( + x: &Exec, + state: &StackState, + index: usize, + new_parent: &str, + policy: ConflictResolution, + original_branch: &str, +) -> String { + let name = state.branches[index].name.clone(); + let old_tip = state.branches[index].tip_sha.clone(); + + // gt refuses to restack over unstaged tracked changes; the sanctioned + // recovery shape is a stash round-trip. + let dirty = !x + .git(&["status", "--porcelain", "--untracked-files=no"]) + .trim() + .is_empty(); + if dirty { + x.git(&["stash", "push", "-q"]); + } + + let new_tip = match x.try_git(&["rebase", new_parent, &name]) { + Ok(_) => x.rev_parse(&branch_ref(&name)), + Err(_) => match policy { + ConflictResolution::TakeAiContinue => { + resolve_conflicts_taking_ai_content(x, &state.branches[index]); + x.try_git_env(&["rebase", "--continue"], &[("GIT_EDITOR", "true")]) + .unwrap_or_else(|error| { + panic!("{}: rebase --continue failed: {}", state.scenario_id, error) + }); + x.rev_parse(&branch_ref(&name)) + } + ConflictResolution::Abort => { + x.git(&["rebase", "--abort"]); + old_tip + } + }, + }; + + // The rebase leaves HEAD on ; gt returns to where the user was. + if original_branch != name { + x.git(&["checkout", original_branch]); + } + if dirty { + x.git(&["stash", "pop", "-q"]); + } + new_tip +} + +fn resolve_conflicts_taking_ai_content(x: &Exec, branch: &BranchState) { + let conflicted = x.git(&["diff", "--name-only", "--diff-filter=U"]); + for path in conflicted.lines().map(str::trim).filter(|p| !p.is_empty()) { + if let Some(file) = branch.files.iter().find(|file| file.path == path) { + let content = file.committed_ai_lines.join("\n") + "\n"; + write_worktree_file(x, path, &content); + } else { + // Conflict on a non-AI path: take the branch side. + x.git(&["checkout", "--theirs", "--", path]); + } + x.git(&["add", "--", path]); + } +} + +fn write_worktree_file(x: &Exec, path: &str, content: &str) { + let full_path = x.workdir().join(path); + if let Some(parent) = full_path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(full_path, content).unwrap(); +} + +// --------------------------------------------------------------------------- +// Workflow families +// --------------------------------------------------------------------------- + +/// gt sync, trunk fast-forward variant: fetch, then move trunk (forward +/// `reset -q --keep` when trunk is checked out, `update-ref` otherwise). No +/// branch restacks. +pub fn sync_ff(x: &Exec, state: &mut StackState) { + prologue(x); + let target = fetch_trunk(x); + move_trunk( + x, + state, + &target, + RefStyle::Individual, + &mut RefBatch::default(), + ); + stash_snapshot(x); +} + +fn move_trunk( + x: &Exec, + state: &mut StackState, + target: &str, + refstyle: RefStyle, + batch: &mut RefBatch, +) { + if state.local_trunk_sha == target { + return; + } + if x.current_branch() == TRUNK { + if !checked_out_move(x, target) { + return; + } + } else { + move_ref(x, batch, refstyle, TRUNK, target); + } + state.local_trunk_sha = target.to_string(); +} + +/// gt sync with restack: fetch + trunk move, then the synthetic-base rewrite +/// core bottom-up through the stack. +pub fn sync_restack(x: &Exec, state: &mut StackState, opts: &FlowOpts) { + prologue(x); + if opts.storm { + hash_object_storm(x, 20); + } + let target = fetch_trunk(x); + let mut batch = RefBatch::default(); + move_trunk(x, state, &target, opts.refstyle, &mut batch); + let old_base = state.stack_base_sha.clone(); + restack_range(x, state, 0, &old_base, &target, opts, &mut batch); + batch.flush(x); + stash_snapshot(x); +} + +/// gt restack: the rewrite core onto local trunk, no fetch. +pub fn restack(x: &Exec, state: &mut StackState, opts: &FlowOpts) { + prologue(x); + let target = x.rev_parse(&branch_ref(TRUNK)); + let mut batch = RefBatch::default(); + let old_base = state.stack_base_sha.clone(); + restack_range(x, state, 0, &old_base, &target, opts, &mut batch); + batch.flush(x); + stash_snapshot(x); +} + +/// gt create: stage fresh AI work, snapshot, `checkout -b`, stash-wrapped +/// commit, metadata refs. The created branch stacks on the current top and is +/// appended to `state.branches`. +pub fn create(x: &Exec, state: &mut StackState, round: usize) { + let top = state + .branches + .last() + .expect("stack should have branches") + .name + .clone(); + if x.current_branch() != top { + x.git(&["checkout", &top]); + } + + let name = format!("gt-created-{}", round + 1); + let path = format!("gt_created_{}_ai.txt", round + 1); + let lines = ai_lines(&format!("created {}", round + 1), 3); + write_ai_file(x.repo(), &path, &lines); + x.git(&["add", "--", &path]); + x.git(&["stash", "create"]); + x.git(&["checkout", "-b", &name]); + x.git(&["commit", "-q", "-m", &format!("gt create {name}")]); + let tip = x.rev_parse("HEAD"); + write_metadata_ref(x, &name); + + state.branches.push(BranchState { + name, + files: vec![AiFileState { + path, + committed_ai_lines: lines, + uncommitted_ai_lines: Vec::new(), + }], + tip_sha: tip.clone(), + ai_commit_sha: Some(tip), + }); +} + +/// gt modify on `state.branches[index]`: checkpointed AI edit + `commit +/// --amend`, then descendant restack via the rewrite core. On a pending-only +/// branch this creates the branch's first commit instead of amending. +pub fn modify_branch( + x: &Exec, + state: &mut StackState, + index: usize, + round: usize, + opts: &FlowOpts, +) { + let name = state.branches[index].name.clone(); + if x.current_branch() != name { + x.git(&["checkout", &name]); + } + + let path = format!("{name}_amend_ai.txt"); + let line = format!("{} amend ai line r{}", name, round + 1); + let branch = &mut state.branches[index]; + let file = if let Some(file) = branch.files.iter_mut().find(|file| file.path == path) { + file + } else { + branch.files.push(AiFileState { + path: path.clone(), + committed_ai_lines: Vec::new(), + uncommitted_ai_lines: Vec::new(), + }); + branch.files.last_mut().unwrap() + }; + file.committed_ai_lines.push(line); + let content = file.committed_ai_lines.clone(); + write_ai_file(x.repo(), &path, &content); + x.git(&["add", "--", &path]); + x.git(&["stash", "create"]); + + let old_tip = state.branches[index].tip_sha.clone(); + let parent_tip = if index == 0 { + state.stack_base_sha.clone() + } else { + state.branches[index - 1].tip_sha.clone() + }; + if old_tip == parent_tip { + // Pending-only branch: gt modify creates its first commit. + x.git(&[ + "commit", + "-q", + "-m", + &format!("gt modify {name} r{}", round + 1), + ]); + } else { + x.git(&["commit", "--amend", "--no-edit", "-q"]); + } + let new_tip = x.rev_parse("HEAD"); + state.branches[index].tip_sha = new_tip.clone(); + state.branches[index].ai_commit_sha = Some(new_tip.clone()); + + let mut batch = RefBatch::default(); + restack_range(x, state, index + 1, &old_tip, &new_tip, opts, &mut batch); + batch.flush(x); +} + +/// gt submit: restack-if-needed, then per-branch +/// `push origin --progress --no-verify --atomic --force-with-lease`. +pub fn submit(x: &Exec, state: &mut StackState, opts: &FlowOpts) { + let trunk_sha = x.rev_parse(&branch_ref(TRUNK)); + if state.stack_base_sha != trunk_sha { + restack(x, state, opts); + } + for index in 0..state.branches.len() { + let name = state.branches[index].name.clone(); + x.git(&[ + "push", + "origin", + "--progress", + "--no-verify", + "--atomic", + "--force-with-lease", + &format!("{0}:{0}", branch_ref(&name)), + ]); + } +} + +/// gt undo of a completed restack: run the restack, then restore every moved +/// branch to its pre-restack tip — `reset -q --keep` backward for the +/// checked-out branch (#1978/#1983 shape), `update-ref` for the rest. +pub fn undo_restack(x: &Exec, state: &mut StackState, opts: &FlowOpts) { + let pre: Vec<(String, Option)> = state + .branches + .iter() + .map(|branch| (branch.tip_sha.clone(), branch.ai_commit_sha.clone())) + .collect(); + let pre_base = state.stack_base_sha.clone(); + + restack(x, state, opts); + + let current = x.current_branch(); + for (index, (old_tip, old_ai)) in pre.into_iter().enumerate() { + if state.branches[index].tip_sha == old_tip { + continue; + } + let name = state.branches[index].name.clone(); + let restored = if name == current { + checked_out_move(x, &old_tip) + } else { + x.git(&["update-ref", &branch_ref(&name), &old_tip]); + true + }; + if restored { + state.branches[index].tip_sha = old_tip; + state.branches[index].ai_commit_sha = old_ai; + } + } + state.stack_base_sha = pre_base; +} + +/// gt move --onto: rewrite core applied with the side branch (non-trunk) as +/// the new parent; descendants restack via the same cascade. +pub fn move_onto(x: &Exec, state: &mut StackState, opts: &FlowOpts) { + let onto = state + .side_branch + .as_ref() + .expect("MOVE_ONTO scenarios build a side branch") + .tip_sha + .clone(); + prologue(x); + let mut batch = RefBatch::default(); + let old_base = state.stack_base_sha.clone(); + restack_range(x, state, 0, &old_base, &onto, opts, &mut batch); + batch.flush(x); + stash_snapshot(x); +} + +/// gt checkout/track/trunk/info control group: reads, metadata refs, and a +/// checkout round-trip. Must be attribution-inert. +pub fn housekeeping(x: &Exec, state: &StackState) { + x.git(&["branch", "--show-current"]); + x.git(&["rev-parse", "HEAD"]); + x.git(&["log", "-1", "--format=%H"]); + for branch in &state.branches { + write_metadata_ref(x, &branch.name); + } + let current = x.current_branch(); + x.git(&["checkout", TRUNK]); + x.git(&["checkout", ¤t]); +} + +/// SYNC_RESTACK-style core executed from a linked worktree that has trunk +/// checked out (the under-test branch owns the main worktree, so its ref is +/// moved from the linked worktree — per-worktree HEAD reflog territory). +pub fn worktree_restack(x: &Exec, state: &mut StackState, opts: &FlowOpts) { + let worktree_path = std::env::temp_dir().join(format!( + "gt-sim-wt-{}-{}", + std::process::id(), + state.scenario_id.to_lowercase().replace('_', "-") + )); + let worktree_str = worktree_path.to_str().expect("worktree path utf-8"); + x.git(&["worktree", "add", worktree_str, TRUNK]); + + let wx = x.at_cwd(worktree_path.clone()); + restack(&wx, state, opts); + + x.git(&["worktree", "remove", "--force", worktree_str]); +} + +/// PARTIAL_STAGE: seed a working tree mixing staged AI edits, unstaged AI +/// edits, and an untracked file, then run the sync core for the trunk state. +pub fn partial_stage(x: &Exec, state: &mut StackState, scenario: &Scenario) { + let under_test = state.under_test; + let name = state.branches[under_test].name.clone(); + + // Staged AI file. + let staged_path = format!("{name}_staged_ai.txt"); + let staged_lines = ai_lines(&format!("{name} staged"), 3); + write_ai_file(x.repo(), &staged_path, &staged_lines); + x.git(&["add", "--", &staged_path]); + state.branches[under_test].files.push(AiFileState { + path: staged_path, + committed_ai_lines: Vec::new(), + uncommitted_ai_lines: staged_lines, + }); + + // Unstaged AI edit to a tracked AI file (mixed scenarios have one). + if let Some(file) = state.branches[under_test] + .files + .iter_mut() + .find(|file| !file.committed_ai_lines.is_empty()) + { + let line = format!("{name} unstaged ai line"); + let mut full = file.committed_ai_lines.clone(); + full.extend(file.uncommitted_ai_lines.iter().cloned()); + full.push(line.clone()); + file.uncommitted_ai_lines.push(line); + write_ai_file(x.repo(), &file.path.clone(), &full); + } + + // Untracked, unattributed scratch file (ls-files --others fodder). + write_file(x.repo(), "scratch_untracked.txt", "scratch\n"); + + match scenario.trunk_state() { + super::scenario::TrunkState::Ff => sync_ff(x, state), + _ => sync_restack(x, state, &FlowOpts::default()), + } +} + +/// LIFECYCLE composite: create -> modify -> sync(restack) -> submit. +pub fn lifecycle(x: &Exec, state: &mut StackState, round: usize, opts: &FlowOpts) { + create(x, state, round); + let created = state.branches.len() - 1; + modify_branch(x, state, created, round, opts); + sync_restack(x, state, opts); + submit(x, state, opts); +} + +// --------------------------------------------------------------------------- +// Scenario driver +// --------------------------------------------------------------------------- + +/// Full pipeline for one scenario: fresh repo + daemon, stack build, workflow +/// replay (with blind recovery when applicable), attribution assertions. +/// Returns the collected violations (empty = pass). +pub fn run_scenario(scenario: &Scenario) -> Vec { + let mut repo = TestRepo::new_with_daemon_scope(match scenario.observation_mode() { + // Blind scenarios restart the daemon mid-test, which requires a + // dedicated daemon; traced scenarios share the pool daemon. + Observation::Blind => DaemonTestScope::Dedicated, + Observation::Traced => DaemonTestScope::Shared, + }); + let mut state = stackbuilder::build(&repo, scenario); + run_workflow(&mut repo, &mut state, scenario); + assertions::assert_attribution(&repo, &state) +} + +/// Run the scenario's workflow (honoring `repeat`), then the blind-recovery +/// step for `observation = blind`. +pub fn run_workflow(repo: &mut TestRepo, state: &mut StackState, scenario: &Scenario) { + let rounds = scenario.repeat_mode().rounds(); + for round in 0..rounds { + let x = Exec::for_scenario(repo, scenario); + dispatch_family(&x, state, scenario, round); + } + if scenario.observation_mode() == Observation::Blind { + recover_blind(repo); + } +} + +fn dispatch_family(x: &Exec, state: &mut StackState, scenario: &Scenario, round: usize) { + let opts = FlowOpts::for_scenario(scenario); + match scenario.family.as_str() { + "SYNC_FF" => { + // With a clean tree the user can sit on trunk, exercising the + // forward reset --keep on a checked-out trunk (#1976). + let clean = matches!( + scenario.attribution_kind(), + Attribution::Committed | Attribution::Multifile + ); + if clean { + x.git(&["checkout", TRUNK]); + } + sync_ff(x, state); + if clean { + let name = state.under_test_branch().name.clone(); + x.git(&["checkout", &name]); + } + } + "SYNC_RESTACK" | "NESTED_CONFLICT" | "REFSTDIN" | "FLAVOR_17X" => { + sync_restack(x, state, &opts) + } + "RESTACK" | "RENAME" | "MERGE_IN_STACK" | "CONFLICT_CONTINUE" | "CONFLICT_ABORT" => { + restack(x, state, &opts) + } + "CREATE" => create(x, state, round), + "MODIFY" => modify_branch(x, state, state.under_test, round, &opts), + "SUBMIT" => submit(x, state, &opts), + "UNDO" => undo_restack(x, state, &opts), + "MOVE_ONTO" => move_onto(x, state, &opts), + "HOUSEKEEPING" => housekeeping(x, state), + "WORKTREE" => worktree_restack(x, state, &opts), + "PARTIAL_STAGE" => partial_stage(x, state, scenario), + "LIFECYCLE" => lifecycle(x, state, round, &opts), + other => panic!("{}: no gt_sim workflow for family {}", scenario.id, other), + } +} + +/// Blind recovery per PLAN: restart the daemon, then one traced no-op commit +/// so the reflog cursor reconciles the unobserved ref moves. +fn recover_blind(repo: &mut TestRepo) { + repo.restart_dedicated_daemon_for_test(); + repo.git(&["commit", "--allow-empty", "-m", "poke"]) + .expect("blind-recovery poke commit should succeed"); + repo.sync_daemon_force(); +} diff --git a/tests/integration/graphite_workflows/mod.rs b/tests/integration/graphite_workflows/mod.rs new file mode 100644 index 0000000000..62fe6838de --- /dev/null +++ b/tests/integration/graphite_workflows/mod.rs @@ -0,0 +1,30 @@ +//! Graphite workflow attribution suite. +//! +//! An exhaustive, empirically-grounded scenario suite proving that AI +//! attribution survives (or documenting exactly where it does not survive) +//! every Graphite workflow permutation. See `PLAN.md` in this directory for +//! the full design, `scenarios.json` for the 540-scenario manifest, and +//! `generate_matrix.py` for the manifest's single source of truth. +//! +//! Layout: +//! - [`scenario`]: manifest loader + typed dimension accessors; +//! - [`stackbuilder`]: pre-workflow repo state (stack shapes, AI files, +//! remote, trunk divergence); +//! - [`gt_sim`]: faithful replays of gt's git command sequences, plus the +//! per-scenario driver `gt_sim::run_scenario`; +//! - [`assertions`]: non-panicking post-workflow invariant checks; +//! - family test files (`sync_restack.rs`, ...) are added on top of this +//! harness core, one `#[test]` per (family x observation x repeat) bucket. + +// The harness core lands ahead of the family test files, so most of its +// surface is intentionally not yet referenced. +#[allow(dead_code)] +pub(crate) mod assertions; +#[allow(dead_code)] +pub(crate) mod gt_sim; +#[allow(dead_code)] +pub(crate) mod scenario; +#[allow(dead_code)] +pub(crate) mod stackbuilder; + +mod smoke; diff --git a/tests/integration/graphite_workflows/scenario.rs b/tests/integration/graphite_workflows/scenario.rs new file mode 100644 index 0000000000..e3fcaff9c0 --- /dev/null +++ b/tests/integration/graphite_workflows/scenario.rs @@ -0,0 +1,144 @@ +//! Scenario manifest for the Graphite workflow attribution suite. +//! +//! `scenarios.json` is generated by `generate_matrix.py` (the single source of +//! truth; IDs are stable and append-only) and embedded at compile time. See +//! `PLAN.md` in this directory for the full design. + +use serde::Deserialize; +use std::str::FromStr; + +/// One row of the scenario matrix. Dimension fields keep their raw manifest +/// strings; use the typed accessors (`stack_shape()`, `attribution_kind()`, +/// ...) when dispatching behavior. +#[derive(Debug, Clone, Deserialize)] +pub struct Scenario { + pub id: String, + pub family: String, + pub stack: String, + pub attribution: String, + pub trunk: String, + pub observation: String, + pub repeat: String, +} + +macro_rules! scenario_dimension { + ($name:ident { $($variant:ident => $text:literal),+ $(,)? }) => { + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum $name { + $($variant),+ + } + + impl FromStr for $name { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + $($text => Ok(Self::$variant),)+ + other => Err(format!( + "unknown {} value: {:?}", + stringify!($name), + other + )), + } + } + } + }; +} + +scenario_dimension!(StackShape { + Single => "single", + Stack2Bot => "stack2_bot", + Stack2Top => "stack2_top", + Stack3Mid => "stack3_mid", + Stack3All => "stack3_all", +}); + +scenario_dimension!(Attribution { + Committed => "committed", + Uncommitted => "uncommitted", + Mixed => "mixed", + Multifile => "multifile", +}); + +scenario_dimension!(TrunkState { + Ff => "ff", + Diverged => "diverged", + Overlap => "overlap", +}); + +scenario_dimension!(Observation { + Traced => "traced", + Blind => "blind", +}); + +scenario_dimension!(Repeat { + Once => "once", + Twice => "twice", +}); + +impl Repeat { + pub fn rounds(self) -> usize { + match self { + Repeat::Once => 1, + Repeat::Twice => 2, + } + } +} + +impl Scenario { + fn parse_dimension>(&self, value: &str) -> T { + value + .parse() + .unwrap_or_else(|error| panic!("{}: {}", self.id, error)) + } + + pub fn stack_shape(&self) -> StackShape { + self.parse_dimension(&self.stack) + } + + pub fn attribution_kind(&self) -> Attribution { + self.parse_dimension(&self.attribution) + } + + pub fn trunk_state(&self) -> TrunkState { + self.parse_dimension(&self.trunk) + } + + pub fn observation_mode(&self) -> Observation { + self.parse_dimension(&self.observation) + } + + pub fn repeat_mode(&self) -> Repeat { + self.parse_dimension(&self.repeat) + } +} + +#[derive(Deserialize)] +struct Manifest { + scenarios: Vec, +} + +/// All 540 scenarios, in manifest order. +pub fn all_scenarios() -> Vec { + let manifest: Manifest = serde_json::from_str(include_str!("scenarios.json")) + .expect("scenarios.json should deserialize"); + manifest.scenarios +} + +/// Every scenario of one family (e.g. `"SYNC_RESTACK"`), for family test files. +pub fn family(family: &str) -> Vec { + let scenarios: Vec = all_scenarios() + .into_iter() + .filter(|scenario| scenario.family == family) + .collect(); + assert!(!scenarios.is_empty(), "unknown scenario family: {family}"); + scenarios +} + +/// Look up a single scenario by its stable ID (e.g. `"GT-SYNC_RESTACK-001"`). +pub fn by_id(id: &str) -> Scenario { + all_scenarios() + .into_iter() + .find(|scenario| scenario.id == id) + .unwrap_or_else(|| panic!("scenario {id} not found in scenarios.json")) +} diff --git a/tests/integration/graphite_workflows/smoke.rs b/tests/integration/graphite_workflows/smoke.rs new file mode 100644 index 0000000000..7ead9a2b0c --- /dev/null +++ b/tests/integration/graphite_workflows/smoke.rs @@ -0,0 +1,20 @@ +//! End-to-end smoke test for the harness core: one scenario through the full +//! pipeline (stack build -> workflow replay -> attribution assertions). + +use super::{gt_sim, scenario}; + +/// GT-SYNC_RESTACK-001: single branch, committed AI work, diverged trunk, +/// traced, once — the canonical `gt sync` restack (fetch, trunk `update-ref`, +/// synthetic-base commit-tree/merge-tree rewrite, checked-out branch moved via +/// `reset -q --keep`). +#[test] +fn smoke_gt_sync_restack_001() { + let scenario = scenario::by_id("GT-SYNC_RESTACK-001"); + let violations = gt_sim::run_scenario(&scenario); + assert!( + violations.is_empty(), + "{} attribution violations:\n{}", + scenario.id, + violations.join("\n") + ); +} diff --git a/tests/integration/graphite_workflows/stackbuilder.rs b/tests/integration/graphite_workflows/stackbuilder.rs new file mode 100644 index 0000000000..e4f484aef3 --- /dev/null +++ b/tests/integration/graphite_workflows/stackbuilder.rs @@ -0,0 +1,489 @@ +//! Builds the pre-workflow repository state for a scenario: trunk `main` with +//! a raw base commit, the requested stack shape of 100%-AI branches, an +//! optional bare `origin` remote, and the trunk-divergence fuel. +//! +//! Contract: the caller controls the daemon lifecycle and must hand this +//! module a `TestRepo` whose daemon is already running — the committed AI work +//! is created with `mock_ai` checkpoints plus traced commits so authorship +//! notes exist before the workflow under test runs. + +use super::scenario::{Attribution, Scenario, StackShape, TrunkState}; +use crate::repos::test_repo::{DaemonTestScope, TestRepo}; +use std::fs; + +/// Trunk branch name used by every scenario. +pub const TRUNK: &str = "main"; + +/// Env that disables trace2 so raw setup/rewrite commands stay invisible to +/// the daemon (same shape as `cold_trace2_repo.rs`). +pub const TRACE2_DISABLED_ENV: [(&str, &str); 3] = [ + ("GIT_TRACE2", "0"), + ("GIT_TRACE2_EVENT", "0"), + ("GIT_TRACE2_PERF", "0"), +]; + +const TRUNK_ADVANCE_TEMP_BRANCH: &str = "gt-sim-trunk-advance"; + +/// One AI-authored file on a branch, with the line-level expectations the +/// post-workflow assertions must reproduce via `git-ai blame`. +#[derive(Debug, Clone)] +pub struct AiFileState { + pub path: String, + /// Lines committed (with a mock_ai checkpoint) before the workflow. + pub committed_ai_lines: Vec, + /// Checkpointed lines still in the working log; the assertion phase + /// commits them (traced) and then requires AI attribution. + pub uncommitted_ai_lines: Vec, +} + +#[derive(Debug, Clone)] +pub struct BranchState { + pub name: String, + pub files: Vec, + /// Current tip; workflows keep this up to date as refs move. + pub tip_sha: String, + /// The commit whose authorship note must survive; workflows retarget this + /// when the commit is rewritten. `None` for pending-only (empty) branches. + pub ai_commit_sha: Option, +} + +#[derive(Debug)] +pub struct StackState { + pub scenario_id: String, + /// Trunk commit the stack forked from. + pub fork_sha: String, + /// Trunk commit the stack is currently based on (updated by restacks). + pub stack_base_sha: String, + /// Local `main` tip. + pub local_trunk_sha: String, + /// `origin/main` tip when the scenario has a remote whose trunk advanced. + pub remote_trunk_sha: Option, + /// Stack branches, bottom-up (`b1`, `b2`, `b3`). + pub branches: Vec, + /// Index into `branches` of the change-under-test branch. + pub under_test: usize, + /// Non-stack branch off trunk used as the `gt move --onto` target. + pub side_branch: Option, + /// Bare `origin`; kept alive here so its temp dir outlives the scenario. + pub remote: Option, +} + +impl StackState { + pub fn under_test_branch(&self) -> &BranchState { + &self.branches[self.under_test] + } +} + +// --------------------------------------------------------------------------- +// Shared low-level helpers (also used by gt_sim / assertions) +// --------------------------------------------------------------------------- + +pub fn raw_git(repo: &TestRepo, args: &[&str]) -> String { + repo.git_og_with_env(args, &TRACE2_DISABLED_ENV) + .unwrap_or_else(|error| panic!("raw trace-disabled git {:?} failed: {}", args, error)) +} + +pub fn raw_head(repo: &TestRepo) -> String { + raw_git(repo, &["rev-parse", "HEAD"]).trim().to_string() +} + +pub fn write_file(repo: &TestRepo, path: &str, content: &str) { + let full_path = repo.path().join(path); + if let Some(parent) = full_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(full_path, content).unwrap(); +} + +pub fn ai_lines(prefix: &str, count: usize) -> Vec { + (1..=count) + .map(|index| format!("{prefix} ai line {index}")) + .collect() +} + +/// Write `lines` to `path` and record them as AI via a `mock_ai` checkpoint. +pub fn write_ai_file(repo: &TestRepo, path: &str, lines: &[String]) { + write_file(repo, path, &(lines.join("\n") + "\n")); + repo.git_ai(&["checkpoint", "mock_ai", path]) + .unwrap_or_else(|error| panic!("mock_ai checkpoint for {path} failed: {error}")); +} + +fn traced_git(repo: &TestRepo, args: &[&str]) { + repo.git(args) + .unwrap_or_else(|error| panic!("traced git {:?} failed: {}", args, error)); +} + +fn traced_commit_all(repo: &TestRepo, message: &str) -> String { + repo.stage_all_and_commit(message) + .unwrap_or_else(|error| panic!("commit {message:?} failed: {error}")) + .commit_sha +} + +// --------------------------------------------------------------------------- +// Family knobs +// --------------------------------------------------------------------------- + +/// Families whose workflow fetches from / pushes to `origin`. +fn needs_remote(family: &str) -> bool { + matches!( + family, + "SYNC_FF" + | "SYNC_RESTACK" + | "SUBMIT" + | "LIFECYCLE" + | "NESTED_CONFLICT" + | "PARTIAL_STAGE" + | "FLAVOR_17X" + | "REFSTDIN" + ) +} + +/// Whether trunk gains commits after the fork. `ff` trunks only advance for +/// families whose whole point is the trunk fast-forward move. +fn trunk_advances(scenario: &Scenario) -> bool { + match scenario.trunk_state() { + TrunkState::Diverged | TrunkState::Overlap => true, + TrunkState::Ff => matches!(scenario.family.as_str(), "SYNC_FF" | "PARTIAL_STAGE"), + } +} + +/// Sync-style families see the advance on `origin/main` (local `main` stays at +/// the fork so the workflow's fetch + trunk move does the forwarding); local +/// restack-style families (including SUBMIT's restack-if-needed) advance local +/// `main` directly. +fn advances_remotely(family: &str) -> bool { + needs_remote(family) && family != "SUBMIT" +} + +fn stack_layout(shape: StackShape) -> (usize, usize) { + match shape { + StackShape::Single => (1, 0), + StackShape::Stack2Bot => (2, 0), + StackShape::Stack2Top => (2, 1), + StackShape::Stack3Mid => (3, 1), + StackShape::Stack3All => (3, 2), + } +} + +/// Path of the pending-only AI file used by the `uncommitted` attribution. +pub fn pending_file_path(branch: &str) -> String { + format!("{branch}_pending_ai.txt") +} + +// --------------------------------------------------------------------------- +// Build +// --------------------------------------------------------------------------- + +pub fn build(repo: &TestRepo, scenario: &Scenario) -> StackState { + let (branch_count, under_test) = stack_layout(scenario.stack_shape()); + + // Trunk base commit (raw, human) + deterministic trunk name. + write_file(repo, "base.txt", "base human line 1\nbase human line 2\n"); + raw_git(repo, &["add", "-A"]); + raw_git(repo, &["commit", "-m", "raw trunk base"]); + raw_git(repo, &["branch", "-M", TRUNK]); + + // RENAME seed: an AI file committed on trunk (traced) so the under-test + // branch commit can express a rename diff against its parent. + let rename_seed = (scenario.family == "RENAME").then(|| { + let lines = ai_lines("ren", 3); + write_ai_file(repo, "ren_ai.txt", &lines); + traced_commit_all(repo, "ai rename seed on trunk"); + lines + }); + + let fork_sha = raw_head(repo); + + let remote = needs_remote(&scenario.family).then(|| { + let remote = TestRepo::new_bare_with_daemon_scope(DaemonTestScope::NoDaemon); + remote + .git_og(&["symbolic-ref", "HEAD", &format!("refs/heads/{TRUNK}")]) + .expect("remote default branch"); + raw_git( + repo, + &["remote", "add", "origin", remote.path().to_str().unwrap()], + ); + raw_git(repo, &["push", "-u", "origin", &format!("{TRUNK}:{TRUNK}")]); + remote + }); + + // Stack branches, bottom-up. Every branch gets a committed 100%-AI file; + // the attribution dimension only alters the change-under-test branch. + let mut branches = Vec::with_capacity(branch_count); + for index in 0..branch_count { + let name = format!("b{}", index + 1); + traced_git(repo, &["checkout", "-b", &name]); + branches.push(build_branch( + repo, + scenario, + &name, + index == under_test, + &rename_seed, + )); + } + + // Side branch off trunk: the non-trunk `gt move --onto` target. + let side_branch = (scenario.family == "MOVE_ONTO").then(|| { + traced_git(repo, &["checkout", TRUNK]); + traced_git(repo, &["checkout", "-b", "side"]); + let lines = ai_lines("side", 3); + write_ai_file(repo, "side_ai.txt", &lines); + let commit = traced_commit_all(repo, "ai work on side"); + BranchState { + name: "side".to_string(), + files: vec![AiFileState { + path: "side_ai.txt".to_string(), + committed_ai_lines: lines, + uncommitted_ai_lines: Vec::new(), + }], + tip_sha: commit.clone(), + ai_commit_sha: Some(commit), + } + }); + + let under_test_name = branches[under_test].name.clone(); + traced_git(repo, &["checkout", &under_test_name]); + + // Trunk divergence / conflict fuel (raw commits, per the trunk dimension). + let mut local_trunk_sha = fork_sha.clone(); + let mut remote_trunk_sha = remote.as_ref().map(|_| fork_sha.clone()); + if scenario.family == "MERGE_IN_STACK" { + // Advance trunk, pull-merge it into the under-test branch, advance + // again — leaves a merge commit inside the stack for the restack core. + advance_trunk_locally( + repo, + &under_test_name, + &[( + "main_only_1.txt".to_string(), + "main only line 1\n".to_string(), + )], + ); + traced_git(repo, &["merge", TRUNK, "-m", "merge trunk into stack"]); + branches[under_test].tip_sha = raw_head(repo); + local_trunk_sha = advance_trunk_locally( + repo, + &under_test_name, + &[( + "main_only_2.txt".to_string(), + "main only line 2\n".to_string(), + )], + ); + } else if trunk_advances(scenario) { + let files = trunk_advance_files(scenario, &branches); + if advances_remotely(&scenario.family) { + remote_trunk_sha = Some(advance_trunk_remotely(repo, &under_test_name, &files)); + } else { + local_trunk_sha = advance_trunk_locally(repo, &under_test_name, &files); + } + } + + // Working-log-only AI edits on the under-test branch. Applied last so the + // raw trunk checkout dance above never runs with a dirty tree. + apply_pending_edits(repo, scenario, &mut branches[under_test]); + + repo.sync_daemon_force(); + + StackState { + scenario_id: scenario.id.clone(), + fork_sha: fork_sha.clone(), + stack_base_sha: fork_sha, + local_trunk_sha, + remote_trunk_sha, + branches, + under_test, + side_branch, + remote, + } +} + +fn build_branch( + repo: &TestRepo, + scenario: &Scenario, + name: &str, + is_under_test: bool, + rename_seed: &Option>, +) -> BranchState { + let attribution = scenario.attribution_kind(); + + // `uncommitted` leaves the change-under-test branch without a commit of + // its own (ref == parent tip): the AI work exists only in the working log. + // Stack3All keeps its committed commit so all branches carry AI commits. + if is_under_test + && attribution == Attribution::Uncommitted + && scenario.stack_shape() != StackShape::Stack3All + { + return BranchState { + name: name.to_string(), + files: Vec::new(), + tip_sha: raw_head(repo), + ai_commit_sha: None, + }; + } + + let mut files = Vec::new(); + if is_under_test && scenario.family == "RENAME" { + let seed = rename_seed.as_ref().expect("RENAME trunk seed"); + repo.git(&["mv", "ren_ai.txt", "ren_ai_renamed.txt"]) + .expect("git mv should succeed"); + let mut lines = seed.clone(); + lines.push(format!("{name} renamed ai line")); + write_ai_file(repo, "ren_ai_renamed.txt", &lines); + files.push(AiFileState { + path: "ren_ai_renamed.txt".to_string(), + committed_ai_lines: lines, + uncommitted_ai_lines: Vec::new(), + }); + } else { + files.push(committed_ai_file(repo, &format!("{name}_ai.txt"), name)); + } + + if is_under_test && attribution == Attribution::Multifile { + for extra in 2..=3 { + let path = format!("{name}_ai_{extra}.txt"); + files.push(committed_ai_file( + repo, + &path, + &format!("{name} extra {extra}"), + )); + } + } + + let commit = traced_commit_all(repo, &format!("ai work on {name}")); + BranchState { + name: name.to_string(), + files, + tip_sha: commit.clone(), + ai_commit_sha: Some(commit), + } +} + +fn committed_ai_file(repo: &TestRepo, path: &str, line_prefix: &str) -> AiFileState { + let lines = ai_lines(line_prefix, 3); + write_ai_file(repo, path, &lines); + AiFileState { + path: path.to_string(), + committed_ai_lines: lines, + uncommitted_ai_lines: Vec::new(), + } +} + +/// Contents trunk gains after the fork. `overlap` recreates the bottom +/// branch's AI file with conflicting human content (add/add conflict fuel); +/// NESTED_CONFLICT overlaps the bottom TWO stack levels. `diverged`/`ff` +/// advances touch main-only files. +fn trunk_advance_files(scenario: &Scenario, branches: &[BranchState]) -> Vec<(String, String)> { + match scenario.trunk_state() { + TrunkState::Overlap => { + let overlap_levels = if scenario.family == "NESTED_CONFLICT" { + 2 + } else { + 1 + }; + branches + .iter() + .take(overlap_levels) + .map(|branch| { + let path = branch + .files + .first() + .map(|file| file.path.clone()) + .unwrap_or_else(|| pending_file_path(&branch.name)); + ( + path, + "trunk conflicting line 1\ntrunk conflicting line 2\n".to_string(), + ) + }) + .collect() + } + TrunkState::Diverged | TrunkState::Ff => vec![ + ( + "main_only_1.txt".to_string(), + "main only line 1\n".to_string(), + ), + ( + "main_only_2.txt".to_string(), + "main only line 2\n".to_string(), + ), + ], + } +} + +/// Raw human commits directly on local `main` (checkout dance, trace2 off). +fn advance_trunk_locally( + repo: &TestRepo, + return_branch: &str, + files: &[(String, String)], +) -> String { + raw_git(repo, &["checkout", TRUNK]); + for (path, content) in files { + write_file(repo, path, content); + raw_git(repo, &["add", "-A"]); + raw_git( + repo, + &["commit", "-m", &format!("raw trunk advance {path}")], + ); + } + let tip = raw_head(repo); + raw_git(repo, &["checkout", return_branch]); + tip +} + +/// Raw human commits pushed to `origin/main` via a temp branch, leaving local +/// `main` at the fork — the shape of trunk advancing remotely. +fn advance_trunk_remotely( + repo: &TestRepo, + return_branch: &str, + files: &[(String, String)], +) -> String { + raw_git(repo, &["checkout", "-b", TRUNK_ADVANCE_TEMP_BRANCH, TRUNK]); + for (path, content) in files { + write_file(repo, path, content); + raw_git(repo, &["add", "-A"]); + raw_git( + repo, + &["commit", "-m", &format!("raw trunk advance {path}")], + ); + } + let tip = raw_head(repo); + raw_git( + repo, + &[ + "push", + "origin", + &format!("{TRUNK_ADVANCE_TEMP_BRANCH}:{TRUNK}"), + ], + ); + raw_git(repo, &["checkout", return_branch]); + raw_git(repo, &["branch", "-D", TRUNK_ADVANCE_TEMP_BRANCH]); + tip +} + +fn apply_pending_edits(repo: &TestRepo, scenario: &Scenario, branch: &mut BranchState) { + match scenario.attribution_kind() { + Attribution::Uncommitted => { + let path = pending_file_path(&branch.name); + let lines = ai_lines(&format!("{} pending", branch.name), 3); + write_ai_file(repo, &path, &lines); + branch.files.push(AiFileState { + path, + committed_ai_lines: Vec::new(), + uncommitted_ai_lines: lines, + }); + } + Attribution::Mixed => { + let file = branch + .files + .first_mut() + .expect("mixed attribution requires a committed AI file"); + let pending = vec![ + format!("{} pending ai line 1", branch.name), + format!("{} pending ai line 2", branch.name), + ]; + let mut full = file.committed_ai_lines.clone(); + full.extend(pending.iter().cloned()); + write_ai_file(repo, &file.path.clone(), &full); + file.uncommitted_ai_lines = pending; + } + Attribution::Committed | Attribution::Multifile => {} + } +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 9762a37e1e..d18131770e 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -76,6 +76,7 @@ mod github_copilot_tools; mod github_integration; mod gix_config_tests; mod graphite; +mod graphite_workflows; mod http_proxy; mod ignore_prompts; mod ignore_unit; diff --git a/tests/integration/repos/test_repo.rs b/tests/integration/repos/test_repo.rs index cacb78bf3e..03283370e2 100644 --- a/tests/integration/repos/test_repo.rs +++ b/tests/integration/repos/test_repo.rs @@ -2862,6 +2862,110 @@ impl TestRepo { Err("git_with_env failed after retries".to_string()) } + /// Run a traced git command with data provided on stdin (e.g. + /// `update-ref --stdin`, `hash-object -w --stdin`). Mirrors the daemon + /// test-sync bookkeeping of `git_with_env` for tracked commands. + pub fn git_with_stdin(&self, args: &[&str], stdin_data: &[u8]) -> Result { + let tracked_invocation = + self.parsed_git_invocation_for_tracking(args, Some(self.path.as_path())); + let command_affects_daemon = self.has_active_daemon() + && git_ai::daemon::test_sync::tracks_parsed_git_invocation_for_test_sync( + &tracked_invocation, + ); + let daemon_test_sync_session = command_affects_daemon.then(new_daemon_test_sync_session_id); + + let mut command = Command::new(real_git_executable()); + let mut command_args = Vec::::new(); + if let Some(session) = daemon_test_sync_session.as_deref() { + self.append_daemon_test_sync_session_args(&mut command_args, session); + } + command_args.push("-C".to_string()); + command_args.push(self.path.to_str().unwrap().to_string()); + command_args.extend(args.iter().map(|arg| (*arg).to_string())); + command.args(&command_args); + self.configure_command_env(&mut command); + + if let Some(patch) = &self.config_patch + && let Ok(patch_json) = serde_json::to_string(patch) + { + command.env("GIT_AI_TEST_CONFIG_PATCH", patch_json); + } + command.env("GIT_AI_TEST_DB_PATH", self.test_db_path.to_str().unwrap()); + command.env("GITAI_TEST_DB_PATH", self.test_db_path.to_str().unwrap()); + + let output = run_command_output_with_stdin( + &mut command, + &format!("git stdin {:?}", args), + stdin_data, + )?; + + if let Some(session) = daemon_test_sync_session.as_deref() { + self.record_daemon_family_expected_completion_session(session); + } + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if output.status.success() { + let combined = if stdout.is_empty() { + stderr + } else if stderr.is_empty() { + stdout + } else { + format!("{}{}", stdout, stderr) + }; + Ok(combined) + } else { + Err(format!("{}{}", stdout, stderr)) + } + } + + /// Run a raw git command (hooks disabled, no trace2 wiring) with data + /// provided on stdin. The blind twin of `git_og_with_env`; callers must not + /// pass env vars that enable trace2, since no daemon sync is recorded. + pub fn git_og_with_stdin_and_env( + &self, + args: &[&str], + envs: &[(&str, &str)], + stdin_data: &[u8], + ) -> Result { + #[cfg(windows)] + let null_hooks = "NUL"; + #[cfg(not(windows))] + let null_hooks = "/dev/null"; + + let mut command = Command::new(real_git_executable()); + command.arg("-C").arg(&self.path); + command + .arg("-c") + .arg(format!("core.hooksPath={}", null_hooks)); + command.args(args); + configure_test_home_env(&mut command, &self.test_home); + for (key, value) in envs { + command.env(key, value); + } + + let output = run_command_output_with_stdin( + &mut command, + &format!("git_og stdin {:?}", args), + stdin_data, + )?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if output.status.success() { + let combined = if stdout.is_empty() { + stderr + } else if stderr.is_empty() { + stdout + } else { + format!("{}{}", stdout, stderr) + }; + Ok(combined) + } else { + Err(format!("{}{}", stdout, stderr)) + } + } + pub fn git_ai_from_working_dir( &self, working_dir: &std::path::Path, From 92055f79e67ec9236e4dd5303f9f15b88122b216 Mon Sep 17 00:00:00 2001 From: Sandesh Devaraju Date: Tue, 28 Jul 2026 15:04:47 -0700 Subject: [PATCH 3/6] graphite_workflows: generated family bucket tests (45 buckets / 540 scenarios) One #[test] per (family x observation x repeat) bucket, each iterating its scenario slice through gt_sim::run_scenario and reporting all violations in a single panic keyed by stable scenario IDs. Generated mechanically from scenarios.json; regenerate via the tooling rather than editing by hand. Co-Authored-By: Claude Fable 5 --- .../graphite_workflows/conflicts.rs | 61 ++++++++++++++++ .../create_modify_submit.rs | 66 +++++++++++++++++ .../graphite_workflows/lifecycle.rs | 51 +++++++++++++ tests/integration/graphite_workflows/mod.rs | 9 +++ .../integration/graphite_workflows/restack.rs | 51 +++++++++++++ .../integration/graphite_workflows/sync_ff.rs | 51 +++++++++++++ .../graphite_workflows/sync_restack.rs | 51 +++++++++++++ .../graphite_workflows/undo_move_misc.rs | 71 +++++++++++++++++++ .../graphite_workflows/worktree_shapes.rs | 71 +++++++++++++++++++ 9 files changed, 482 insertions(+) create mode 100644 tests/integration/graphite_workflows/conflicts.rs create mode 100644 tests/integration/graphite_workflows/create_modify_submit.rs create mode 100644 tests/integration/graphite_workflows/lifecycle.rs create mode 100644 tests/integration/graphite_workflows/restack.rs create mode 100644 tests/integration/graphite_workflows/sync_ff.rs create mode 100644 tests/integration/graphite_workflows/sync_restack.rs create mode 100644 tests/integration/graphite_workflows/undo_move_misc.rs create mode 100644 tests/integration/graphite_workflows/worktree_shapes.rs diff --git a/tests/integration/graphite_workflows/conflicts.rs b/tests/integration/graphite_workflows/conflicts.rs new file mode 100644 index 0000000000..c356b7ed55 --- /dev/null +++ b/tests/integration/graphite_workflows/conflicts.rs @@ -0,0 +1,61 @@ +//! CONFLICT_CONTINUE + CONFLICT_ABORT + NESTED_CONFLICT scenario buckets — generated by generate_matrix.py tooling. +//! +//! One #[test] per (family x observation x repeat) bucket. Each bucket runs +//! its scenarios through gt_sim::run_scenario and reports every violation in +//! a single panic, prefixed with stable scenario IDs for triage. See PLAN.md. + +use super::gt_sim; +use super::scenario; + +fn run_bucket(family: &str, observation: &str, repeat: &str) { + let scenarios: Vec<_> = scenario::family(family) + .into_iter() + .filter(|s| s.observation == observation && s.repeat == repeat) + .collect(); + assert!(!scenarios.is_empty(), "empty bucket {family}/{observation}/{repeat}"); + let mut violations = Vec::new(); + for s in &scenarios { + violations.extend(gt_sim::run_scenario(s)); + } + assert!( + violations.is_empty(), + "{} attribution violations in {} {}/{} ({} scenarios):\n{}", + violations.len(), + family, + observation, + repeat, + scenarios.len(), + violations.join("\n") + ); +} + +#[test] +fn conflict_continue_blind_once() { + run_bucket("CONFLICT_CONTINUE", "blind", "once"); +} + +#[test] +fn conflict_continue_traced_once() { + run_bucket("CONFLICT_CONTINUE", "traced", "once"); +} + +#[test] +fn conflict_abort_blind_once() { + run_bucket("CONFLICT_ABORT", "blind", "once"); +} + +#[test] +fn conflict_abort_traced_once() { + run_bucket("CONFLICT_ABORT", "traced", "once"); +} + +#[test] +fn nested_conflict_blind_once() { + run_bucket("NESTED_CONFLICT", "blind", "once"); +} + +#[test] +fn nested_conflict_traced_once() { + run_bucket("NESTED_CONFLICT", "traced", "once"); +} + diff --git a/tests/integration/graphite_workflows/create_modify_submit.rs b/tests/integration/graphite_workflows/create_modify_submit.rs new file mode 100644 index 0000000000..01ec3c1510 --- /dev/null +++ b/tests/integration/graphite_workflows/create_modify_submit.rs @@ -0,0 +1,66 @@ +//! CREATE + MODIFY + SUBMIT scenario buckets — generated by generate_matrix.py tooling. +//! +//! One #[test] per (family x observation x repeat) bucket. Each bucket runs +//! its scenarios through gt_sim::run_scenario and reports every violation in +//! a single panic, prefixed with stable scenario IDs for triage. See PLAN.md. + +use super::gt_sim; +use super::scenario; + +fn run_bucket(family: &str, observation: &str, repeat: &str) { + let scenarios: Vec<_> = scenario::family(family) + .into_iter() + .filter(|s| s.observation == observation && s.repeat == repeat) + .collect(); + assert!(!scenarios.is_empty(), "empty bucket {family}/{observation}/{repeat}"); + let mut violations = Vec::new(); + for s in &scenarios { + violations.extend(gt_sim::run_scenario(s)); + } + assert!( + violations.is_empty(), + "{} attribution violations in {} {}/{} ({} scenarios):\n{}", + violations.len(), + family, + observation, + repeat, + scenarios.len(), + violations.join("\n") + ); +} + +#[test] +fn create_blind_once() { + run_bucket("CREATE", "blind", "once"); +} + +#[test] +fn create_traced_once() { + run_bucket("CREATE", "traced", "once"); +} + +#[test] +fn modify_blind_once() { + run_bucket("MODIFY", "blind", "once"); +} + +#[test] +fn modify_blind_twice() { + run_bucket("MODIFY", "blind", "twice"); +} + +#[test] +fn modify_traced_once() { + run_bucket("MODIFY", "traced", "once"); +} + +#[test] +fn modify_traced_twice() { + run_bucket("MODIFY", "traced", "twice"); +} + +#[test] +fn submit_traced_once() { + run_bucket("SUBMIT", "traced", "once"); +} + diff --git a/tests/integration/graphite_workflows/lifecycle.rs b/tests/integration/graphite_workflows/lifecycle.rs new file mode 100644 index 0000000000..9dc0ae15cf --- /dev/null +++ b/tests/integration/graphite_workflows/lifecycle.rs @@ -0,0 +1,51 @@ +//! LIFECYCLE scenario buckets — generated by generate_matrix.py tooling. +//! +//! One #[test] per (family x observation x repeat) bucket. Each bucket runs +//! its scenarios through gt_sim::run_scenario and reports every violation in +//! a single panic, prefixed with stable scenario IDs for triage. See PLAN.md. + +use super::gt_sim; +use super::scenario; + +fn run_bucket(family: &str, observation: &str, repeat: &str) { + let scenarios: Vec<_> = scenario::family(family) + .into_iter() + .filter(|s| s.observation == observation && s.repeat == repeat) + .collect(); + assert!(!scenarios.is_empty(), "empty bucket {family}/{observation}/{repeat}"); + let mut violations = Vec::new(); + for s in &scenarios { + violations.extend(gt_sim::run_scenario(s)); + } + assert!( + violations.is_empty(), + "{} attribution violations in {} {}/{} ({} scenarios):\n{}", + violations.len(), + family, + observation, + repeat, + scenarios.len(), + violations.join("\n") + ); +} + +#[test] +fn lifecycle_blind_once() { + run_bucket("LIFECYCLE", "blind", "once"); +} + +#[test] +fn lifecycle_blind_twice() { + run_bucket("LIFECYCLE", "blind", "twice"); +} + +#[test] +fn lifecycle_traced_once() { + run_bucket("LIFECYCLE", "traced", "once"); +} + +#[test] +fn lifecycle_traced_twice() { + run_bucket("LIFECYCLE", "traced", "twice"); +} + diff --git a/tests/integration/graphite_workflows/mod.rs b/tests/integration/graphite_workflows/mod.rs index 62fe6838de..c21035da9f 100644 --- a/tests/integration/graphite_workflows/mod.rs +++ b/tests/integration/graphite_workflows/mod.rs @@ -28,3 +28,12 @@ pub(crate) mod scenario; pub(crate) mod stackbuilder; mod smoke; + +mod conflicts; +mod create_modify_submit; +mod lifecycle; +mod restack; +mod sync_ff; +mod sync_restack; +mod undo_move_misc; +mod worktree_shapes; diff --git a/tests/integration/graphite_workflows/restack.rs b/tests/integration/graphite_workflows/restack.rs new file mode 100644 index 0000000000..ea200c8b35 --- /dev/null +++ b/tests/integration/graphite_workflows/restack.rs @@ -0,0 +1,51 @@ +//! RESTACK + REFSTDIN scenario buckets — generated by generate_matrix.py tooling. +//! +//! One #[test] per (family x observation x repeat) bucket. Each bucket runs +//! its scenarios through gt_sim::run_scenario and reports every violation in +//! a single panic, prefixed with stable scenario IDs for triage. See PLAN.md. + +use super::gt_sim; +use super::scenario; + +fn run_bucket(family: &str, observation: &str, repeat: &str) { + let scenarios: Vec<_> = scenario::family(family) + .into_iter() + .filter(|s| s.observation == observation && s.repeat == repeat) + .collect(); + assert!(!scenarios.is_empty(), "empty bucket {family}/{observation}/{repeat}"); + let mut violations = Vec::new(); + for s in &scenarios { + violations.extend(gt_sim::run_scenario(s)); + } + assert!( + violations.is_empty(), + "{} attribution violations in {} {}/{} ({} scenarios):\n{}", + violations.len(), + family, + observation, + repeat, + scenarios.len(), + violations.join("\n") + ); +} + +#[test] +fn restack_blind_once() { + run_bucket("RESTACK", "blind", "once"); +} + +#[test] +fn restack_traced_once() { + run_bucket("RESTACK", "traced", "once"); +} + +#[test] +fn refstdin_blind_once() { + run_bucket("REFSTDIN", "blind", "once"); +} + +#[test] +fn refstdin_traced_once() { + run_bucket("REFSTDIN", "traced", "once"); +} + diff --git a/tests/integration/graphite_workflows/sync_ff.rs b/tests/integration/graphite_workflows/sync_ff.rs new file mode 100644 index 0000000000..303e6764ae --- /dev/null +++ b/tests/integration/graphite_workflows/sync_ff.rs @@ -0,0 +1,51 @@ +//! SYNC_FF scenario buckets — generated by generate_matrix.py tooling. +//! +//! One #[test] per (family x observation x repeat) bucket. Each bucket runs +//! its scenarios through gt_sim::run_scenario and reports every violation in +//! a single panic, prefixed with stable scenario IDs for triage. See PLAN.md. + +use super::gt_sim; +use super::scenario; + +fn run_bucket(family: &str, observation: &str, repeat: &str) { + let scenarios: Vec<_> = scenario::family(family) + .into_iter() + .filter(|s| s.observation == observation && s.repeat == repeat) + .collect(); + assert!(!scenarios.is_empty(), "empty bucket {family}/{observation}/{repeat}"); + let mut violations = Vec::new(); + for s in &scenarios { + violations.extend(gt_sim::run_scenario(s)); + } + assert!( + violations.is_empty(), + "{} attribution violations in {} {}/{} ({} scenarios):\n{}", + violations.len(), + family, + observation, + repeat, + scenarios.len(), + violations.join("\n") + ); +} + +#[test] +fn sync_ff_blind_once() { + run_bucket("SYNC_FF", "blind", "once"); +} + +#[test] +fn sync_ff_blind_twice() { + run_bucket("SYNC_FF", "blind", "twice"); +} + +#[test] +fn sync_ff_traced_once() { + run_bucket("SYNC_FF", "traced", "once"); +} + +#[test] +fn sync_ff_traced_twice() { + run_bucket("SYNC_FF", "traced", "twice"); +} + diff --git a/tests/integration/graphite_workflows/sync_restack.rs b/tests/integration/graphite_workflows/sync_restack.rs new file mode 100644 index 0000000000..1ea4ead41a --- /dev/null +++ b/tests/integration/graphite_workflows/sync_restack.rs @@ -0,0 +1,51 @@ +//! SYNC_RESTACK scenario buckets — generated by generate_matrix.py tooling. +//! +//! One #[test] per (family x observation x repeat) bucket. Each bucket runs +//! its scenarios through gt_sim::run_scenario and reports every violation in +//! a single panic, prefixed with stable scenario IDs for triage. See PLAN.md. + +use super::gt_sim; +use super::scenario; + +fn run_bucket(family: &str, observation: &str, repeat: &str) { + let scenarios: Vec<_> = scenario::family(family) + .into_iter() + .filter(|s| s.observation == observation && s.repeat == repeat) + .collect(); + assert!(!scenarios.is_empty(), "empty bucket {family}/{observation}/{repeat}"); + let mut violations = Vec::new(); + for s in &scenarios { + violations.extend(gt_sim::run_scenario(s)); + } + assert!( + violations.is_empty(), + "{} attribution violations in {} {}/{} ({} scenarios):\n{}", + violations.len(), + family, + observation, + repeat, + scenarios.len(), + violations.join("\n") + ); +} + +#[test] +fn sync_restack_blind_once() { + run_bucket("SYNC_RESTACK", "blind", "once"); +} + +#[test] +fn sync_restack_blind_twice() { + run_bucket("SYNC_RESTACK", "blind", "twice"); +} + +#[test] +fn sync_restack_traced_once() { + run_bucket("SYNC_RESTACK", "traced", "once"); +} + +#[test] +fn sync_restack_traced_twice() { + run_bucket("SYNC_RESTACK", "traced", "twice"); +} + diff --git a/tests/integration/graphite_workflows/undo_move_misc.rs b/tests/integration/graphite_workflows/undo_move_misc.rs new file mode 100644 index 0000000000..39320b4525 --- /dev/null +++ b/tests/integration/graphite_workflows/undo_move_misc.rs @@ -0,0 +1,71 @@ +//! UNDO + MOVE_ONTO + HOUSEKEEPING + FLAVOR_17X scenario buckets — generated by generate_matrix.py tooling. +//! +//! One #[test] per (family x observation x repeat) bucket. Each bucket runs +//! its scenarios through gt_sim::run_scenario and reports every violation in +//! a single panic, prefixed with stable scenario IDs for triage. See PLAN.md. + +use super::gt_sim; +use super::scenario; + +fn run_bucket(family: &str, observation: &str, repeat: &str) { + let scenarios: Vec<_> = scenario::family(family) + .into_iter() + .filter(|s| s.observation == observation && s.repeat == repeat) + .collect(); + assert!(!scenarios.is_empty(), "empty bucket {family}/{observation}/{repeat}"); + let mut violations = Vec::new(); + for s in &scenarios { + violations.extend(gt_sim::run_scenario(s)); + } + assert!( + violations.is_empty(), + "{} attribution violations in {} {}/{} ({} scenarios):\n{}", + violations.len(), + family, + observation, + repeat, + scenarios.len(), + violations.join("\n") + ); +} + +#[test] +fn undo_blind_once() { + run_bucket("UNDO", "blind", "once"); +} + +#[test] +fn undo_blind_twice() { + run_bucket("UNDO", "blind", "twice"); +} + +#[test] +fn undo_traced_once() { + run_bucket("UNDO", "traced", "once"); +} + +#[test] +fn undo_traced_twice() { + run_bucket("UNDO", "traced", "twice"); +} + +#[test] +fn move_onto_blind_once() { + run_bucket("MOVE_ONTO", "blind", "once"); +} + +#[test] +fn move_onto_traced_once() { + run_bucket("MOVE_ONTO", "traced", "once"); +} + +#[test] +fn housekeeping_traced_twice() { + run_bucket("HOUSEKEEPING", "traced", "twice"); +} + +#[test] +fn flavor_17x_traced_once() { + run_bucket("FLAVOR_17X", "traced", "once"); +} + diff --git a/tests/integration/graphite_workflows/worktree_shapes.rs b/tests/integration/graphite_workflows/worktree_shapes.rs new file mode 100644 index 0000000000..357c702d18 --- /dev/null +++ b/tests/integration/graphite_workflows/worktree_shapes.rs @@ -0,0 +1,71 @@ +//! WORKTREE + RENAME + MERGE_IN_STACK + PARTIAL_STAGE scenario buckets — generated by generate_matrix.py tooling. +//! +//! One #[test] per (family x observation x repeat) bucket. Each bucket runs +//! its scenarios through gt_sim::run_scenario and reports every violation in +//! a single panic, prefixed with stable scenario IDs for triage. See PLAN.md. + +use super::gt_sim; +use super::scenario; + +fn run_bucket(family: &str, observation: &str, repeat: &str) { + let scenarios: Vec<_> = scenario::family(family) + .into_iter() + .filter(|s| s.observation == observation && s.repeat == repeat) + .collect(); + assert!(!scenarios.is_empty(), "empty bucket {family}/{observation}/{repeat}"); + let mut violations = Vec::new(); + for s in &scenarios { + violations.extend(gt_sim::run_scenario(s)); + } + assert!( + violations.is_empty(), + "{} attribution violations in {} {}/{} ({} scenarios):\n{}", + violations.len(), + family, + observation, + repeat, + scenarios.len(), + violations.join("\n") + ); +} + +#[test] +fn worktree_blind_once() { + run_bucket("WORKTREE", "blind", "once"); +} + +#[test] +fn worktree_traced_once() { + run_bucket("WORKTREE", "traced", "once"); +} + +#[test] +fn rename_blind_once() { + run_bucket("RENAME", "blind", "once"); +} + +#[test] +fn rename_traced_once() { + run_bucket("RENAME", "traced", "once"); +} + +#[test] +fn merge_in_stack_blind_once() { + run_bucket("MERGE_IN_STACK", "blind", "once"); +} + +#[test] +fn merge_in_stack_traced_once() { + run_bucket("MERGE_IN_STACK", "traced", "once"); +} + +#[test] +fn partial_stage_blind_once() { + run_bucket("PARTIAL_STAGE", "blind", "once"); +} + +#[test] +fn partial_stage_traced_once() { + run_bucket("PARTIAL_STAGE", "traced", "once"); +} + From 2f5cd6999a5e75da5fe343cfc39caf806cf27868 Mon Sep 17 00:00:00 2001 From: Sandesh Devaraju Date: Tue, 28 Jul 2026 15:23:46 -0700 Subject: [PATCH 4/6] graphite_workflows: sync at flow boundaries + scenario timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Throughput: the traced Exec layer no longer runs sync_daemon_force after every tracked command — traced traffic rides the async trace2 pipeline (closer to production). Barriers are kept only where semantics demand a processed daemon: end of each workflow round (between repeat rounds), before the blind-recovery daemon restart, before a linked worktree is removed (per-worktree reflogs), and at the start of the assertion phase (git-ai blame / git notes reads also pre-sync on their own). Robustness: run_scenario now executes each scenario on a watchdog thread with a 180s wall-clock deadline; on expiry it returns a distinguishable "TIMEOUT" violation and abandons the thread, so a hung daemon costs one scenario instead of the bucket. Scenario panics still propagate. The once-per-process git-ai binary build is warmed before the clock starts, and each scenario logs a "[gt-sim] finished in Xs" timing line. GT-SYNC_RESTACK-001 scenario time: 13.4s (was ~68s wall for a solo run, which was dominated by the pre-existing once-per-process cargo build of the git-ai test binary; marginal scenarios in a bucket now run 7-15s). SYNC_FF traced/once bucket (8 scenarios): all pass, 7.5-14.5s each. Also: rustfmt normalization of the generated family bucket files. Co-Authored-By: Claude Fable 5 --- .../graphite_workflows/assertions.rs | 16 +-- .../graphite_workflows/conflicts.rs | 6 +- .../create_modify_submit.rs | 6 +- .../integration/graphite_workflows/gt_sim.rs | 106 ++++++++++++++---- .../graphite_workflows/lifecycle.rs | 6 +- .../integration/graphite_workflows/restack.rs | 6 +- .../integration/graphite_workflows/sync_ff.rs | 6 +- .../graphite_workflows/sync_restack.rs | 6 +- .../graphite_workflows/undo_move_misc.rs | 6 +- .../graphite_workflows/worktree_shapes.rs | 6 +- 10 files changed, 123 insertions(+), 47 deletions(-) diff --git a/tests/integration/graphite_workflows/assertions.rs b/tests/integration/graphite_workflows/assertions.rs index a555cdaa23..663d00094f 100644 --- a/tests/integration/graphite_workflows/assertions.rs +++ b/tests/integration/graphite_workflows/assertions.rs @@ -32,6 +32,10 @@ struct NoteExpectation { pub fn assert_attribution(repo: &TestRepo, state: &StackState) -> Vec { let sid = &state.scenario_id; let mut violations = Vec::new(); + // Assertion-phase barrier: everything the workflow issued must be + // processed before we read blame/notes. (git-ai blame and `git notes` + // reads below also pre-sync on their own, so no further explicit syncs + // are needed in this phase.) repo.sync_daemon_force(); // 1. Commit pending (working-log-only) AI edits with a traced commit so @@ -56,7 +60,6 @@ pub fn assert_attribution(repo: &TestRepo, state: &StackState) -> Vec { )); continue; } - repo.sync_daemon_force(); for expectation in files.iter().filter(|f| f.branch == branch.name) { check_file(repo, sid, expectation, &mut violations); } @@ -91,13 +94,10 @@ fn commit_pending_edits( let branch = repo.current_branch(); match repo.stage_all_and_commit("gt-sim: commit pending ai edits for assertion") { - Ok(commit) => { - repo.sync_daemon_force(); - Some(PendingCommit { - branch, - commit: commit.commit_sha, - }) - } + Ok(commit) => Some(PendingCommit { + branch, + commit: commit.commit_sha, + }), Err(error) => { violations.push(format!( "{}: committing pending AI edits for assertion failed: {}", diff --git a/tests/integration/graphite_workflows/conflicts.rs b/tests/integration/graphite_workflows/conflicts.rs index c356b7ed55..ff3bc6cfb8 100644 --- a/tests/integration/graphite_workflows/conflicts.rs +++ b/tests/integration/graphite_workflows/conflicts.rs @@ -12,7 +12,10 @@ fn run_bucket(family: &str, observation: &str, repeat: &str) { .into_iter() .filter(|s| s.observation == observation && s.repeat == repeat) .collect(); - assert!(!scenarios.is_empty(), "empty bucket {family}/{observation}/{repeat}"); + assert!( + !scenarios.is_empty(), + "empty bucket {family}/{observation}/{repeat}" + ); let mut violations = Vec::new(); for s in &scenarios { violations.extend(gt_sim::run_scenario(s)); @@ -58,4 +61,3 @@ fn nested_conflict_blind_once() { fn nested_conflict_traced_once() { run_bucket("NESTED_CONFLICT", "traced", "once"); } - diff --git a/tests/integration/graphite_workflows/create_modify_submit.rs b/tests/integration/graphite_workflows/create_modify_submit.rs index 01ec3c1510..1c1f8f3610 100644 --- a/tests/integration/graphite_workflows/create_modify_submit.rs +++ b/tests/integration/graphite_workflows/create_modify_submit.rs @@ -12,7 +12,10 @@ fn run_bucket(family: &str, observation: &str, repeat: &str) { .into_iter() .filter(|s| s.observation == observation && s.repeat == repeat) .collect(); - assert!(!scenarios.is_empty(), "empty bucket {family}/{observation}/{repeat}"); + assert!( + !scenarios.is_empty(), + "empty bucket {family}/{observation}/{repeat}" + ); let mut violations = Vec::new(); for s in &scenarios { violations.extend(gt_sim::run_scenario(s)); @@ -63,4 +66,3 @@ fn modify_traced_twice() { fn submit_traced_once() { run_bucket("SUBMIT", "traced", "once"); } - diff --git a/tests/integration/graphite_workflows/gt_sim.rs b/tests/integration/graphite_workflows/gt_sim.rs index 2d04236982..71ce21079d 100644 --- a/tests/integration/graphite_workflows/gt_sim.rs +++ b/tests/integration/graphite_workflows/gt_sim.rs @@ -1,9 +1,14 @@ //! Faithful replays of the Graphite CLI's git command sequences, per the //! empirical catalog in `PLAN.md` Part 1. One function per workflow family; //! all git traffic goes through [`Exec`], which runs either traced (real git -//! wired to the per-test daemon via trace2, daemon-synced after every tracked -//! mutating command) or blind (`git_og_with_env` + trace2 disabled) for the -//! rewrite phase of `observation = blind` scenarios. +//! wired to the per-test daemon via trace2) or blind (`git_og_with_env` + +//! trace2 disabled) for the rewrite phase of `observation = blind` scenarios. +//! +//! Daemon-sync discipline: traced commands ride the async trace2 pipeline +//! (closer to production and much faster); `sync_daemon_force` barriers run +//! only at flow boundaries — the end of each workflow round, before a blind +//! daemon restart, before a linked worktree is removed, and at the start of +//! the assertion phase (git-ai reads also pre-sync on their own). //! //! Key shapes implemented here: //! - synthetic-base restack: `cat-file -p ~` / `commit-tree @@ -25,6 +30,9 @@ use super::stackbuilder::{ }; use crate::repos::test_repo::{DaemonTestScope, TestRepo}; use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RefStyle { @@ -165,16 +173,9 @@ impl<'a> Exec<'a> { return self.repo.git_og_with_env(&full, &env); } - let result = self.repo.git_with_env(args, envs, self.cwd.as_deref()); - if self - .repo - .git_command_affects_daemon_for_tracking(args, self.cwd.as_deref()) - { - // Traced scenarios keep the daemon caught up after every tracked - // mutating command (the run_traced_git pattern). - self.repo.sync_daemon_force(); - } - result + // Tracked commands record their test-sync sessions inside + // git_with_env; the next sync_barrier / git-ai read waits for them. + self.repo.git_with_env(args, envs, self.cwd.as_deref()) } pub fn git_stdin(&self, args: &[&str], input: &[u8]) -> String { @@ -182,18 +183,19 @@ impl<'a> Exec<'a> { self.repo .git_og_with_stdin_and_env(args, &TRACE2_DISABLED_ENV, input) } else { - let result = self.repo.git_with_stdin(args, input); - if self - .repo - .git_command_affects_daemon_for_tracking(args, None) - { - self.repo.sync_daemon_force(); - } - result + self.repo.git_with_stdin(args, input) }; result.unwrap_or_else(|error| panic!("gt-sim git stdin {:?} failed: {}", args, error)) } + /// Flow-boundary barrier: wait for the daemon to process everything + /// issued so far. No-op for blind executions (the daemon saw nothing). + pub fn sync_barrier(&self) { + if !self.blind { + self.repo.sync_daemon_force(); + } + } + pub fn rev_parse(&self, rev: &str) -> String { self.git(&["rev-parse", rev]).trim().to_string() } @@ -770,6 +772,9 @@ pub fn worktree_restack(x: &Exec, state: &mut StackState, opts: &FlowOpts) { let wx = x.at_cwd(worktree_path.clone()); restack(&wx, state, opts); + // Let the daemon process the worktree's commands (per-worktree reflogs) + // before the worktree disappears. + x.sync_barrier(); x.git(&["worktree", "remove", "--force", worktree_str]); } @@ -826,10 +831,58 @@ pub fn lifecycle(x: &Exec, state: &mut StackState, round: usize, opts: &FlowOpts // Scenario driver // --------------------------------------------------------------------------- +/// Per-scenario wall-clock budget; a hung daemon costs one scenario (reported +/// as a TIMEOUT violation), not the whole bucket. +const SCENARIO_DEADLINE: Duration = Duration::from_secs(180); + /// Full pipeline for one scenario: fresh repo + daemon, stack build, workflow /// replay (with blind recovery when applicable), attribution assertions. -/// Returns the collected violations (empty = pass). +/// Returns the collected violations (empty = pass). Bounded by +/// [`SCENARIO_DEADLINE`]; on timeout the scenario thread is abandoned and a +/// single "TIMEOUT" violation is returned. Scenario panics propagate to the +/// caller unchanged. pub fn run_scenario(scenario: &Scenario) -> Vec { + // Warm the once-per-process git-ai binary build before the clock starts, + // so the deadline and the timing line measure the scenario itself. + crate::repos::test_repo::get_binary_path(); + let started = std::time::Instant::now(); + let (sender, receiver) = mpsc::channel(); + let scenario_for_thread = scenario.clone(); + let handle = thread::Builder::new() + .name(format!("gt-sim-{}", scenario.id)) + .spawn(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + run_scenario_inner(&scenario_for_thread) + })); + let _ = sender.send(result); + }) + .expect("spawn scenario thread"); + + let violations = match receiver.recv_timeout(SCENARIO_DEADLINE) { + Ok(Ok(violations)) => { + let _ = handle.join(); + violations + } + Ok(Err(panic_payload)) => { + let _ = handle.join(); + std::panic::resume_unwind(panic_payload) + } + Err(_) => vec![format!( + "{}: TIMEOUT — scenario exceeded {}s wall-clock (daemon likely hung); thread abandoned", + scenario.id, + SCENARIO_DEADLINE.as_secs() + )], + }; + eprintln!( + "[gt-sim] {} finished in {:.1}s ({} violations)", + scenario.id, + started.elapsed().as_secs_f64(), + violations.len() + ); + violations +} + +fn run_scenario_inner(scenario: &Scenario) -> Vec { let mut repo = TestRepo::new_with_daemon_scope(match scenario.observation_mode() { // Blind scenarios restart the daemon mid-test, which requires a // dedicated daemon; traced scenarios share the pool daemon. @@ -842,12 +895,17 @@ pub fn run_scenario(scenario: &Scenario) -> Vec { } /// Run the scenario's workflow (honoring `repeat`), then the blind-recovery -/// step for `observation = blind`. +/// step for `observation = blind`. A sync barrier closes every round so +/// back-to-back rounds (and the daemon restart below) start from a fully +/// processed daemon state. pub fn run_workflow(repo: &mut TestRepo, state: &mut StackState, scenario: &Scenario) { let rounds = scenario.repeat_mode().rounds(); for round in 0..rounds { let x = Exec::for_scenario(repo, scenario); dispatch_family(&x, state, scenario, round); + // End-of-round barrier; also settles pending mock_ai checkpoint + // completions issued during blind rounds. + repo.sync_daemon_force(); } if scenario.observation_mode() == Observation::Blind { recover_blind(repo); @@ -895,6 +953,8 @@ fn dispatch_family(x: &Exec, state: &mut StackState, scenario: &Scenario, round: /// Blind recovery per PLAN: restart the daemon, then one traced no-op commit /// so the reflog cursor reconciles the unobserved ref moves. fn recover_blind(repo: &mut TestRepo) { + // Barrier before restart: the restart asserts no pending daemon work. + repo.sync_daemon_force(); repo.restart_dedicated_daemon_for_test(); repo.git(&["commit", "--allow-empty", "-m", "poke"]) .expect("blind-recovery poke commit should succeed"); diff --git a/tests/integration/graphite_workflows/lifecycle.rs b/tests/integration/graphite_workflows/lifecycle.rs index 9dc0ae15cf..8f48431bdb 100644 --- a/tests/integration/graphite_workflows/lifecycle.rs +++ b/tests/integration/graphite_workflows/lifecycle.rs @@ -12,7 +12,10 @@ fn run_bucket(family: &str, observation: &str, repeat: &str) { .into_iter() .filter(|s| s.observation == observation && s.repeat == repeat) .collect(); - assert!(!scenarios.is_empty(), "empty bucket {family}/{observation}/{repeat}"); + assert!( + !scenarios.is_empty(), + "empty bucket {family}/{observation}/{repeat}" + ); let mut violations = Vec::new(); for s in &scenarios { violations.extend(gt_sim::run_scenario(s)); @@ -48,4 +51,3 @@ fn lifecycle_traced_once() { fn lifecycle_traced_twice() { run_bucket("LIFECYCLE", "traced", "twice"); } - diff --git a/tests/integration/graphite_workflows/restack.rs b/tests/integration/graphite_workflows/restack.rs index ea200c8b35..6c2bfdbdd3 100644 --- a/tests/integration/graphite_workflows/restack.rs +++ b/tests/integration/graphite_workflows/restack.rs @@ -12,7 +12,10 @@ fn run_bucket(family: &str, observation: &str, repeat: &str) { .into_iter() .filter(|s| s.observation == observation && s.repeat == repeat) .collect(); - assert!(!scenarios.is_empty(), "empty bucket {family}/{observation}/{repeat}"); + assert!( + !scenarios.is_empty(), + "empty bucket {family}/{observation}/{repeat}" + ); let mut violations = Vec::new(); for s in &scenarios { violations.extend(gt_sim::run_scenario(s)); @@ -48,4 +51,3 @@ fn refstdin_blind_once() { fn refstdin_traced_once() { run_bucket("REFSTDIN", "traced", "once"); } - diff --git a/tests/integration/graphite_workflows/sync_ff.rs b/tests/integration/graphite_workflows/sync_ff.rs index 303e6764ae..68bb05e2a6 100644 --- a/tests/integration/graphite_workflows/sync_ff.rs +++ b/tests/integration/graphite_workflows/sync_ff.rs @@ -12,7 +12,10 @@ fn run_bucket(family: &str, observation: &str, repeat: &str) { .into_iter() .filter(|s| s.observation == observation && s.repeat == repeat) .collect(); - assert!(!scenarios.is_empty(), "empty bucket {family}/{observation}/{repeat}"); + assert!( + !scenarios.is_empty(), + "empty bucket {family}/{observation}/{repeat}" + ); let mut violations = Vec::new(); for s in &scenarios { violations.extend(gt_sim::run_scenario(s)); @@ -48,4 +51,3 @@ fn sync_ff_traced_once() { fn sync_ff_traced_twice() { run_bucket("SYNC_FF", "traced", "twice"); } - diff --git a/tests/integration/graphite_workflows/sync_restack.rs b/tests/integration/graphite_workflows/sync_restack.rs index 1ea4ead41a..4454b95254 100644 --- a/tests/integration/graphite_workflows/sync_restack.rs +++ b/tests/integration/graphite_workflows/sync_restack.rs @@ -12,7 +12,10 @@ fn run_bucket(family: &str, observation: &str, repeat: &str) { .into_iter() .filter(|s| s.observation == observation && s.repeat == repeat) .collect(); - assert!(!scenarios.is_empty(), "empty bucket {family}/{observation}/{repeat}"); + assert!( + !scenarios.is_empty(), + "empty bucket {family}/{observation}/{repeat}" + ); let mut violations = Vec::new(); for s in &scenarios { violations.extend(gt_sim::run_scenario(s)); @@ -48,4 +51,3 @@ fn sync_restack_traced_once() { fn sync_restack_traced_twice() { run_bucket("SYNC_RESTACK", "traced", "twice"); } - diff --git a/tests/integration/graphite_workflows/undo_move_misc.rs b/tests/integration/graphite_workflows/undo_move_misc.rs index 39320b4525..43264735ba 100644 --- a/tests/integration/graphite_workflows/undo_move_misc.rs +++ b/tests/integration/graphite_workflows/undo_move_misc.rs @@ -12,7 +12,10 @@ fn run_bucket(family: &str, observation: &str, repeat: &str) { .into_iter() .filter(|s| s.observation == observation && s.repeat == repeat) .collect(); - assert!(!scenarios.is_empty(), "empty bucket {family}/{observation}/{repeat}"); + assert!( + !scenarios.is_empty(), + "empty bucket {family}/{observation}/{repeat}" + ); let mut violations = Vec::new(); for s in &scenarios { violations.extend(gt_sim::run_scenario(s)); @@ -68,4 +71,3 @@ fn housekeeping_traced_twice() { fn flavor_17x_traced_once() { run_bucket("FLAVOR_17X", "traced", "once"); } - diff --git a/tests/integration/graphite_workflows/worktree_shapes.rs b/tests/integration/graphite_workflows/worktree_shapes.rs index 357c702d18..e9266f4f55 100644 --- a/tests/integration/graphite_workflows/worktree_shapes.rs +++ b/tests/integration/graphite_workflows/worktree_shapes.rs @@ -12,7 +12,10 @@ fn run_bucket(family: &str, observation: &str, repeat: &str) { .into_iter() .filter(|s| s.observation == observation && s.repeat == repeat) .collect(); - assert!(!scenarios.is_empty(), "empty bucket {family}/{observation}/{repeat}"); + assert!( + !scenarios.is_empty(), + "empty bucket {family}/{observation}/{repeat}" + ); let mut violations = Vec::new(); for s in &scenarios { violations.extend(gt_sim::run_scenario(s)); @@ -68,4 +71,3 @@ fn partial_stage_blind_once() { fn partial_stage_traced_once() { run_bucket("PARTIAL_STAGE", "traced", "once"); } - From 793784593fc194ddf7e1beb35ab9f87cb5c09bb0 Mon Sep 17 00:00:00 2001 From: Sandesh Devaraju Date: Tue, 28 Jul 2026 21:23:49 -0700 Subject: [PATCH 5/6] graphite_workflows: replay multi-commit branches, preserve merges as merges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restack core assumed single-commit branches: replay_branch_commit anchored the synthetic base at old_tip~, which for MERGE_IN_STACK's two-commit merge-carrying branch is the branch's OWN AI commit — the 3-way merge then resolved to exactly the new trunk tree and the branch's content (b1_ai.txt) was erased from the rewritten history. restack_range now replays the branch's own commits individually: - enumerate them via rev-list --reverse --first-parent .. and replay bottom-up, threading the new parent and recording per-commit old->new mappings; - non-merge commits keep the existing synthetic-base replay shape; - merge commits mint REAL merges: merged tree via the synthetic-base trick against the new first parent, then commit-tree with the new first parent plus every non-first parent (kept as-is when unchanged, remapped when replayed); - per-commit skip when a commit already sits on the right parent (subsumes the old whole-branch idempotency check); - branch.ai_commit_sha is retargeted through the per-commit map (A1 -> A1'), falling back to the tip only for the mapless rebase path. MERGE_IN_STACK traced/once: both scenarios now pass (0 violations). MERGE_IN_STACK blind/once: still fails via the blind-window note strand (content survives, notes never migrate to the raw-rewritten commits) — a genuine finding, left failing for triage. Smoke re-verified green. Co-Authored-By: Claude Fable 5 --- .../integration/graphite_workflows/gt_sim.rs | 141 ++++++++++++++++-- 1 file changed, 128 insertions(+), 13 deletions(-) diff --git a/tests/integration/graphite_workflows/gt_sim.rs b/tests/integration/graphite_workflows/gt_sim.rs index 71ce21079d..b8b7b61d91 100644 --- a/tests/integration/graphite_workflows/gt_sim.rs +++ b/tests/integration/graphite_workflows/gt_sim.rs @@ -29,6 +29,7 @@ use super::stackbuilder::{ write_ai_file, write_file, }; use crate::repos::test_repo::{DaemonTestScope, TestRepo}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::mpsc; use std::thread; @@ -333,6 +334,9 @@ fn restack_range( hash_object_storm(x, 20); } + // old->new shas of this branch's replayed commits, for retargeting + // ai_commit_sha through rewrites (A1 -> A1', not blindly to the tip). + let mut commit_map: HashMap = HashMap::new(); let new_tip = if old_tip == new_parent { // Already exactly at the new parent — nothing to do. old_tip.clone() @@ -349,21 +353,23 @@ fn restack_range( move_ref(x, batch, opts.refstyle, &name, &new_parent); new_parent.clone() } - } else if x.rev_parse(&format!("{old_tip}~")) == new_parent { - // Already based on the new parent (idempotent second run): skip. - old_tip.clone() } else { - match replay_branch_commit(x, &old_tip, &new_parent) { - Ok(minted) => { - if is_checked_out { - if checked_out_move(x, &minted) { - minted + match replay_branch_commits(x, &old_parent, &old_tip, &new_parent) { + Ok((minted_tip, map)) => { + commit_map = map; + if minted_tip == old_tip { + // Already based on the new parent (idempotent second + // run): nothing moved. + old_tip.clone() + } else if is_checked_out { + if checked_out_move(x, &minted_tip) { + minted_tip } else { old_tip.clone() } } else { - move_ref(x, batch, opts.refstyle, &name, &minted); - minted + move_ref(x, batch, opts.refstyle, &name, &minted_tip); + minted_tip } } Err(()) => { @@ -382,8 +388,14 @@ fn restack_range( let rewritten = new_tip != old_tip && old_tip != old_parent; let branch = &mut state.branches[index]; - if rewritten && branch.ai_commit_sha.is_some() { - branch.ai_commit_sha = Some(new_tip.clone()); + if let Some(ai_commit) = branch.ai_commit_sha.clone() { + if let Some(mapped) = commit_map.get(&ai_commit) { + branch.ai_commit_sha = Some(mapped.clone()); + } else if rewritten { + // No per-commit map (rebase fallback): the branch collapsed to + // a rewritten tip. + branch.ai_commit_sha = Some(new_tip.clone()); + } } branch.tip_sha = new_tip.clone(); old_parent = old_tip; @@ -395,7 +407,110 @@ fn restack_range( } } -/// Mint the restacked commit for a single-commit branch via the observed +/// Replay every commit the branch carries (its first-parent chain over +/// `old_parent..old_tip`) onto `new_parent`, bottom-up, threading the new +/// parent and recording per-commit old->new mappings. Non-merge commits use +/// the synthetic-base replay; merge commits are minted as REAL merges (first +/// parent remapped, non-first parents kept as-is unless they were replayed). +/// Commits already sitting on the right parent are skipped (idempotent +/// re-runs). Returns the new tip plus the mapping; Err on the first +/// merge-tree conflict (the caller falls back to a real rebase). +fn replay_branch_commits( + x: &Exec, + old_parent: &str, + old_tip: &str, + new_parent: &str, +) -> Result<(String, HashMap), ()> { + let commits = x.git(&[ + "rev-list", + "--reverse", + "--first-parent", + &format!("{old_parent}..{old_tip}"), + ]); + let mut map = HashMap::new(); + let mut cursor = new_parent.to_string(); + for commit in commits.lines().map(str::trim).filter(|c| !c.is_empty()) { + let parents = commit_parents(x, commit); + let first_parent = parents.first().cloned().unwrap_or_default(); + let minted = if first_parent == cursor { + commit.to_string() + } else if parents.len() <= 1 { + replay_branch_commit(x, commit, &cursor)? + } else { + replay_merge_commit(x, commit, &parents, &cursor, &map)? + }; + map.insert(commit.to_string(), minted.clone()); + cursor = minted; + } + Ok((cursor, map)) +} + +fn commit_parents(x: &Exec, commit: &str) -> Vec { + x.git(&["rev-list", "--parents", "-n", "1", commit]) + .split_whitespace() + .skip(1) + .map(str::to_string) + .collect() +} + +/// Mint a rewritten merge commit: merged tree via the synthetic-base trick +/// against the new first parent, then a real merge commit keeping every +/// non-first parent (remapped when it was itself replayed). +fn replay_merge_commit( + x: &Exec, + old_merge: &str, + old_parents: &[String], + new_first_parent: &str, + map: &HashMap, +) -> Result { + x.git(&["cat-file", "-p", &format!("{old_merge}~")]); + let synthetic = x + .git(&[ + "commit-tree", + &format!("{new_first_parent}^{{tree}}"), + "-p", + &format!("{old_merge}~"), + "-m", + "_", + ]) + .trim() + .to_string(); + let merged = x + .try_git(&[ + "merge-tree", + "--allow-unrelated-histories", + &synthetic, + old_merge, + ]) + .map_err(|_| ())?; + let tree = merged + .lines() + .next() + .expect("merge-tree should print a tree oid") + .trim() + .to_string(); + let message = x + .git(&["log", "-1", "--format=%B", old_merge]) + .trim() + .to_string(); + + let mut args: Vec = vec![ + "commit-tree".to_string(), + tree, + "-p".to_string(), + new_first_parent.to_string(), + ]; + for parent in &old_parents[1..] { + args.push("-p".to_string()); + args.push(map.get(parent).cloned().unwrap_or_else(|| parent.clone())); + } + args.push("-m".to_string()); + args.push(message); + let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect(); + Ok(x.git(&arg_refs).trim().to_string()) +} + +/// Mint the restacked commit for a single (non-merge) commit via the observed /// plumbing sequence. Returns Err on a merge-tree conflict. fn replay_branch_commit(x: &Exec, old_tip: &str, new_parent: &str) -> Result { x.git(&["cat-file", "-p", &format!("{old_tip}~")]); From 09c569e2e15e27e4b455d29114d31ef5aec93d20 Mon Sep 17 00:00:00 2001 From: Sandesh Devaraju Date: Tue, 28 Jul 2026 21:24:57 -0700 Subject: [PATCH 6/6] graphite_workflows: failure taxonomy from first full run 540 scenarios: 332 pass, 208 fail, all failures triaged to root cause. Genuine application gaps: (1) unobserved non-FF ref moves strand notes with no reconciliation path (gt's plumbing writes no rebase spans, so even span-keyed recovery cannot fire); (2) pending working-log loss via sideways reset --keep onto minted siblings (src/daemon.rs:5648, forward- only carry), cross-worktree update-ref (src/daemon.rs:5734), and stash pop -q flag mis-parse (src/daemon/ref_cursor.rs:922). Validated healthy: forward resets (#1976), undo bounds (#1978/#1983), sync-FF, traced amends, conflict-abort. One harness bug found and fixed (multi-commit merge replay). Co-Authored-By: Claude Fable 5 --- .../graphite_workflows/EXPECTED_FAILURES.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/integration/graphite_workflows/EXPECTED_FAILURES.md diff --git a/tests/integration/graphite_workflows/EXPECTED_FAILURES.md b/tests/integration/graphite_workflows/EXPECTED_FAILURES.md new file mode 100644 index 0000000000..3a0f8cf773 --- /dev/null +++ b/tests/integration/graphite_workflows/EXPECTED_FAILURES.md @@ -0,0 +1,111 @@ +# Graphite Workflow Suite — Failure Taxonomy + +First full run: 2026-07-28, branch `graphite-workflow-attribution-suite`, +540 scenarios / 45 buckets. **332 pass, 208 fail** (2,045 itemized +violations). Failures cluster into the mechanisms below; each has been +triaged per PLAN.md Part 3 with representative scenarios re-run in +isolation on a calm machine. + +## Validated-healthy areas (all scenarios pass) + +- `SYNC_FF` (32/32): forward `reset -q --keep` trunk moves carry the + working log — upstream #1976 confirmed effective, traced and blind, + including twice-idempotency. +- `UNDO` (24/24): backward `reset --keep` across the fork point loses + nothing and fabricates nothing — #1978/#1983 confirmed effective. +- `MODIFY` traced (24/24): amend + descendant cascade preserves + attribution when observed. +- `CONFLICT_ABORT` blind (8/8): abort restores pre-workflow tips and notes. +- `HOUSEKEEPING`, `FLAVOR_17X` traced: metadata flows and 1.7.x object + storms are attribution-inert. + +## Mechanism 1 — blind gt rewrites are unrecoverable (GENUINE, ~154 scenarios) + +**Signature:** observation=blind × attribution=committed → +`human_blame` + `missing_note` on every rewritten commit. +**Families:** SYNC_RESTACK, RESTACK, REFSTDIN, WORKTREE, MODIFY, +MOVE_ONTO, RENAME, LIFECYCLE (blind clusters). +**Triage verdict: GENUINE** (verified on GT-RESTACK-002; traced twin +GT-RESTACK-001 passes in the same run, isolating the loss to the blind +dimension). Evidence: the intact 457-byte note remains attached to the +old pre-restack commit (unreachable except via reflog); the rewritten +commit has none; branch reflog contains only `commit` → +`reset: moving to ` → `commit: poke` — **no rebase spans exist**, +because gt's plumbing path never runs `git rebase`. After restart the +recovery poke cold-seeds a fresh `RefCursor` clamped to its own reflog +entry (`ref_cursor.rs::initialize_from_command_reflog_start_offsets`), +deliberately skipping the blind rows as prior untraced history; note +translation (`handle_non_fast_forward_rewrite`) only runs on +live-observed moves. Main has NO unobserved-rewrite reconciliation, and +pending upstream PR #1961 keys strictly on `rebase (start)/(finish)` +spans — it would not fire on this shape either. +**Upstream direction:** generalize post-restart reconciliation from +rebase spans to arbitrary unobserved non-fast-forward branch-ref moves: +screen recent reflog transitions whose old tip carries an ai note and +whose new tip lacks one, then replay note translation across the +old→new pair. (Server-side backfill from pre-rewrite noted heads remains +the retroactive remedy.) + +## Mechanism 2 — working-log loss through traced restacks (GENUINE, ~54 scenarios) + +**Signature:** attribution=uncommitted|mixed, even observation=traced → +pending AI lines blamed human; their eventual commit carries an +empty-attestation note (the production "empty note" shape). +**Families:** SYNC_RESTACK, RESTACK, REFSTDIN, WORKTREE, LIFECYCLE, +SUBMIT, PARTIAL_STAGE, CONFLICT_CONTINUE (traced mixed/uncommitted +clusters). +**Triage verdict: GENUINE — three distinct proximate defects, one shared +signature** (verified with minimal repros outside the harness; forward +controls pass, so #1976 itself is confirmed working): + +- **(A) Sideways `reset -q --keep` onto a commit-tree-minted sibling** + (gt's core restack move of the checked-out branch): the daemon's Reset + handler renames the working log only for ancestor-related moves + (`src/daemon.rs:5648-5673`; #1976 covers forward only). The minted + replacement is a sibling, so the flow falls through to + `handle_non_fast_forward_rewrite`, which shifts committed notes but + never the working log. Pending checkpoints stay keyed to the + unreachable old tip → the eventual commit mints an empty-attestation + note; pending lines blame human. Verified on GT-RESTACK-005; minimal + 4-command repro in the triage log. +- **(C) Cross-worktree `update-ref`**: the update-ref working-log carry + (`src/daemon.rs:5713-5766`) requires the move to affect the *invoking + command's* HEAD — a branch checked out in a different worktree records + no HEAD reflog entry there, so even plain fast-forward moves strand the + log. Drives the WORKTREE mixed/uncommitted clusters on all shapes. +- **(D) `stash pop -q` restore never runs**: `enrich_stash` + (`src/daemon/ref_cursor.rs:916-931`) passes `stash_args.get(1)` as the + stash target without stripping flags, so `-q` fails target resolution + and the attribution saved at `stash push` (verified present in + `.git/ai/stashes_v2//INITIAL`) is never restored. All upstream + stash tests are flag-less. Drives CONFLICT_CONTINUE. + +**Upstream direction:** carry the working log old→new in the Reset +handler's non-ancestor arm and in the update-ref handler for branches +checked out in *any* worktree (mirroring the unconditional rename that +`apply_checkout_switch_working_log_side_effect` already performs for +checkouts), and skip flags when resolving stash pop targets (default +`stash@{0}`). + +## Mechanism 3 — MERGE_IN_STACK blame failure (HARNESS BUG, 4 scenarios) + +**Signature:** `git-ai blame failed: ... Failed to canonicalize +file path ... No such file or directory` + missing note. +**Verdict:** harness artifact — NOT a git-ai bug. `git-ai blame` never +crashed; the harness's restack core assumes single-commit branches and, +for the two-commit merge-carrying branch, anchors the synthetic base at +the branch's own AI commit (`old_tip~`) instead of the old stack base. +The merge-tree then resolves to exactly the new trunk tree, deleting the +branch's AI file. **FIXED in the harness** (commit `215634667`): branch +commits are enumerated via `rev-list --reverse --first-parent` and +replayed bottom-up with per-commit old→new mapping; merge commits are +minted as real merges with remapped parents. Post-fix outcomes: both +traced scenarios PASS with zero violations; both blind scenarios fail +purely via Mechanism 1 (content survives, rewritten commits lack notes) +and are reclassified there. + +**Side observation from triage** (needs follow-up, possibly genuine): the +daemon attached an empty-attestation note to a *raw, trace2-disabled* +human trunk commit (`base_commit_sha` = itself, one mock_ai session, no +file entries) — a note mis-landed on a commit the daemon nominally never +observed.