vouchington-tooling 0.52.1 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -230,6 +230,33 @@ Host-lock environment:
230
230
  `git_worktree_canonical_path_hash <path>` helper resolves the physical path and prints a stable
231
231
  `d` plus the first 12 lowercase hexadecimal characters of its SHA-256 digest.
232
232
 
233
+ ## Reclaiming finished worktrees
234
+
235
+ `vouchington worktree reclaim [--yes] [--json]` removes git worktrees whose work is done. It is a
236
+ dry run unless `--yes` is passed. It prints one line per worktree (`reclaimed`, `would reclaim`, or
237
+ `kept: <reason>`, control characters escaped), or `{ dryRun, worktrees }` with `--json`.
238
+
239
+ It skips the main checkout, the caller's own worktree, and locked, bare, detached and missing
240
+ worktrees. Any other worktree is kept, with a reason, unless all of these hold:
241
+
242
+ - `git status` (untracked files and submodules included) is empty, and no index entry is
243
+ assume-unchanged or skip-worktree.
244
+ - `gh pr list --head <branch>` has a merged or closed pull request from this repository (not a
245
+ fork) whose `headRefOid` equals HEAD, and no open pull request from this repository. That PR also
246
+ proves HEAD is published, so squash merges whose branch was deleted are reclaimed. A `gh` failure,
247
+ or a query returning 1000 results, keeps the worktree.
248
+
249
+ With `--yes`, HEAD and cleanliness are checked again right before `git worktree remove <path>` runs.
250
+ It is never forced, and a refusal from git is reported as kept. Ignored files (for example `.env`, `node_modules`, build output) are deleted with the worktree, exactly as `git worktree remove` does; keep anything you need outside ignored paths or commit it. An unexpected error on one worktree is reported as `kept: error: <message>` and the run carries on. There is no `prune`, no branch
251
+ deletion, and git runs with `core.fsmonitor=false`.
252
+
253
+ Known limitations: git cannot check and remove a worktree atomically, so a worktree that changes in the moment between the final check and `git worktree remove` can still be removed; the final check runs immediately before removal to keep that window small.
254
+
255
+ This is NOT a security boundary. Run it as a trusted user or agent, and do not grant it as an
256
+ unconditional allow rule. It has no teardown hook: per-worktree resources such as databases and
257
+ containers are reclaimed afterwards by the consumer's own orphan cleanup, because the worktree is
258
+ gone.
259
+
233
260
  ## Library
234
261
 
235
262
  ```ts
@@ -0,0 +1,2 @@
1
+ /** `vouchington worktree reclaim [--yes] [--json]`: exits 2 on bad usage, 1 when git cannot list. */
2
+ export declare function runWorktreeCommand(args: readonly string[], env?: NodeJS.ProcessEnv, cwd?: string): number;
@@ -0,0 +1,21 @@
1
+ import { formatReport, reclaimWorktrees } from "../../git-worktrees/reclaim.mjs";
2
+ import { commandUsage } from "../usage.mjs";
3
+ /** `vouchington worktree reclaim [--yes] [--json]`: exits 2 on bad usage, 1 when git cannot list. */
4
+ export function runWorktreeCommand(args, env = process.env, cwd = process.cwd()) {
5
+ const [subcommand, ...flags] = args;
6
+ const unknown = flags.find((flag) => flag !== '--yes' && flag !== '--json');
7
+ if (subcommand !== 'reclaim' || unknown !== undefined) {
8
+ process.stderr.write(`vouchington: expected: reclaim [--yes] [--json]\n${commandUsage('worktree')}`);
9
+ return 2;
10
+ }
11
+ try {
12
+ const report = reclaimWorktrees(flags.includes('--yes'), { cwd, env });
13
+ const json = flags.includes('--json');
14
+ process.stdout.write(json ? `${JSON.stringify(report)}\n` : formatReport(report));
15
+ return 0;
16
+ }
17
+ catch (error) {
18
+ process.stderr.write(`vouchington: ${error.message}\n`);
19
+ return 1;
20
+ }
21
+ }
@@ -20,6 +20,7 @@ import { runLinkSkill } from "./commands/link-skill.mjs";
20
20
  import { runRetrospectiveFactsCommand } from "./commands/retrospective-facts.mjs";
21
21
  import { runAgentBlackboardCommand } from "./commands/agent-blackboard.mjs";
22
22
  import { runMcpCommand } from "./commands/mcp.mjs";
23
+ import { runWorktreeCommand } from "./commands/worktree.mjs";
23
24
  import { runWithHostLock } from "./commands/with-host-lock.mjs";
24
25
  import { runFreshBase } from "./commands/fresh-base.mjs";
25
26
  import { runRequireUpToDate } from "./commands/require-up-to-date.mjs";
@@ -134,6 +135,8 @@ export function runCli(argv = process.argv) {
134
135
  return runAgentBlackboardCommand(parsed.args);
135
136
  case 'mcp':
136
137
  return runMcpCommand(parsed.args);
138
+ case 'worktree':
139
+ return runWorktreeCommand(parsed.args);
137
140
  case 'require-up-to-date':
138
141
  return runRequireUpToDate(parsed);
139
142
  case 'fresh-base':
@@ -37,6 +37,9 @@ type ForwardedCommand = {
37
37
  } | {
38
38
  kind: 'mcp';
39
39
  args: string[];
40
+ } | {
41
+ kind: 'worktree';
42
+ args: string[];
40
43
  };
41
44
  export declare function parseForwardedCommand(command: string | undefined, args: string[]): ForwardedCommand | undefined;
42
45
  export {};
@@ -25,5 +25,7 @@ export function parseForwardedCommand(command, args) {
25
25
  return { kind: 'agent-blackboard', args };
26
26
  if (command === 'mcp')
27
27
  return { kind: 'mcp', args };
28
+ if (command === 'worktree')
29
+ return { kind: 'worktree', args };
28
30
  return undefined;
29
31
  }
@@ -66,6 +66,9 @@ export type ParsedCli = {
66
66
  } | {
67
67
  kind: 'mcp';
68
68
  args: string[];
69
+ } | {
70
+ kind: 'worktree';
71
+ args: string[];
69
72
  } | {
70
73
  kind: 'require-up-to-date';
71
74
  remote: string;
@@ -1,4 +1,4 @@
1
- export declare const USAGE = "Usage: vouchington <command> [options]\n\nCommands:\n runner-port-policy Print or validate a runner port policy\n with-host-lock Run a command under a host-wide lock\n gha-runtime-audit Audit successful GitHub Actions job runtimes\n require-up-to-date Require HEAD to include a fetched remote branch\n fresh-base Classify the worktree against a remote base branch\n gitleaks-directory-scan Scan a directory with Gitleaks\n ast-grep-examples Run AST-grep rule examples\n ast-grep-pack Print shipped unconditional AST-grep pack paths as JSON\n gha-workspace-policy Check GitHub Actions workspace safety policy\n gha-output Write a collision-safe multiline GITHUB_OUTPUT record\n gha-needs-results Fail if required GitHub Actions job results failed\n download-with-diagnostics Download a URL and report HTTP status on failure\n download-optional-run-artifacts Download optional artifacts from the current run\n host-pressure-diagnostics Print a bounded host memory/OOM/PSI snapshot\n allocate-browser-safe-ports Allocate Fetch-safe localhost ports\n diagnose-port-collision Capture bounded localhost port diagnostics\n prepare-trivy-db Download the Trivy vulnerability database\n gha-artifacts-cleanup Delete classified GitHub Actions artifacts\n http-origin Validate an optional HTTP(S) origin\n vitest-blob-manifest Stamp a vitest-blob-manifest:v1 identity file\n vitest-report-attempt Write or read a Vitest report-attempt marker\n prepare-vitest-reports Validate and select Vitest report JSON files\n pnpm-install Install a pnpm workspace with retry and release-age fail-fast\n check-cache-size Measure a path and decide whether to save a GHA cache\n make-shard-matrix Emit a [1..N] GitHub Actions shard matrix\n load-runner-env Overlay a runner env file onto GITHUB_ENV with injection guards\n clean-workspace Reset a persistent-runner workspace with a fork-PR trust gate\n install-github-release Download a checksum-verified GitHub Release binary\n run-with-timeout Run a command with GNU timeout or a Perl fallback\n lint-links Two-pass lychee: internal links fail, external warn\n materialize-pr-context Dump PR title/body/files/diff/comments and #N crawl\n wait-for-apt-locks Wait until apt/dpkg lock files are free\n install-playwright-chromium-arm64 Install Playwright Chromium from browsers.json\n ghcr-package-retention Delete old GHCR package versions (KEEP_MIN or --main-history)\n harness-admission-lane Compute a GITHUB_RUN_ID admission lane for fleet fan-out\n harness-assert-gates Fail if any named HARNESS_*_ENABLED gate is enabled\n gha-collaborator-trust Classify authors as write-access collaborators or not\n nuget-central-version Validate a Directory.Packages.props PackageVersion delta\n swift-semantic-equal Compare Swift sources ignoring comments and whitespace\n post-review Post one COMMENT review from a staged payload file\n stage-review-payload Validate a review payload file into a staging directory\n retrospective-transcript Format facts from Claude-compatible, Codex, or Grok transcripts\n link-skill Link one packaged skill into an explicit consumer directory\n retrospective-facts Gather immutable facts for a retrospective\n agent-blackboard Probe and journal an Agent Blackboard deployment\n mcp Serve the agent-blackboard journal tools over stdio (MCP)\n\nOptions:\n -h, --help Show this help\n -v, --version Print the package version\n\nrunner-port-policy\n (no args) Print the shipped policy as JSON\n --file <path> Validate and print a policy file\n --reserved <port> Print true if the port is reserved\n\nwith-host-lock\n --name <family>\n [--slots <n>]\n --timeout-seconds <n>\n [--command-timeout-seconds <n>]\n [--failure-diagnostics <absolute-script>]\n [--on-acquire-timeout fail|run-unlocked]\n -- <command> [args...]\n\ngha-runtime-audit\n [--repository owner/name] Default GITHUB_REPOSITORY\n [--branch main]\n [--median-threshold-floor <seconds>] Off unless set. Must be below the ceiling\n [--median-threshold-ceiling <seconds>] Default 360. Flags a five-sample median above this\n --pr-workflow <name|/regex/> Repeatable\n --push-workflow <name|/regex/> Repeatable\n\nrequire-up-to-date --remote <name> --branch <name>\nfresh-base [--remote <name>] [--branch <name>] [--skip-fetch]\ngitleaks-directory-scan --config <path> [--directory <path>]\nast-grep-examples --rules <directory> --config <path>\nast-grep-pack\ngha-workspace-policy [--root <directory>] [--workflow-directory <directory>] [--action-directory <directory>]\n\ngha-output <name>\ngha-needs-results [label]\ndownload-with-diagnostics <url> <destination> [-- curl-args...]\ndownload-optional-run-artifacts (--name <name>... | --pattern <pattern>) --dir <directory>\nhost-pressure-diagnostics\nallocate-browser-safe-ports <count> [--policy path] [--forbidden-ports path]\ndiagnose-port-collision [--ports \"2200 2216\"] [--output-dir PATH]\nprepare-trivy-db\ngha-artifacts-cleanup run --run-id <id> [--keep-pattern glob] [--delete-pattern glob] [--patterns-file json]\ngha-artifacts-cleanup sweep --older-than-hours <n> [--keep-pattern glob] [--delete-pattern glob] [--patterns-file json]\nhttp-origin [--field NAME] [value]\nvitest-blob-manifest <suite> [reports-directory]\nvitest-report-attempt <write DIRECTORY SUITE|read ROOT>\nprepare-vitest-reports [primary-directory] [fallback-directory] [output-directory]\npnpm-install --runner-lifecycle persistent|ephemeral|ephemeral-full --install-scripts true|false\ncheck-cache-size <path> <max-bytes> <label>\nmake-shard-matrix <total>\nload-runner-env\nclean-workspace\ninstall-github-release --repo owner/name --version X --asset 'name-{platform}.tar.gz' --bin name [--tag-prefix PREFIX] [--expected-sha256 SHA256] [--no-checksum] [--checksums-asset NAME] [--version-flag FLAG] [--bin-dir DIR]\nrun-with-timeout <timeout-seconds> <kill-after-seconds> <command...>\nlint-links [--offline] [--config PATH] [--glob PATTERN] [files...]\nmaterialize-pr-context\nwait-for-apt-locks\ninstall-playwright-chromium-arm64 [name:archive...]\nghcr-package-retention [--main-history] [--apply|--dry-run] [<url-encoded-package>...]\nharness-admission-lane <lanes>\nharness-assert-gates <gate>...\ngha-collaborator-trust <owner/repo> [--allow-bot <login>]... < authors.json\nnuget-central-version <trusted-props> <candidate-props> <metadata-json> <output-props>\nswift-semantic-equal <base> <head> <file.swift>\npost-review\nstage-review-payload optional|required <source> <destination>\nretrospective-transcript [--session-id ID] [--jsonl PATH] [--projects-dir PATH] [--codex-sessions-dir PATH] [--grok-sessions-dir PATH]\nlink-skill <name> --source-root <skills-dir> --target-root <consumer-skills-dir> Link a packaged or repository-local skill\nretrospective-facts (--pr NUMBER | --branch NAME | --no-pr) [--repo OWNER/NAME] [--raw]\nagent-blackboard probe\nagent-blackboard session-start [--skip-env NAME] [--setup-hint TEXT] [--docs-path PATH] [--restore-hint TEXT] [--timeout-ms N] (SessionStart hook; payload on stdin)\nagent-blackboard journal append --mode interactive|autonomous --source-event-id ID --work-outcome OUTCOME --coverage-status STATUS --session-id UUID --agent NAME --version VERSION --file PATH --repository OWNER/NAME [--repository OWNER/NAME ...] [--parent-session-id UUID] [--timestamp ISO8601] [--coverage-source SOURCE,...] [--dropped-count N] [--outbox-directory PATH]\nagent-blackboard journal entries --session-id UUID\nagent-blackboard journal status --session-id UUID --outbox-directory PATH\nagent-blackboard journal flush --outbox-directory PATH [--session-id UUID]\nInteractive journal append requires --outbox-directory PATH.\nagent-blackboard journal is deprecated and being phased out: agents journal through the mcp server.\nagent-blackboard snapshot partition --snapshot PATH --checksum SHA256 --counts '{\"sessions\":N,\"entries\":N,\"records\":N,\"bytes\":N}'\nagent-blackboard snapshot cleanup [--snapshot PATH] [--partition-directory PATH --receipt JSON]\nmcp\n (no args) Serve journal_append, journal_entries, outbox_status, outbox_flush,\n session_ensure, retrospective_check, retrospective_save,\n pr_validate, pr_create, pr_update,\n snapshot_export, snapshot_cleanup, and session_archive over stdio.\n Needs the optional @modelcontextprotocol/sdk peer (and zod).\n Launch it from inside the repository's git worktree.\n";
1
+ export declare const USAGE = "Usage: vouchington <command> [options]\n\nCommands:\n runner-port-policy Print or validate a runner port policy\n with-host-lock Run a command under a host-wide lock\n gha-runtime-audit Audit successful GitHub Actions job runtimes\n require-up-to-date Require HEAD to include a fetched remote branch\n fresh-base Classify the worktree against a remote base branch\n gitleaks-directory-scan Scan a directory with Gitleaks\n ast-grep-examples Run AST-grep rule examples\n ast-grep-pack Print shipped unconditional AST-grep pack paths as JSON\n gha-workspace-policy Check GitHub Actions workspace safety policy\n gha-output Write a collision-safe multiline GITHUB_OUTPUT record\n gha-needs-results Fail if required GitHub Actions job results failed\n download-with-diagnostics Download a URL and report HTTP status on failure\n download-optional-run-artifacts Download optional artifacts from the current run\n host-pressure-diagnostics Print a bounded host memory/OOM/PSI snapshot\n allocate-browser-safe-ports Allocate Fetch-safe localhost ports\n diagnose-port-collision Capture bounded localhost port diagnostics\n prepare-trivy-db Download the Trivy vulnerability database\n gha-artifacts-cleanup Delete classified GitHub Actions artifacts\n http-origin Validate an optional HTTP(S) origin\n vitest-blob-manifest Stamp a vitest-blob-manifest:v1 identity file\n vitest-report-attempt Write or read a Vitest report-attempt marker\n prepare-vitest-reports Validate and select Vitest report JSON files\n pnpm-install Install a pnpm workspace with retry and release-age fail-fast\n check-cache-size Measure a path and decide whether to save a GHA cache\n make-shard-matrix Emit a [1..N] GitHub Actions shard matrix\n load-runner-env Overlay a runner env file onto GITHUB_ENV with injection guards\n clean-workspace Reset a persistent-runner workspace with a fork-PR trust gate\n install-github-release Download a checksum-verified GitHub Release binary\n run-with-timeout Run a command with GNU timeout or a Perl fallback\n lint-links Two-pass lychee: internal links fail, external warn\n materialize-pr-context Dump PR title/body/files/diff/comments and #N crawl\n wait-for-apt-locks Wait until apt/dpkg lock files are free\n install-playwright-chromium-arm64 Install Playwright Chromium from browsers.json\n ghcr-package-retention Delete old GHCR package versions (KEEP_MIN or --main-history)\n harness-admission-lane Compute a GITHUB_RUN_ID admission lane for fleet fan-out\n harness-assert-gates Fail if any named HARNESS_*_ENABLED gate is enabled\n gha-collaborator-trust Classify authors as write-access collaborators or not\n nuget-central-version Validate a Directory.Packages.props PackageVersion delta\n swift-semantic-equal Compare Swift sources ignoring comments and whitespace\n post-review Post one COMMENT review from a staged payload file\n stage-review-payload Validate a review payload file into a staging directory\n retrospective-transcript Format facts from Claude-compatible, Codex, or Grok transcripts\n link-skill Link one packaged skill into an explicit consumer directory\n retrospective-facts Gather immutable facts for a retrospective\n agent-blackboard Probe and journal an Agent Blackboard deployment\n mcp Serve the agent-blackboard journal tools over stdio (MCP)\n worktree Reclaim finished git worktrees (dry run unless --yes)\n\nOptions:\n -h, --help Show this help\n -v, --version Print the package version\n\nrunner-port-policy\n (no args) Print the shipped policy as JSON\n --file <path> Validate and print a policy file\n --reserved <port> Print true if the port is reserved\n\nwith-host-lock\n --name <family>\n [--slots <n>]\n --timeout-seconds <n>\n [--command-timeout-seconds <n>]\n [--failure-diagnostics <absolute-script>]\n [--on-acquire-timeout fail|run-unlocked]\n -- <command> [args...]\n\ngha-runtime-audit\n [--repository owner/name] Default GITHUB_REPOSITORY\n [--branch main]\n [--median-threshold-floor <seconds>] Off unless set. Must be below the ceiling\n [--median-threshold-ceiling <seconds>] Default 360. Flags a five-sample median above this\n --pr-workflow <name|/regex/> Repeatable\n --push-workflow <name|/regex/> Repeatable\n\nrequire-up-to-date --remote <name> --branch <name>\nfresh-base [--remote <name>] [--branch <name>] [--skip-fetch]\ngitleaks-directory-scan --config <path> [--directory <path>]\nast-grep-examples --rules <directory> --config <path>\nast-grep-pack\ngha-workspace-policy [--root <directory>] [--workflow-directory <directory>] [--action-directory <directory>]\n\ngha-output <name>\ngha-needs-results [label]\ndownload-with-diagnostics <url> <destination> [-- curl-args...]\ndownload-optional-run-artifacts (--name <name>... | --pattern <pattern>) --dir <directory>\nhost-pressure-diagnostics\nallocate-browser-safe-ports <count> [--policy path] [--forbidden-ports path]\ndiagnose-port-collision [--ports \"2200 2216\"] [--output-dir PATH]\nprepare-trivy-db\ngha-artifacts-cleanup run --run-id <id> [--keep-pattern glob] [--delete-pattern glob] [--patterns-file json]\ngha-artifacts-cleanup sweep --older-than-hours <n> [--keep-pattern glob] [--delete-pattern glob] [--patterns-file json]\nhttp-origin [--field NAME] [value]\nvitest-blob-manifest <suite> [reports-directory]\nvitest-report-attempt <write DIRECTORY SUITE|read ROOT>\nprepare-vitest-reports [primary-directory] [fallback-directory] [output-directory]\npnpm-install --runner-lifecycle persistent|ephemeral|ephemeral-full --install-scripts true|false\ncheck-cache-size <path> <max-bytes> <label>\nmake-shard-matrix <total>\nload-runner-env\nclean-workspace\ninstall-github-release --repo owner/name --version X --asset 'name-{platform}.tar.gz' --bin name [--tag-prefix PREFIX] [--expected-sha256 SHA256] [--no-checksum] [--checksums-asset NAME] [--version-flag FLAG] [--bin-dir DIR]\nrun-with-timeout <timeout-seconds> <kill-after-seconds> <command...>\nlint-links [--offline] [--config PATH] [--glob PATTERN] [files...]\nmaterialize-pr-context\nwait-for-apt-locks\ninstall-playwright-chromium-arm64 [name:archive...]\nghcr-package-retention [--main-history] [--apply|--dry-run] [<url-encoded-package>...]\nharness-admission-lane <lanes>\nharness-assert-gates <gate>...\ngha-collaborator-trust <owner/repo> [--allow-bot <login>]... < authors.json\nnuget-central-version <trusted-props> <candidate-props> <metadata-json> <output-props>\nswift-semantic-equal <base> <head> <file.swift>\npost-review\nstage-review-payload optional|required <source> <destination>\nretrospective-transcript [--session-id ID] [--jsonl PATH] [--projects-dir PATH] [--codex-sessions-dir PATH] [--grok-sessions-dir PATH]\nlink-skill <name> --source-root <skills-dir> --target-root <consumer-skills-dir> Link a packaged or repository-local skill\nretrospective-facts (--pr NUMBER | --branch NAME | --no-pr) [--repo OWNER/NAME] [--raw]\nagent-blackboard probe\nagent-blackboard session-start [--skip-env NAME] [--setup-hint TEXT] [--docs-path PATH] [--restore-hint TEXT] [--timeout-ms N] (SessionStart hook; payload on stdin)\nagent-blackboard journal append --mode interactive|autonomous --source-event-id ID --work-outcome OUTCOME --coverage-status STATUS --session-id UUID --agent NAME --version VERSION --file PATH --repository OWNER/NAME [--repository OWNER/NAME ...] [--parent-session-id UUID] [--timestamp ISO8601] [--coverage-source SOURCE,...] [--dropped-count N] [--outbox-directory PATH]\nagent-blackboard journal entries --session-id UUID\nagent-blackboard journal status --session-id UUID --outbox-directory PATH\nagent-blackboard journal flush --outbox-directory PATH [--session-id UUID]\nInteractive journal append requires --outbox-directory PATH.\nagent-blackboard journal is deprecated and being phased out: agents journal through the mcp server.\nagent-blackboard snapshot partition --snapshot PATH --checksum SHA256 --counts '{\"sessions\":N,\"entries\":N,\"records\":N,\"bytes\":N}'\nagent-blackboard snapshot cleanup [--snapshot PATH] [--partition-directory PATH --receipt JSON]\nmcp\n (no args) Serve journal_append, journal_entries, outbox_status, outbox_flush,\n session_ensure, retrospective_check, retrospective_save,\n pr_validate, pr_create, pr_update,\n snapshot_export, snapshot_cleanup, and session_archive over stdio.\n Needs the optional @modelcontextprotocol/sdk peer (and zod).\n Launch it from inside the repository's git worktree.\n\nworktree reclaim [--yes] [--json]\n Dry run by default. Removes clean, fully pushed worktrees whose same-repository PR for the\n branch is merged or closed at exactly HEAD, using git worktree remove (never forced).\n Skips the main checkout, the current worktree, and locked, bare or detached worktrees.\n";
2
2
  export declare function printUsage(stream?: NodeJS.WritableStream): void;
3
3
  export declare function commandNames(usage?: string): string[];
4
4
  export declare function commandUsage(command: string, usage?: string): string;
@@ -47,6 +47,7 @@ Commands:
47
47
  retrospective-facts Gather immutable facts for a retrospective
48
48
  agent-blackboard Probe and journal an Agent Blackboard deployment
49
49
  mcp Serve the agent-blackboard journal tools over stdio (MCP)
50
+ worktree Reclaim finished git worktrees (dry run unless --yes)
50
51
 
51
52
  Options:
52
53
  -h, --help Show this help
@@ -134,6 +135,11 @@ mcp
134
135
  snapshot_export, snapshot_cleanup, and session_archive over stdio.
135
136
  Needs the optional @modelcontextprotocol/sdk peer (and zod).
136
137
  Launch it from inside the repository's git worktree.
138
+
139
+ worktree reclaim [--yes] [--json]
140
+ Dry run by default. Removes clean, fully pushed worktrees whose same-repository PR for the
141
+ branch is merged or closed at exactly HEAD, using git worktree remove (never forced).
142
+ Skips the main checkout, the current worktree, and locked, bare or detached worktrees.
137
143
  `;
138
144
  export function printUsage(stream = process.stdout) {
139
145
  stream.write(USAGE);
@@ -0,0 +1,14 @@
1
+ import { type RunOptions } from './run.mts';
2
+ export type Assessment = {
3
+ readonly head: string;
4
+ } | {
5
+ readonly reason: string;
6
+ };
7
+ export declare function readRepository(main: RunOptions): string | undefined;
8
+ /** Clean status and no hidden index entries. Returns the HEAD commit or a reason. */
9
+ export declare function assessLocal(worktree: RunOptions): Assessment;
10
+ /**
11
+ * Needs a merged or closed same-repository PR for this branch whose head is exactly HEAD. That
12
+ * also proves HEAD is published on GitHub, so deleted branches (squash merges) need no remote ref.
13
+ */
14
+ export declare function assessPullRequest(branch: string, head: string, repository: string | undefined, main: RunOptions): string | undefined;
@@ -0,0 +1,68 @@
1
+ import { git, LOCAL_ENV, run, withoutVariables } from "./run.mjs";
2
+ const FINISHED = new Set(['MERGED', 'CLOSED']);
3
+ function parseJson(text) {
4
+ try {
5
+ return text === undefined ? undefined : JSON.parse(text);
6
+ }
7
+ catch {
8
+ return undefined;
9
+ }
10
+ }
11
+ /** Repo-local git variables, GH_REPO and GH_HOST must not redirect the lookup away from the main checkout. */
12
+ function runGh(args, main) {
13
+ const env = withoutVariables(main.env, [...LOCAL_ENV, 'GH_REPO', 'GH_HOST']);
14
+ return run('gh', args, { cwd: main.cwd, env });
15
+ }
16
+ export function readRepository(main) {
17
+ const repo = parseJson(runGh(['repo', 'view', '--json', 'nameWithOwner'], main));
18
+ return repo?.nameWithOwner?.toLowerCase();
19
+ }
20
+ /** Clean status and no hidden index entries. Returns the HEAD commit or a reason. */
21
+ export function assessLocal(worktree) {
22
+ const status = git(['status', '--porcelain', '--untracked-files=all', '--ignore-submodules=none'], worktree);
23
+ if (status === undefined)
24
+ return { reason: 'cannot read status' };
25
+ if (status !== '')
26
+ return { reason: 'uncommitted changes' };
27
+ const files = git(['ls-files', '-v', '-z'], worktree);
28
+ if (files === undefined)
29
+ return { reason: 'cannot read index' };
30
+ if (files.split('\0').some((entry) => /^[a-zS]/.test(entry))) {
31
+ return { reason: 'hidden index entries (assume-unchanged or skip-worktree)' };
32
+ }
33
+ const headOutput = git(['rev-parse', 'HEAD'], worktree);
34
+ if (headOutput === undefined)
35
+ return { reason: 'cannot read HEAD' };
36
+ const head = headOutput.trim();
37
+ return { head };
38
+ }
39
+ const PAGE = 1000;
40
+ function listPulls(branch, state, main) {
41
+ const fields = 'state,headRefOid,isCrossRepository,headRepository,headRepositoryOwner';
42
+ const args = ['pr', 'list', '--head', branch, '--state', state, '--limit', String(PAGE)];
43
+ const listed = parseJson(runGh([...args, '--json', fields], main));
44
+ return Array.isArray(listed) && listed.length < PAGE ? listed : undefined;
45
+ }
46
+ /**
47
+ * Needs a merged or closed same-repository PR for this branch whose head is exactly HEAD. That
48
+ * also proves HEAD is published on GitHub, so deleted branches (squash merges) need no remote ref.
49
+ */
50
+ export function assessPullRequest(branch, head, repository, main) {
51
+ if (repository === undefined)
52
+ return 'cannot read repository';
53
+ // Open PRs are queried on their own so a long history cannot hide one; a full page is unreadable.
54
+ const open = listPulls(branch, 'open', main);
55
+ const all = listPulls(branch, 'all', main);
56
+ if (open === undefined || all === undefined)
57
+ return 'cannot read PR state';
58
+ const own = [...open, ...all].filter((pr) => pr.isCrossRepository === false &&
59
+ `${pr.headRepositoryOwner?.login}/${pr.headRepository?.name}`.toLowerCase() === repository);
60
+ if (own.some((pr) => pr.state === 'OPEN'))
61
+ return 'open PR';
62
+ const finished = own.filter((pr) => FINISHED.has(pr.state));
63
+ if (finished.length === 0)
64
+ return 'no finished PR from this repository';
65
+ if (!finished.some((pr) => pr.headRefOid === head))
66
+ return 'PR head differs from HEAD';
67
+ return undefined;
68
+ }
@@ -0,0 +1,10 @@
1
+ export interface WorktreeRecord {
2
+ readonly path: string;
3
+ readonly branch?: string;
4
+ readonly head?: string;
5
+ readonly locked: boolean;
6
+ readonly bare: boolean;
7
+ readonly detached: boolean;
8
+ }
9
+ /** Parses `git worktree list --porcelain -z` (NUL fields, an empty field ends each record). */
10
+ export declare function parseWorktreeList(porcelain: string | undefined): WorktreeRecord[];
@@ -0,0 +1,34 @@
1
+ const BRANCH_PREFIX = 'branch refs/heads/';
2
+ function parseRecord(fields) {
3
+ const path = fields.find((field) => field.startsWith('worktree '))?.slice('worktree '.length);
4
+ if (path === undefined)
5
+ return undefined;
6
+ const branch = fields
7
+ .find((field) => field.startsWith(BRANCH_PREFIX))
8
+ ?.slice(BRANCH_PREFIX.length);
9
+ const head = fields.find((field) => field.startsWith('HEAD '))?.slice('HEAD '.length);
10
+ return {
11
+ path,
12
+ locked: fields.some((field) => field === 'locked' || field.startsWith('locked ')),
13
+ bare: fields.includes('bare'),
14
+ detached: fields.includes('detached'),
15
+ ...(branch === undefined ? {} : { branch }),
16
+ ...(head === undefined ? {} : { head }),
17
+ };
18
+ }
19
+ /** Parses `git worktree list --porcelain -z` (NUL fields, an empty field ends each record). */
20
+ export function parseWorktreeList(porcelain) {
21
+ const records = [];
22
+ let fields = [];
23
+ for (const field of [...(porcelain ?? '').split('\0'), '']) {
24
+ if (field !== '') {
25
+ fields.push(field);
26
+ continue;
27
+ }
28
+ const record = parseRecord(fields);
29
+ if (record !== undefined)
30
+ records.push(record);
31
+ fields = [];
32
+ }
33
+ return records;
34
+ }
@@ -0,0 +1,15 @@
1
+ import { type RunOptions } from './run.mts';
2
+ interface ReclaimEntry {
3
+ readonly path: string;
4
+ readonly branch: string;
5
+ readonly status: 'reclaimed' | 'would reclaim' | 'kept';
6
+ readonly reason?: string;
7
+ }
8
+ export interface ReclaimReport {
9
+ readonly dryRun: boolean;
10
+ readonly worktrees: ReclaimEntry[];
11
+ }
12
+ /** Lists finished worktrees and, with `yes`, removes them with a plain (never forced) `git worktree remove`. */
13
+ export declare function reclaimWorktrees(yes: boolean, runtime: RunOptions): ReclaimReport;
14
+ export declare function formatReport(report: ReclaimReport): string;
15
+ export {};
@@ -0,0 +1,80 @@
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import { sep } from 'node:path';
3
+ import { assessLocal, assessPullRequest, readRepository } from "./assess.mjs";
4
+ import { parseWorktreeList } from "./list.mjs";
5
+ import { git } from "./run.mjs";
6
+ /** Assesses (and with `yes` removes) one worktree; undefined means it is not a candidate. */
7
+ function reclaimOne(record, context) {
8
+ const { path, branch, locked, bare, detached } = record;
9
+ if (locked || bare || detached || branch === undefined)
10
+ return undefined;
11
+ if (!existsSync(path) || realpathSync(path) === context.selfReal)
12
+ return undefined;
13
+ const keep = (reason) => ({ path, branch, status: 'kept', reason });
14
+ const nests = (paths) => paths.some((other) => other.startsWith(path + sep));
15
+ if (nests(context.registered)) {
16
+ return keep('contains another worktree');
17
+ }
18
+ const worktree = { cwd: path, env: context.runtime.env };
19
+ const local = assessLocal(worktree);
20
+ if ('reason' in local)
21
+ return keep(local.reason);
22
+ const reason = assessPullRequest(branch, local.head, context.repository, context.mainRun);
23
+ if (reason !== undefined)
24
+ return keep(reason);
25
+ if (!context.yes)
26
+ return { path, branch, status: 'would reclaim' };
27
+ const again = assessLocal(worktree);
28
+ const pullAgain = assessPullRequest(branch, local.head, context.repository, context.mainRun);
29
+ const listed = parseWorktreeList(git(['worktree', 'list', '--porcelain', '-z'], context.runtime));
30
+ const same = listed.some((entry) => entry.path === path && entry.branch === branch && entry.head === local.head);
31
+ if (nests(listed.map((entry) => entry.path)))
32
+ return keep('contains another worktree');
33
+ if ('reason' in again || again.head !== local.head || pullAgain !== undefined || !same)
34
+ return keep('changed since check');
35
+ if (git(['worktree', 'remove', path], { ...context.mainRun, timeout: 0 }) === undefined) {
36
+ return keep('git worktree remove refused');
37
+ }
38
+ return { path, branch, status: 'reclaimed' };
39
+ }
40
+ /** Lists finished worktrees and, with `yes`, removes them with a plain (never forced) `git worktree remove`. */
41
+ export function reclaimWorktrees(yes, runtime) {
42
+ const listing = git(['worktree', 'list', '--porcelain', '-z'], runtime);
43
+ const self = git(['rev-parse', '--show-toplevel'], runtime)?.replace(/\n$/, '');
44
+ const [main, ...others] = parseWorktreeList(listing);
45
+ if (main === undefined || !self)
46
+ throw new Error('not inside a git worktree');
47
+ const mainRun = { cwd: main.path, env: runtime.env };
48
+ const context = {
49
+ yes,
50
+ runtime,
51
+ mainRun,
52
+ selfReal: realpathSync(self),
53
+ registered: [main, ...others].map((record) => record.path),
54
+ repository: readRepository(mainRun),
55
+ };
56
+ const worktrees = [];
57
+ for (const record of others) {
58
+ try {
59
+ const entry = reclaimOne(record, context);
60
+ if (entry !== undefined)
61
+ worktrees.push(entry);
62
+ }
63
+ catch (error) {
64
+ const reason = `error: ${error.message}`;
65
+ worktrees.push({ path: record.path, branch: record.branch, status: 'kept', reason });
66
+ }
67
+ }
68
+ return { dryRun: !yes, worktrees };
69
+ }
70
+ function escape(text) {
71
+ return text.replace(/\p{Cc}/gu, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`);
72
+ }
73
+ export function formatReport(report) {
74
+ return report.worktrees
75
+ .map((entry) => {
76
+ const status = entry.status === 'kept' ? `kept: ${entry.reason}` : entry.status;
77
+ return `${escape(entry.path)}: ${escape(status)}\n`;
78
+ })
79
+ .join('');
80
+ }
@@ -0,0 +1,16 @@
1
+ export interface Fixture {
2
+ readonly root: string;
3
+ readonly main: string;
4
+ readonly caller: string;
5
+ readonly env: NodeJS.ProcessEnv;
6
+ git(cwd: string, ...args: string[]): string;
7
+ /** Adds a worktree on a new branch with one commit; optionally pushes it to origin. */
8
+ addWorktree(name: string, options?: {
9
+ push?: boolean;
10
+ dir?: string;
11
+ }): string;
12
+ /** Writes the fake `gh pr list` answer; a PR with no head defaults to the branch tip. */
13
+ setPrs(branch: string, prs: object[]): void;
14
+ cleanup(): void;
15
+ }
16
+ export declare function createFixture(): Fixture;
@@ -0,0 +1,113 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ // Answers `gh repo view` and `gh pr list --head <branch>` from files, like the real CLI.
6
+ const FAKE_GH = `#!${process.execPath}
7
+ const fs = require('node:fs')
8
+ const args = process.argv.slice(2)
9
+ const env = process.env
10
+ if (env.FAKE_GH_FAIL) process.exit(1)
11
+ if (env.FAKE_GH_NO_GIT_DIR && env.GIT_DIR) process.exit(1)
12
+ if (args[0] === 'repo') {
13
+ console.log(JSON.stringify({ nameWithOwner: env.GH_REPO || 'Acme/Widgets' }))
14
+ process.exit(0)
15
+ }
16
+ if (env.FAKE_GH_GARBAGE) {
17
+ console.log('not json')
18
+ process.exit(0)
19
+ }
20
+ const state = args[args.indexOf('--state') + 1]
21
+ // Simulates a change that lands between the first assessment and the re-check.
22
+ if (state === 'all') {
23
+ const { execFileSync } = require('node:child_process')
24
+ const run = (cwd, ...a) => execFileSync('git', a, { cwd, stdio: 'ignore' })
25
+ if (env.FAKE_GH_NEST && !fs.existsSync(env.FAKE_GH_DIR + '/nested')) {
26
+ fs.writeFileSync(env.FAKE_GH_DIR + '/nested', '')
27
+ run(env.FAKE_GH_NEST, 'worktree', 'add', '-b', 'kid', 'sub/kid')
28
+ }
29
+ if (env.FAKE_GH_TOUCH) fs.writeFileSync(env.FAKE_GH_TOUCH, 'x')
30
+ if (env.FAKE_GH_SWITCH) run(env.FAKE_GH_SWITCH, 'checkout', '-b', 'switched')
31
+ if (env.FAKE_GH_ADVANCE) {
32
+ run(env.FAKE_GH_ADVANCE, 'commit', '--allow-empty', '-m', 'late')
33
+ run(env.FAKE_GH_ADVANCE, 'push', 'origin', 'HEAD')
34
+ }
35
+ }
36
+ const branch = args[args.indexOf('--head') + 1]
37
+ const file = env.FAKE_GH_DIR + '/' + branch.replaceAll('/', '_') + '.json'
38
+ let pulls = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : []
39
+ if (state === 'open') pulls = pulls.filter((pr) => pr.state === 'OPEN')
40
+ const pad = Number(env['FAKE_GH_PAD_' + state.toUpperCase()] || 0)
41
+ for (let i = 0; i < pad; i += 1) pulls.push({ state: 'CLOSED' })
42
+ console.log(JSON.stringify(pulls))
43
+ // Reopen every PR after the first full answer, as if it changed before the re-check.
44
+ if (state === 'all' && env.FAKE_GH_REOPEN && !fs.existsSync(file + '.reopened')) {
45
+ fs.writeFileSync(file, JSON.stringify(pulls.map((pr) => ({ ...pr, state: 'OPEN' }))))
46
+ fs.writeFileSync(file + '.reopened', '')
47
+ }
48
+ `;
49
+ const realGit = execFileSync('which', ['git'], { encoding: 'utf8' }).trim();
50
+ export function createFixture() {
51
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'worktree-reclaim-')));
52
+ const bin = join(root, 'bin');
53
+ const ghDir = join(root, 'gh');
54
+ mkdirSync(bin);
55
+ mkdirSync(ghDir);
56
+ writeFileSync(join(bin, 'gh'), FAKE_GH);
57
+ chmodSync(join(bin, 'gh'), 0o755);
58
+ // A git wrapper that fails any invocation containing the words in FAKE_GIT_FAIL, to reach error paths.
59
+ writeFileSync(join(bin, 'git'), `#!/bin/sh\ncase " $* " in *" $FAKE_GIT_FAIL "*) [ -n "$FAKE_GIT_FAIL" ] && exit 1;; esac\nexec '${realGit}' "$@"\n`);
60
+ chmodSync(join(bin, 'git'), 0o755);
61
+ const env = {
62
+ ...process.env,
63
+ PATH: `${bin}:${process.env.PATH ?? ''}`,
64
+ FAKE_GH_DIR: ghDir,
65
+ GIT_CONFIG_GLOBAL: '/dev/null',
66
+ GIT_CONFIG_SYSTEM: '/dev/null',
67
+ GIT_AUTHOR_NAME: 'Test',
68
+ GIT_AUTHOR_EMAIL: 'test@example.com',
69
+ GIT_COMMITTER_NAME: 'Test',
70
+ GIT_COMMITTER_EMAIL: 'test@example.com',
71
+ };
72
+ const git = (cwd, ...args) => execFileSync('git', args, { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
73
+ const origin = join(root, 'origin.git');
74
+ const main = join(root, 'main');
75
+ git(root, 'init', '--bare', '-b', 'main', origin);
76
+ git(root, 'init', '-b', 'main', main);
77
+ writeFileSync(join(main, 'README.md'), 'base\n');
78
+ git(main, 'add', '.');
79
+ git(main, 'commit', '-m', 'base');
80
+ git(main, 'remote', 'add', 'origin', origin);
81
+ git(main, 'push', '-u', 'origin', 'main');
82
+ const caller = join(root, 'worktrees', 'caller');
83
+ git(main, 'worktree', 'add', '-b', 'caller', caller);
84
+ return {
85
+ root,
86
+ main,
87
+ caller,
88
+ env,
89
+ git,
90
+ addWorktree: (name, options = {}) => {
91
+ const path = join(root, 'worktrees', options.dir ?? name);
92
+ git(main, 'worktree', 'add', '-b', name, path);
93
+ writeFileSync(join(path, `${name}.txt`), `${name}\n`);
94
+ git(path, 'add', '.');
95
+ git(path, 'commit', '-m', name);
96
+ if (options.push)
97
+ git(path, 'push', '-u', 'origin', name);
98
+ return path;
99
+ },
100
+ setPrs: (branch, prs) => {
101
+ const tip = git(main, 'rev-parse', `refs/heads/${branch}`).trim();
102
+ const filled = prs.map((pr) => ({
103
+ headRefOid: tip,
104
+ isCrossRepository: false,
105
+ headRepository: { name: 'Widgets' },
106
+ headRepositoryOwner: { login: 'acme' },
107
+ ...pr,
108
+ }));
109
+ writeFileSync(join(ghDir, `${branch.replaceAll('/', '_')}.json`), JSON.stringify(filled));
110
+ },
111
+ cleanup: () => rmSync(root, { force: true, recursive: true }),
112
+ };
113
+ }
@@ -0,0 +1,13 @@
1
+ export interface RunOptions {
2
+ readonly cwd: string;
3
+ readonly env: NodeJS.ProcessEnv;
4
+ /** Milliseconds before a read-only call is killed; 0 for none (worktree removal). */
5
+ readonly timeout?: number;
6
+ }
7
+ /** Runs a command without a shell; returns stdout, or undefined when it fails to start or exits non-zero. */
8
+ export declare function run(file: string, args: readonly string[], options: RunOptions): string | undefined;
9
+ export declare const LOCAL_ENV: string[];
10
+ /** A copy of `env` without the named variables. */
11
+ export declare function withoutVariables(env: NodeJS.ProcessEnv, names: readonly string[]): NodeJS.ProcessEnv;
12
+ /** git with fsmonitor disabled so a repository config can never execute a hook. */
13
+ export declare function git(args: readonly string[], options: RunOptions): string | undefined;
@@ -0,0 +1,50 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ /** Runs a command without a shell; returns stdout, or undefined when it fails to start or exits non-zero. */
3
+ export function run(file, args, options) {
4
+ try {
5
+ return execFileSync(file, args, {
6
+ cwd: options.cwd,
7
+ env: options.env,
8
+ encoding: 'utf8',
9
+ maxBuffer: 64 * 1024 * 1024,
10
+ stdio: ['ignore', 'pipe', 'ignore'],
11
+ timeout: options.timeout ?? 120_000,
12
+ });
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ // Repo-local overrides (see `git rev-parse --local-env-vars`): the cwd must pick the repository.
19
+ export const LOCAL_ENV = [
20
+ 'GIT_DIR',
21
+ 'GIT_WORK_TREE',
22
+ 'GIT_INDEX_FILE',
23
+ 'GIT_OBJECT_DIRECTORY',
24
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
25
+ 'GIT_COMMON_DIR',
26
+ 'GIT_PREFIX',
27
+ 'GIT_NAMESPACE',
28
+ 'GIT_CONFIG',
29
+ 'GIT_CONFIG_PARAMETERS',
30
+ 'GIT_CONFIG_COUNT',
31
+ 'GIT_GRAFT_FILE',
32
+ 'GIT_IMPLICIT_WORK_TREE',
33
+ 'GIT_INTERNAL_SUPER_PREFIX',
34
+ 'GIT_SHALLOW_FILE',
35
+ 'GIT_REPLACE_REF_BASE',
36
+ 'GIT_NO_REPLACE_OBJECTS',
37
+ 'GIT_QUARANTINE_PATH',
38
+ ];
39
+ /** A copy of `env` without the named variables. */
40
+ export function withoutVariables(env, names) {
41
+ const copy = { ...env };
42
+ for (const name of names)
43
+ delete copy[name];
44
+ return copy;
45
+ }
46
+ /** git with fsmonitor disabled so a repository config can never execute a hook. */
47
+ export function git(args, options) {
48
+ const env = withoutVariables(options.env, LOCAL_ENV);
49
+ return run('git', ['-c', 'core.fsmonitor=false', ...args], { ...options, env });
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.52.1",
3
+ "version": "0.53.0",
4
4
  "description": "Vouchington CLI and extractable tooling libraries.",
5
5
  "homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
6
6
  "bugs": {