From c992f862462bb051c3bacd6a68bbd8a22f9b77a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 31 Oct 2026 16:28:38 +0100 Subject: [PATCH] build: run parallel work in worktrunk worktrees beside the repository Worktrees for parallel fixes and features come from `wt switch -c`, at ../CodoMetis.TypeKit-worktrees/, with worktrunk's default branch set to the open release branch, so new branches, the plugin's agent worktrees and `wt remove`/`wt step prune` all base on release/ rather than main. The Bash guard refuses `wt merge` and `wt step squash|rebase|push`, which squash, rebase or fast-forward locally: work lands by a PR and a merge commit (decision 94). Seeded twice: the detection removed fails 17 cases, an option's value read as the subcommand fails 4. No .worktreeinclude and no copy hook: a fresh worktree built the solution cold in 27 s with nothing copied in. `.claude/worktrees/` joins .gitignore for the desktop app's own sessions. design/worktrees.md is the runbook; decision 126 the reasons. Refs #265 Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + AGENTS.md | 12 +- CONTRIBUTING.md | 6 +- ...rktrunk-worktrees-beside-the-repository.md | 79 +++++++++++++ design/adr/README.md | 1 + design/plan.md | 1 + design/worktrees.md | 105 ++++++++++++++++++ .../GuardTests.cs | 40 +++++++ tools/BashGuard/Program.cs | 38 ++++++- 9 files changed, 277 insertions(+), 8 deletions(-) create mode 100644 design/adr/0126-parallel-work-runs-in-worktrunk-worktrees-beside-the-repository.md create mode 100644 design/worktrees.md diff --git a/.gitignore b/.gitignore index f27dc071..b72a6056 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ BenchmarkDotNet.Artifacts/ # docs-query's index (tools/DocsQuery): one file, fingerprinted, rebuilt when the docs change /.docs-query.cache /.docs-query.cache.*.tmp + +# The desktop app's own worktree sessions (design/worktrees.md); worktrunk's go beside the repository +.claude/worktrees/ diff --git a/AGENTS.md b/AGENTS.md index ae2b6c1e..03b15844 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,8 +78,8 @@ An agent builds and tests through `tools/BuildVerdict` (`build []` verdict: the exit code named, each diagnostic once with its remedy from `tools/BuildVerdict/build-verdict-remedies.tsv` (give an UNMAPPED code a row once its fix is known), and the failing tests; the full log is `$GIT_DIR/build-verdict-last.log`. In an agent's Bash tool, `tools/BashGuard` -refuses the raw `dotnet build`, `dotnet test` and `dotnet msbuild` and prints the verdict's form of the command; -the raw commands above are for a terminal. The tools run from `bin/Release`, built at session start in the +refuses the raw `dotnet build`, `dotnet test` and `dotnet msbuild` and prints the verdict's form of the command, +and refuses `wt merge` and `wt step squash|rebase|push`; the raw commands above are for a terminal. The tools run from `bin/Release`, built at session start in the session's own checkout ([decision 105](design/adr/0105-every-tool-is-built-in-the-session-s-checkout.md)). A tool built from other sources refuses to run and names the command that rebuilds it, `dotnet build tools/ -c Release`. @@ -113,8 +113,8 @@ here, so it leads and the source decides. change" from it alone. - **Refresh it**, since it does not follow the working tree: `index_repository` with the repository root as `repo_path` and `name: "CodoMetis.TypeKit"`, at the start of a session and after a branch switch, a - rebase, a merge or a pull. A worktree under `.claude/worktrees/` isn't indexed: index it under a name of - its own and delete that project when done, or search the source. + rebase, a merge or a pull. A worktree under `.claude/worktrees/` or `../CodoMetis.TypeKit-worktrees/` isn't + indexed: index it under a name of its own and delete that project when done, or search the source. The way into the docs is docs-query (`dotnet tools/DocsQuery/bin/Release/net10.0/docs-query.dll`, [its README](tools/DocsQuery/README.md)): `entry`, `cites`, `cited-by`, `list`, `claims` and `check` @@ -212,7 +212,9 @@ What trips agents here: `spike:`. A `fix:` ends with the trailers `Defect-Class:` and `Found-By:`, one of each, from the tables of [decision 94](design/adr/0094-topic-branches-into-a-release-branch-and-fix-trailers.md) (`CommitTrailerTests`). Work happens on a `/` branch from `release/`, reaches it - by a PR, and every merge is a merge commit: never squash or rebase a pushed branch. + by a PR, and every merge is a merge commit: never squash or rebase a pushed branch. Parallel work gets + a worktree each, made by `wt switch -c / --no-cd` beside the repository, with the session + started in it ([design/worktrees.md](design/worktrees.md), [decision 126](design/adr/0126-parallel-work-runs-in-worktrunk-worktrees-beside-the-repository.md)). 7. **Hold what the documents promise** ([decision 112](design/adr/0112-the-prober-gives-way-to-valueranges-oracle-kit.md), [test/oracles/README.md](test/oracles/README.md) for the files and how to add an oracle). A new public member, or a new promise in `docs/` or a README, gets a claim in `test/oracles/claims.json`, quoted verbatim and naming diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4cc44779..586d7a53 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -148,7 +148,8 @@ Work for the next version goes into its release branch, `release/` (`re into `main` ([decision 94](design/adr/0094-topic-branches-into-a-release-branch-and-fix-trailers.md)). Branch from it as `/`, where the type is your main commit's (`feat/…`, `fix/…`, `docs/…`, `build/…`), and open the PR against it. PRs are merged with a merge commit, never squashed or rebased: -the design records cite commits by hash. +the design records cite commits by hash. To work on several at once, give each its own worktree beside +the repository: [design/worktrees.md](design/worktrees.md) has the commands. - One concern per PR, closing one issue, with its labels and milestone ([above](#issues-and-labels)). The description follows the template: `Closes #n`, then Problem, Change and Tests, with the reasoning @@ -207,7 +208,8 @@ The packages share one version and are released together, only through `## — Unreleased` section. `ChangelogTests` fails while the baseline trails the last version the changelog dates, and pack while the new section is empty, so open it with its first line. Once that is on `main`, cut the next `release/` from it and - push it, and open the version's milestone and its `epic` issue. + push it, open the version's milestone and its `epic` issue, and point worktrunk at the new branch in + each clone: `wt config state default-branch set release/`. `workflow_dispatch` with `dry_run` (the default) rehearses everything but the push. diff --git a/design/adr/0126-parallel-work-runs-in-worktrunk-worktrees-beside-the-repository.md b/design/adr/0126-parallel-work-runs-in-worktrunk-worktrees-beside-the-repository.md new file mode 100644 index 00000000..f5d17a5d --- /dev/null +++ b/design/adr/0126-parallel-work-runs-in-worktrunk-worktrees-beside-the-repository.md @@ -0,0 +1,79 @@ +# 0126. Parallel work runs in worktrunk worktrees beside the repository, based on the release branch + +Date: 2026-10-31 +Status: accepted + +Context: 2026-10-31, issue #265, at the start of 2.4.0. 2.3.0 closed with eight PRs in flight at once and 2.3.1 +with ten, each in a worktree of its own. Those worktrees came from three places, each with a flaw: + +- **The desktop app's worktree sessions** go to `.claude/worktrees/` on `claude/`, inside the + repository, kept out of `git add -A` only by a line in `.git/info/exclude`, which no clone carries. On this day + the main checkout had 19 linked worktrees, 17 of them there, several detached and several for merged work. +- **Agents with `isolation: "worktree"`** started at `origin/main`, not at the release branch: on 2026-10-13 three + of four were 49 commits behind `release/2.0.0`. +- **Hand-made worktrees** (`git worktree add`) under the scratchpad and `/tmp`, each named and removed by hand. + +The maintainer adopted worktrunk (`wt`, 0.81.0, and its Claude Code plugin at commit `2c04fb370427`) in another +repository the same day, with a handoff of what was measured there. This record fits it to this repository, +whose work lands by a pull request into `release/` with a merge commit +([decision 94](0094-topic-branches-into-a-release-branch-and-fix-trailers.md)), not by a local merge into `main`. + +## Decision + +**Worktrees go beside the repository**, at `../CodoMetis.TypeKit-worktrees/`, made by +`wt switch -c /`. The path is set in each machine's worktrunk user config, since the project file +cannot set it. Outside the repository there is no ignore rule to keep right. `.claude/worktrees/` joins the +tracked `.gitignore` for the desktop app's sessions, which ignore the plugin. + +**worktrunk's default branch is the open release branch**, `wt config state default-branch set release/`, +set per clone and moved by CONTRIBUTING's "Releasing" when the next release branch is cut. One setting makes +`wt switch -c` branch from the release, the plugin's agent worktrees start there (its hook runs +`wt switch --create`), and `wt remove` and `wt step prune` judge "merged" against the branch topics merge into. + +**Nothing lands locally.** The Bash guard refuses `wt merge` and its building blocks `wt step squash`, `rebase` +and `push`, past worktrunk's global options and except with `--help`: by default they squash, rebase and +fast-forward onto the default branch, and none writes a merge commit. The user config turns the four `merge.*` +keys off as a net. The guard reads the words the shell would pass, like its other rules; `wt` called through a +wrapper or a variable passes it. + +**Nothing is copied into a new worktree.** No `.worktreeinclude` and no `.config/wt.toml` hook, since a fresh +worktree needs no gitignored file to build, and so no hook waits on `wt config approvals add`. + +[design/worktrees.md](../worktrees.md) is the runbook, linked from AGENTS.md and CONTRIBUTING. + +## Evidence + +Measured 2026-10-31 on macOS (arm64), SDK 10.0.4xx, in a worktree `wt switch -c build/worktrunk-worktrees` made +with no `--base` from `release/2.4.0` at `3be21dc`: + +| Step | Result | +|---|---| +| `wt switch -c`, default branch set to `release/2.4.0` | branch and worktree from `release/2.4.0`, at `../CodoMetis.TypeKit-worktrees/build-worktrunk-worktrees` | +| the guard and the tools, cold, as SessionStart builds them | 2 s, no error | +| the whole solution, cold, through `build-verdict build` | 27 s, 33 projects, no error, nothing copied in | +| `CodoMetis.TypeKit.BashGuard.Tests` | 112 passed | + +The other repository measured a copy of its build caches at 1.9 GiB in 6.3 s, saving about 2 s, and found the +copied `obj/` holding absolute paths into the primary checkout; the compiler servers and the NuGet cache are +shared per machine, which is why a cold worktree builds fast here too. + +The guard's rule was shown to fail against two seeded defects: the detection removed (17 of the new cases fail: +every refused `wt merge` and `wt step` form, and the message test), and the value of `-C`, `--config` and +`--config-set` read as a subcommand (4 fail: the forms after `-C`, after `--config-set`, a step after options, and +the message test). + +Not measured: an agent's `isolation: "worktree"` under the plugin starting at the release branch. The plugin +loads only in a session started after its install, and worktrunk's documentation is the source for its hook +running `wt switch --create`. + +## Consequences + +- A session works in the worktree it was started in. `build-verdict` tests the checkout it was built in, so a + session in the main checkout that edits a worktree judges the wrong tree, and the code graph does not cover a + worktree outside the repository. +- Every clone sets two things the repository cannot: the worktree path in worktrunk's user config and the default + branch in its local git config. A clone that sets neither gets worktrunk's sibling layout (`../repo.branch`) + and branches from `main`. +- An agent worktree made by the plugin is named `agent-`, which PR hygiene refuses as a head branch, and + outlives its agent; the runbook pre-creates worktrees for subagents instead. +- The plugin follows worktrunk's `main`. Its behaviour is re-checked after `claude plugin update`. diff --git a/design/adr/README.md b/design/adr/README.md index 9abd10ba..2ba9c800 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -128,3 +128,4 @@ per area ([design](../plan.md)) link to the records they rest on. | [0119](0119-a-gap-found-before-the-tag-ships-in-that-release.md) | A form an Error misses, found before its version is tagged, is reported in that version when it loses what the rule protects in every build, though code built on the previous minor can contain it: 2.3.0's CMTK0003 on a `Task.WhenAny` of results (#159), `Wait()` on a branch (#160), an array in `Task.WaitAll`/`WaitAny` (#161), a dropped `Task.WhenEach` (#162), and a `WhenAny` of held tasks silent; the rule stops at the tag, after which decision 34 holds; refines 34 and 118 | 2026-10-29 | accepted | | [0120](0120-a-net11-0-target-and-documented-floor-raises-join-a-minor.md) | A `net11.0` target joins a 2.x minor when a feature needs it, and a dependency floor may rise in a minor, on every target or on `net11.0` alone, if it is documented (the Semantic Versioning FAQ calls a dependency update with an unchanged public API compatible; a project that references the dependency lower gets NU1605, measured on SDK 11 RC1, so the changelog names the floor, who it reaches and both ways out, and the smoke test shows it); a union new on `net11.0` is an addition and may join a 2.x minor (a `LangVersion` 14 consumer who matches on it gets CS9327, and on Metalama 2026.1 CS8652, measured); Metalama is one version for every target and TypeKit follows the maintained Metalama year in a minor: 2.3 and 2.4 stay on 2026.1 and 2.5.0 moves both targets to 2027.0 once stable (a direct 2026.1 reference gets NU1605 and stays on 2.4.x; a woven `net11.0` project goes from C# 14 to 15; conditions: a license-free CI and clean-container build, the ceiling and the 2.4.x line documented); the SDK 11 spike comes first (#170); 3.0.0 stays the `net10.0` drop and a change of an existing contract, timed to .NET 12 LTS, and .NET 11 is no reason for a major; refines decision 44 | 2026-10-29 | accepted | | [0121](0121-a-generated-member-is-refused-wherever-the-value-object-reaches-it.md) | CMTK1011, and CMTK1008 for comparison, refuse a generated member written by hand wherever a value object or closed set reaches it: on it, in a base record, public or explicit, or as an interface's explicit implementation, in any assembly, found by the interface mapping since Metalama lists no private member of another assembly's type; a base record's `Create` beside the value object's own (#195) and a seam a base record declares (#194) too; `IConvertible`, `IValueObject<,>`, `IValueObjectMaterializer<,>`, `IPlainValueObject<,>` and a set's `IParsable` declared through an interface or by a base record are CMTK1011 where the aspect crashed (#192); a closed set's base class counts as a base record does, its `sealed` `ToString()` the seam and one not sealed refused (#205); an interface's `==` and .NET's own `INumberBase` bridges are not refused; an exception to 34 taken in the patch 2.3.1 (#191, #192, #194, #195, #197, #205) | 2026-10-30 | accepted | +| [0126](0126-parallel-work-runs-in-worktrunk-worktrees-beside-the-repository.md) | Parallel work runs in worktrunk worktrees at `../CodoMetis.TypeKit-worktrees/`, set per machine in worktrunk's user config; worktrunk's default branch is the open `release/`, set per clone, so `wt switch -c`, the plugin's agent worktrees, `wt remove` and `wt step prune` base on it; the Bash guard refuses `wt merge` and `wt step squash`, `rebase` and `push`, since work lands by a PR and a merge commit (decision 94); nothing is copied into a new worktree, which builds the solution cold in 27 s; `.claude/worktrees/` joins `.gitignore`; [design/worktrees.md](../worktrees.md) is the runbook | 2026-10-31 | accepted | diff --git a/design/plan.md b/design/plan.md index 9a76a937..c3ab3265 100644 --- a/design/plan.md +++ b/design/plan.md @@ -24,6 +24,7 @@ you touch; the rest is reference. | [Mutation survivors, 2026-10-16](mutation-2026-10-16.md) | The first Stryker.NET run, on the Serilog satellite: counts per package, each survivor read as a missing assertion or an equivalent mutant, and the valid mutants the compile lost | one dated page per run | | [Finder benchmark, 2026-10-16](finder-benchmark-2026-10-16.md) | The 24 release escapes put back as seeds: which test, probe row or nothing detects each, totals per finder as applied, reached and detected, and the accounting every probe round and analyst run reports | one dated page per run | | [Oracles](../test/oracles/README.md) | What the documents promise as claims held by named tests, every public member claimed or waived, the oracle classes and their seeds, the candidates and the axes a consumer varies ([decision 112](adr/0112-the-prober-gives-way-to-valueranges-oracle-kit.md)) | with each feature and each promise a document gains | +| [Parallel work in worktrees](worktrees.md) | The runbook for worktrunk: worktrees beside the repository, based on the release branch, landed by PR, and what to know ([decision 126](adr/0126-parallel-work-runs-in-worktrunk-worktrees-beside-the-repository.md)) | when worktrunk or the plugin changes | | [The code graph](code-graph.md) | What the codebase-memory graph was measured to find and miss, and how to keep it current; AGENTS.md's "Finding code" rests on it | when the graph is measured again | | [Probing without the tooling](probing-without-tooling.md) | How BLite applied the probing concept by hand, with no prober, covering array or ledger: what each tool function became, the process as it ran, the issues and PRs it produced, and an adoption guide for trying the same here without the tooling | copied 2026-10-18; as the method is tried here | diff --git a/design/worktrees.md b/design/worktrees.md new file mode 100644 index 00000000..52426aae --- /dev/null +++ b/design/worktrees.md @@ -0,0 +1,105 @@ +# Parallel work in worktrees + +How several fixes and features run at once, each on its own branch in its own worktree, made and cleaned up +by worktrunk (`wt`). [Decision 126](adr/0126-parallel-work-runs-in-worktrunk-worktrees-beside-the-repository.md) +holds the reasons and the measurements; this page holds the commands. + +``` +~/RiderProjects/ +├── CodoMetis.TypeKit/ ← the main checkout +└── CodoMetis.TypeKit-worktrees/ ← every worktrunk and agent worktree + ├── fix-ef-boxed-ordering/ ← branch fix/ef-boxed-ordering (`/` becomes `-`) + └── feat-value-object-arithmetic/ ← branch feat/value-object-arithmetic +``` + +## Once per machine + +```bash +brew install worktrunk # or: cargo install worktrunk +wt config shell install # then start a new shell +claude plugin marketplace add max-sixty/worktrunk +claude plugin install worktrunk@worktrunk # loads in new sessions only +``` + +Then, in `~/.config/worktrunk/config.toml`, under the identifier `wt config show` prints for this checkout +(`github.com/CaffeinatedCoder/CodoMetis.TypeKit` for a clone of the main repository): + +```toml +[projects."github.com/CaffeinatedCoder/CodoMetis.TypeKit"] +worktree-path = "{{ repo_path }}/../{{ repo }}-worktrees/{{ branch | sanitize }}" +# A net under the guard's refusal of `wt merge`: nothing lands locally here. +merge.squash = false +merge.rebase = false +merge.ff = false +merge.commit = false +``` + +The project file `.config/wt.toml` cannot set the path, so each machine sets it. A clone without a remote is +keyed by its path, and adding a remote later changes the key: run `wt config show` again then. + +## Once per release branch + +```bash +wt config state default-branch set release/2.4.0 +``` + +worktrunk's default branch is what `wt switch -c` branches from, what the plugin's agent worktrees start at, +and what `wt remove` and `wt step prune` call "merged". Left at `main`, a new branch misses the open release's +work and a merged topic never counts as merged. The setting lives in the clone's local git config, so every +clone sets it, and CONTRIBUTING's "Releasing" moves it with the next release branch. + +## Every task + +```bash +git fetch origin release/2.4.0:release/2.4.0 # the base, current +wt switch -c fix/ef-boxed-ordering --no-cd # branch from release/2.4.0, worktree beside the repo +``` + +Start the session *in* `../CodoMetis.TypeKit-worktrees/fix-ef-boxed-ordering`. Its SessionStart hook builds the +guard and the tools there ([decision 105](adr/0105-every-tool-is-built-in-the-session-s-checkout.md)), and +`build-verdict` tests the checkout it was built in, so a session started in the main checkout judges the wrong +tree. Name the branch from the issue's type label (`feat/`, `fix/`, `docs/`, `test/`, `build/`), as PR hygiene +reads it. + +To hand subagents a worktree each from one session, make the worktrees with `wt switch -c ... --no-cd` and name +the path in each agent's prompt, without `isolation: "worktree"`. + +```bash +wt list # every worktree, ahead/behind release, agent markers +git push -u origin fix/ef-boxed-ordering # then open the PR into release/2.4.0 +``` + +Work lands by the PR and its merge commit +([decision 94](adr/0094-topic-branches-into-a-release-branch-and-fix-trailers.md)). The Bash guard refuses +`wt merge` and `wt step squash`, `rebase` and `push`, which squash, rebase or fast-forward locally by default. + +After the merge: + +```bash +git fetch origin release/2.4.0:release/2.4.0 # wt judges "merged" against the local branch +wt remove fix/ef-boxed-ordering # deletes the branch once merged; -D forces +wt step prune --dry-run # sweeps merged worktrees older than a day +``` + +## What to know + +- **Nothing is copied into a new worktree.** A fresh one builds the solution cold in 27 s and its tools in 2 s + (measured 2026-10-31): no gitignored file is needed, so there is no `.worktreeinclude` and no `post-start` + hook. Add both if that changes, and measure first: copied `obj/` folders keep absolute paths into the main + checkout. +- **Agents with `isolation: "worktree"` follow the plugin**: its hook makes their worktrees with + `wt switch --create`, so they land beside the repository and start at the default branch (worktrunk's + documentation; not yet observed here, where agents used to start at `origin/main`). They are named `agent-` + on a branch of the same name, which PR hygiene refuses, and outlive the agent until `wt remove` or + `wt step prune`. Pre-creating the worktree avoids both. +- **The desktop app's own worktree sessions ignore the plugin.** They go to `.claude/worktrees/` on + `claude/`, which `.gitignore` keeps out of `git add -A`. +- **Ids minted in parallel collide**, textually clean: decision records, `design/knowledge/` entries and claim + ids in `test/oracles/claims.json`. Hand each parallel task its numbers, and check them against the release + branch right before the merge. +- **The code graph does not cover a worktree outside the repository.** Index it under a name of its own, as + AGENTS.md's "Finding code" says, or search the source. +- **The plugin installs from worktrunk's `main`, not a release.** Its worktree hooks need `wt` on Claude Code's + `PATH` (`/opt/homebrew/bin` on macOS). Check `claude plugin list` after `claude plugin update`. +- **The guard reads words, not every shell.** `wt` called through a wrapper (`cargo run --bin wt`, a variable) + passes it. diff --git a/test/CodoMetis.TypeKit.BashGuard.Tests/GuardTests.cs b/test/CodoMetis.TypeKit.BashGuard.Tests/GuardTests.cs index f7ce8c62..af374c15 100644 --- a/test/CodoMetis.TypeKit.BashGuard.Tests/GuardTests.cs +++ b/test/CodoMetis.TypeKit.BashGuard.Tests/GuardTests.cs @@ -84,6 +84,23 @@ internal static string Payload(string command) [InlineData("a query that restores, short", "dotnet msbuild src/A/A.csproj -r -getItem:PackageReference")] [InlineData("a property set, not queried", "dotnet msbuild src/A/A.csproj -property:Configuration=Release")] [InlineData("a build that queries", "dotnet build src/A/A.csproj -getProperty:TargetPath")] + // worktrunk's local landing: work reaches a release branch by a pull request and a merge commit + [InlineData("wt merge", "wt merge")] + [InlineData("wt merge into a target", "wt merge release/2.4.0")] + [InlineData("wt merge with every merge key off", "wt merge --no-squash --no-rebase --no-ff")] + [InlineData("wt merge after -C", "wt -C ../CodoMetis.TypeKit-worktrees/fix-x merge")] + [InlineData("wt merge after --config-set", "wt --config-set merge.squash=false merge")] + [InlineData("wt merge after -v", "wt -vv merge")] + [InlineData("wt merge by its path", "/opt/homebrew/bin/wt merge")] + [InlineData("wt merge after command and an assignment", "WORKTRUNK_VERBOSE=1 command wt merge")] + [InlineData("wt merge after &&", "git push && wt merge")] + [InlineData("wt merge, quoted", "wt 'merge'")] + [InlineData("wt merge in bash -c", "bash -c 'wt merge'")] + [InlineData("wt merge in a substitution", "echo $(wt merge)")] + [InlineData("wt step squash", "wt step squash")] + [InlineData("wt step rebase", "wt step rebase release/2.4.0")] + [InlineData("wt step push", "wt step push")] + [InlineData("wt step push after options", "wt -C x step -v push")] public void Refused(string name, string command) => Guard(Payload(command)).Code.ShouldBe(2, name); // ---- what passes ---------------------------------------------------------------------------------- @@ -119,6 +136,18 @@ internal static string Payload(string command) [InlineData("in a tab-stripped heredoc body", "cat <<-EOF\n\tdotnet test\n\tEOF\necho done")] [InlineData("a here-string", "grep -c x <<< \"dotnet build\"")] [InlineData("a tail that reads a log", "tail -20 \"$(git rev-parse --absolute-git-dir)/build-verdict-last.log\"")] + [InlineData("wt switch -c", "wt switch -c fix/merge-key --no-cd")] + [InlineData("wt switch to a branch named merge", "wt switch merge")] + [InlineData("wt list", "wt list --format json")] + [InlineData("wt remove", "wt remove fix/x")] + [InlineData("wt step prune", "wt step prune --dry-run")] + [InlineData("wt step diff", "wt step diff --branch fix/x")] + [InlineData("wt step for-each running git", "wt step for-each -- git status")] + [InlineData("wt config naming merge keys", "wt --config-set merge.squash=false list")] + [InlineData("help for wt merge", "wt merge --help")] + [InlineData("wt merge as an argument", "echo wt merge")] + [InlineData("wt merge in a search pattern", "grep -n \"wt merge\" design/worktrees.md")] + [InlineData("git merge", "git merge --no-ff fix/x")] // The Python original looped forever on these, until the hook's timeout. [InlineData("a command ending in a redirection", "ls 2>")] [InlineData("a command ending in a heredoc operator", "cat <<")] @@ -156,6 +185,17 @@ public void A_build_refusal_keeps_its_target_and_flags() error.ShouldContain("Run: dotnet tools/BuildVerdict/bin/Release/net10.0/build-verdict.dll build src/A/A.csproj -- -c Release"); } + [Fact] + public void A_landing_refusal_names_the_pull_request() + { + var (code, error) = Guard(Payload("wt -C x step push")); + + code.ShouldBe(2); + error.ShouldContain("Refused: `wt step push`", customMessage: "it names the step it refused"); + error.ShouldContain("by a pull request and a merge commit (decision 94)", customMessage: "it says how work lands"); + error.ShouldContain("design/worktrees.md", customMessage: "it points at the runbook"); + } + // ---- payloads it cannot read are allowed ---------------------------------------------------------- [Theory] diff --git a/tools/BashGuard/Program.cs b/tools/BashGuard/Program.cs index 78f5f3ad..477d0e0f 100644 --- a/tools/BashGuard/Program.cs +++ b/tools/BashGuard/Program.cs @@ -13,7 +13,11 @@ // reads a project: -getProperty or -getItem with no target, -getTargetResult or restore. MSBuild then // stops after evaluation and writes nothing (measured 2026-10-17), and the verdict has no such form; // - the verdict tool piped into head, tail, less or more: the verdict is already the short form, and -// the pipe hides the tool's exit code. +// the pipe hides the tool's exit code; +// - `wt merge`, and `wt step squash`, `rebase` and `push`, its building blocks, past worktrunk's global +// options (-C, --config, --config-set, -v), except with --help: work reaches a release branch by a pull +// request and a merge commit (decision 94), and these squash, rebase or fast-forward locally. Called +// through a wrapper (`cargo run --bin wt`, a variable) they pass. // // The command is split the way the shell would: on ; && || | & and newlines, into $( ), backticks and // the string of `bash -c`, past leading assignments, `env`, `time`, `command`, `exec`, `nice` and the @@ -270,6 +274,8 @@ static class Guard static readonly HashSet Shells = ["bash", "sh", "zsh", "dash"]; static readonly HashSet RefusedVerbs = ["build", "test", "msbuild"]; static readonly HashSet Pagers = ["head", "tail", "less", "more"]; + static readonly HashSet WtOptionsWithValue = ["-C", "--config", "--config-set"]; + static readonly HashSet LandingSteps = ["squash", "rebase", "push"]; const string VerdictTool = "tools/BuildVerdict"; const string Verdict = "dotnet tools/BuildVerdict/bin/Release/net10.0/build-verdict.dll"; @@ -363,6 +369,28 @@ static bool IsQuery(List words) => words.Skip(2).Select(Switch).Any(s => s is not null && Queries.Contains(s)) && !words.Skip(2).Select(Switch).Any(s => s is not null && Runs.Contains(s)); + // The words of `wt ...` that are no option nor an option's value: `wt -C x step -v push` gives [step, push]. + static List WtVerbs(List words) + { + List verbs = []; + for (var k = 1; k < words.Count; k++) + { + if (WtOptionsWithValue.Contains(words[k])) k++; + else if (!words[k].StartsWith('-')) verbs.Add(words[k]); + } + return verbs; + } + + // `wt merge` or `wt step squash|rebase|push`, as "merge" or "step push"; null for any other command. + static string? Landing(List words) + { + if (Program(words[0]) != "wt" || words.Skip(1).Any(a => a is "--help" or "-h")) return null; + var verbs = WtVerbs(words); + if (verbs is ["merge", ..]) return "merge"; + if (verbs is ["step", var step, ..] && LandingSteps.Contains(step)) return $"step {step}"; + return null; + } + // Whether any command in the text builds a tool project: what a stale guard still lets through. public static bool BuildsATool(string text) => new Scanner(text).Scan().Segments.Any(s => IsToolBuild(CommandWords(s.Words))); @@ -437,6 +465,7 @@ public static bool BuildsATool(string text) => if (!words.Skip(2).Any(a => a is "--help" or "-h" or "-?" or "/?")) yield return ("toolchain", (words[1], words.Skip(2).ToList())); } + if (Landing(words) is { } landing) yield return ("landing", landing); if (IsVerdict(words) && op is ("|" or "|&") && n + 1 < segments.Count) { var next = segments[n + 1].Words; @@ -468,6 +497,13 @@ public static string Message(string rule, object detail) $"An MSBuild switch belongs on `build -- ...`, never on `test -- ...`. A person in a " + $"terminal is not affected by this hook."; } + if (rule == "landing") + return + $"Refused: `wt {detail}`. In this repository work reaches release/ by a pull request and a " + + $"merge commit (decision 94), never squashed, rebased once pushed, or fast-forwarded locally, which is " + + $"what `wt merge` and its steps do by default, onto worktrunk's default branch. Push the branch " + + $"(`git push -u origin `) and open the pull request; once it merged, `wt remove ` cleans " + + $"up. design/worktrees.md has the whole flow. A person in a terminal is not affected by this hook."; return $"Refused: the verdict tool ({VerdictTool}) piped into {detail}. The verdict is already the short " + $"form, and the pipe replaces the tool's exit code with {detail}'s. Run it bare; for " +