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 708bb2f6..4eda9319 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` @@ -215,7 +215,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 c59b9202..48dc8ad1 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 @@ -222,7 +223,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 b08389b3..d5f04e3f 100644 --- a/design/adr/README.md +++ b/design/adr/README.md @@ -132,3 +132,4 @@ per area ([design](../plan.md)) link to the records they rest on. | [0123](0123-a-hand-written-operator-is-refused-in-a-query.md) | An arithmetic, bitwise or shift operator a value object declares by hand is refused in an EF Core query, naming the value object and `.Value`, by an interceptor and, for query filters, when the model is built; the generated ones carry `[TranslatedAsWrappedArithmetic]`, which is what tells them apart; a conversion operator is left to EF; refines decision 122 | 2026-10-30 | accepted | | [0124](0124-an-operator-in-a-query-is-the-wrapped-type-s-arithmetic.md) | In an EF Core query, every generated arithmetic operator is rewritten to the wrapped type's operator over `ProviderValue`, which translates only through TypeKit's converter, its value-object result `Materialize`d and re-typed, never cast; `byte`, `short` and `char` operators are refused; refines decision 122, on decision 123's mark | 2026-10-30 | accepted | | [0125](0125-a-package-documents-the-third-party-code-it-contains.md) | A package carries a `THIRD-PARTY-NOTICES.txt` only when it contains third-party code (a bundled assembly, copied source, a binary release), built from the shipping closure with the license texts; a package that bundles nothing documents its own license, copyright and dependency list, and its dependencies' licenses are held to an allow-list by a conventions test, not listed (#181); no SBOM is shipped, since GitHub's export is an inventory without license data | 2026-10-30 | open | +| [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 " +