* fix(#373): replace unquoted $GSD_SDK with space-safe gsd_run launcher Workflow bash blocks resolved the runtime as GSD_SDK="node $GSD_TOOLS" and invoked it unquoted ($GSD_SDK query ...). On install paths containing spaces (e.g. /Volumes/Mini Me/...) the unquoted expansion word-split into `node /Volumes/Mini gsd-tools.cjs ...`, failing with "Cannot find module '/Volumes/Mini'" and getting masked by `2>/dev/null || echo "{}"` into a silent empty state. Replace the string variable with a single-line shell launcher that defines a gsd_run function, invokes the runtime with a fully-quoted path and "$@", and preserves the local-cjs / installed-gsd-tools-on-PATH fallback (#3668) plus the loud not-found error and install hint. The launcher uses _GSD_SHIM_NAME indirection so no workflow emits the /gsd-tools substring that the do.md dispatcher-parity scanner would misread, and is single-line to stay within the per-file progressive-disclosure line budgets (#2551). The canonical launcher lives in get-shit-done/workflows/_runtime-launcher.snippet.sh, is propagated once per file by scripts/sync-runtime-launcher.cjs, and is locked by tests/runtime-launcher-parity.test.cjs (fails CI on drift, on a reappearing $GSD_SDK token, or on a /gsd-tools substring). Dependent workflow-assertion tests are updated from $GSD_SDK to gsd_run, and the runtime launcher is registered in CONTEXT.md. Fixes #373 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(#373): add changeset fragment and allow-test-rule for parity guard The runtime-launcher parity test is a structural drift guard that reads workflow markdown to assert the canonical launcher is present and the retired $GSD_SDK / /gsd-tools tokens are absent; annotate it with allow-test-rule per the no-source-grep lint escape hatch. Add the required .changeset fragment for this user-facing fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(#373): make parity PATH-fallback assertion cross-platform (Windows) Subtest (E) compared GSD_TOOLS against the Node-side absolute temp path, but the value originates from git-bash which reports the POSIX form, so the prefix comparison failed on windows-latest while the launcher itself worked (the installed stub was invoked). Assert the resolved binary by normalized suffix (/bin/gsd-tools, not .cjs) instead of the absolute prefix; the behavioral stub-invocation assertion is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,7 @@ function parseExecutePhaseContract(filePath) {
|
||||
delegatesToCleanupWave: lines.some(l => l.includes('worktree.cleanup-wave')),
|
||||
// Does the cleanup-wave invocation use || exit 1 (fail-closed)?
|
||||
cleanupWaveFailClosed: lines.some(
|
||||
l => /\$GSD_SDK query worktree\.cleanup-wave.*\|\| exit 1/.test(l),
|
||||
l => /gsd_run query worktree\.cleanup-wave.*\|\| exit 1/.test(l),
|
||||
),
|
||||
// Does the workflow export/reference WAVE_WORKTREE_MANIFEST for the SDK?
|
||||
passesWaveManifest: lines.some(l => l.includes('WAVE_WORKTREE_MANIFEST')),
|
||||
@@ -54,7 +54,7 @@ describe('execute-phase.md — post-merge deletion audit (#2384)', () => {
|
||||
// The workflow delegates to the SDK rather than duplicating the check inline.
|
||||
assert.ok(
|
||||
contract.delegatesToCleanupWave,
|
||||
'execute-phase.md must delegate to $GSD_SDK query worktree.cleanup-wave (#2384/#3797)',
|
||||
'execute-phase.md must delegate to gsd_run query worktree.cleanup-wave (#2384/#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -90,18 +90,18 @@ describe('bug #2661: execute-plan.md update_roadmap gating', () => {
|
||||
|
||||
test('update_roadmap step exists and invokes roadmap.update-plan-progress', () => {
|
||||
assert.ok(stepMatch, 'update_roadmap step must exist');
|
||||
// After #3797 architectural fix, callsites use $GSD_SDK — accept either form
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
assert.ok(
|
||||
/(?:\$GSD_SDK|gsd-sdk) query roadmap\.update-plan-progress/.test(step),
|
||||
/gsd_run query roadmap\.update-plan-progress/.test(step),
|
||||
'update_roadmap must still invoke roadmap.update-plan-progress'
|
||||
);
|
||||
});
|
||||
|
||||
test('use_worktrees: false mode — sync call is gated to fire (the #2661 reproducer)', () => {
|
||||
// The non-worktree branch must contain the sync call.
|
||||
// After #3797 architectural fix, callsites use $GSD_SDK — accept either form
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
assert.ok(
|
||||
/IS_WORKTREE.*!=.*"true"[\s\S]*?(?:\$GSD_SDK|gsd-sdk) query roadmap\.update-plan-progress/.test(step),
|
||||
/IS_WORKTREE.*!=.*"true"[\s\S]*?gsd_run query roadmap\.update-plan-progress/.test(step),
|
||||
'sync call must execute on the IS_WORKTREE != "true" branch (use_worktrees: false)'
|
||||
);
|
||||
});
|
||||
@@ -119,9 +119,9 @@ describe('bug #2661: execute-plan.md update_roadmap gating', () => {
|
||||
'bash block must include the IS_WORKTREE worktree-detection check'
|
||||
);
|
||||
// Sync call must appear after the guard check, not before.
|
||||
// After #3797 architectural fix, callsites use $GSD_SDK — accept either form
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
const guardIdx = bash.search(/if \[ "\$IS_WORKTREE" != "true" \]/);
|
||||
const callIdx = bash.search(/(?:\$GSD_SDK|gsd-sdk) query roadmap\.update-plan-progress/);
|
||||
const callIdx = bash.search(/gsd_run query roadmap\.update-plan-progress/);
|
||||
assert.ok(guardIdx >= 0, 'guard must be present');
|
||||
assert.ok(callIdx > guardIdx,
|
||||
'sync call must appear inside the use_worktrees: false guard, not before/outside it');
|
||||
|
||||
@@ -41,7 +41,7 @@ function parseWorkflowContract(filePath) {
|
||||
delegatesToCleanupWave: lines.some(l => l.includes('worktree.cleanup-wave')),
|
||||
// Does the cleanup-wave invocation use || exit 1 (fail-closed)?
|
||||
cleanupWaveFailClosed: lines.some(
|
||||
l => /\$GSD_SDK query worktree\.cleanup-wave.*\|\| exit 1/.test(l),
|
||||
l => /gsd_run query worktree\.cleanup-wave.*\|\| exit 1/.test(l),
|
||||
),
|
||||
// Does the workflow still contain the broken ls-files --exclude-standard rescue form?
|
||||
hasBrokenLsFilesForm: lines.some(
|
||||
|
||||
@@ -26,8 +26,8 @@ function parseWorkflowSteps(content) {
|
||||
const body = match[2];
|
||||
return {
|
||||
name: match[1],
|
||||
// After #3797 architectural fix, callsites use $GSD_SDK — accept either form
|
||||
readsRuntimeConfig: body.includes('RUNTIME=$($GSD_SDK query config-get runtime --default claude') || body.includes('RUNTIME=$(gsd-sdk query config-get runtime --default claude'),
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
readsRuntimeConfig: body.includes('RUNTIME=$(gsd_run query config-get runtime --default claude'),
|
||||
codexWorktreeGuard: body.includes('Codex execute-phase worktree isolation is unsupported'),
|
||||
worktreeDispatchGuidance: body.includes('isolation="worktree"'),
|
||||
};
|
||||
|
||||
@@ -30,20 +30,20 @@ describe('bug #3381: verify-work forwards workstream context', () => {
|
||||
/PHASE_ARG=\$\(echo "\$ARGUMENTS" \| sed -E 's\/--ws\[\[:space:\]\]\+\[\^\[:space:\]\]\+\/\/g' \| xargs\)/,
|
||||
'verify-work must derive PHASE_ARG after removing --ws',
|
||||
);
|
||||
// After #3797 architectural fix, callsites use $GSD_SDK — accept either bare or $GSD_SDK form
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
assert.match(
|
||||
workflow,
|
||||
/(?:\$GSD_SDK|gsd-sdk) query init\.verify-work "\$\{PHASE_ARG\}" \$\{GSD_WS\}/,
|
||||
/gsd_run query init\.verify-work "\$\{PHASE_ARG\}" \$\{GSD_WS\}/,
|
||||
'init.verify-work must receive GSD_WS so phase_dir resolves in workstreams',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/(?:\$GSD_SDK|gsd-sdk) query phase\.mvp-mode "\$\{phase_number\}" \$\{GSD_WS\} --pick active/,
|
||||
/gsd_run query phase\.mvp-mode "\$\{phase_number\}" \$\{GSD_WS\} --pick active/,
|
||||
'phase.mvp-mode must receive GSD_WS so roadmap mode is workstream-scoped',
|
||||
);
|
||||
assert.match(
|
||||
workflow,
|
||||
/(?:\$GSD_SDK|gsd-sdk) query roadmap\.get-phase "\$\{phase_number\}" \$\{GSD_WS\} --pick goal/,
|
||||
/gsd_run query roadmap\.get-phase "\$\{phase_number\}" \$\{GSD_WS\} --pick goal/,
|
||||
'roadmap.get-phase must receive GSD_WS so goals are workstream-scoped',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -31,12 +31,12 @@ describe('bug #3521 — quick.md post-merge cleanup CWD safety (via SDK delegati
|
||||
|
||||
test('quick.md cleanup delegates CWD-safe worktree cleanup to SDK (worktree.cleanup-wave)', () => {
|
||||
const content = readQuickMd();
|
||||
// After #3797: quick.md delegates to $GSD_SDK query worktree.cleanup-wave
|
||||
// After #3797: quick.md delegates to gsd_run query worktree.cleanup-wave
|
||||
// which handles CWD pinning, STATE.md backup, deletion guards, and branch
|
||||
// cleanup internally. The manual shell loop has been removed.
|
||||
assert.ok(
|
||||
content.includes('worktree.cleanup-wave'),
|
||||
'quick.md must delegate cleanup to $GSD_SDK query worktree.cleanup-wave (#3797)',
|
||||
'quick.md must delegate cleanup to gsd_run query worktree.cleanup-wave (#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,11 +44,11 @@ describe('bug #3521 — quick.md post-merge cleanup CWD safety (via SDK delegati
|
||||
const content = readQuickMd();
|
||||
// The || exit 1 enforces fail-closed: SDK safety refusals (e.g. branch
|
||||
// drift detection from #3174) surface immediately rather than being swallowed.
|
||||
// This is the equivalent of the pre-#3797 `gsd-sdk query ... || exit 1` in the
|
||||
// This is the equivalent of the pre-#3797 `gsd_run query ... || exit 1` in the
|
||||
// `if command -v gsd-sdk` branch.
|
||||
assert.match(
|
||||
content,
|
||||
/\$GSD_SDK query worktree\.cleanup-wave.*\|\| exit 1/,
|
||||
/gsd_run query worktree\.cleanup-wave.*\|\| exit 1/,
|
||||
'quick.md cleanup-wave must use || exit 1 — fail-closed for safety refusals (#3521/#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,14 +16,63 @@ const { execFileSync } = require('node:child_process');
|
||||
|
||||
const WORKFLOW_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'next.md');
|
||||
|
||||
/**
|
||||
* Extract the canonical runtime resolver snippet from next.md.
|
||||
*
|
||||
* Supports two forms:
|
||||
* - One-line form (canonical): the entire launcher is a single line starting with
|
||||
* `_GSD_SHIM_NAME="gsd-tools.cjs";` — return that line directly.
|
||||
* - Multi-line form (legacy): starts with a `# Runtime launcher:` comment or a
|
||||
* `_GSD_SHIM_NAME=` line followed by separate GSD_TOOLS= and if/elif/else/fi
|
||||
* lines — scan to the closing `fi`.
|
||||
*/
|
||||
function extractResolverSnippet() {
|
||||
const content = fs.readFileSync(WORKFLOW_PATH, 'utf8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
const start = lines.findIndex((line) => line.includes('SDK resolution: prefer local gsd-tools.cjs'));
|
||||
assert.notEqual(start, -1, 'next.md must contain the SDK resolution snippet');
|
||||
|
||||
const end = lines.findIndex((line, index) => index > start && line === 'fi');
|
||||
assert.notEqual(end, -1, 'SDK resolution snippet must end with fi');
|
||||
// Find the canonical preamble — prefer _GSD_SHIM_NAME= line (handles both forms)
|
||||
let start = lines.findIndex((line) => /^_GSD_SHIM_NAME=/.test(line.trim()));
|
||||
if (start === -1) {
|
||||
// Fallback: canonical preamble comment (multi-line legacy form)
|
||||
start = lines.findIndex((line) =>
|
||||
/^\s*#\s*Runtime launcher:.*prefer local gsd-tools\.cjs.*installed gsd-tools on PATH/.test(line)
|
||||
);
|
||||
}
|
||||
if (start === -1) {
|
||||
// Last fallback: GSD_TOOLS= with RUNTIME_DIR
|
||||
start = lines.findIndex((line) => line.includes('GSD_TOOLS="${RUNTIME_DIR:-'));
|
||||
}
|
||||
assert.notEqual(
|
||||
start,
|
||||
-1,
|
||||
'next.md must contain the canonical runtime preamble ' +
|
||||
'(_GSD_SHIM_NAME= line, # Runtime launcher: comment, or GSD_TOOLS= line with RUNTIME_DIR)'
|
||||
);
|
||||
|
||||
// One-line form: the entire launcher (including `if` and `fi`) is on a single line.
|
||||
// Detect by checking whether the start line contains a semicolon-separated `if` and `fi`.
|
||||
const startLine = lines[start].trim();
|
||||
if (/^_GSD_SHIM_NAME=.*;\s*if\s+\[.*\bfi$/.test(startLine)) {
|
||||
// Single-line canonical launcher — return it as-is
|
||||
return startLine;
|
||||
}
|
||||
|
||||
// Multi-line form: scan forward from start to the closing `fi`, tracking if-depth
|
||||
let depth = 0;
|
||||
let end = -1;
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
const t = lines[i].trim();
|
||||
if (/^if\s+/.test(t)) depth++;
|
||||
if (/^fi(\s|$)/.test(t)) {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.notEqual(end, -1, 'runtime preamble must end with a closing `fi`');
|
||||
|
||||
return lines.slice(start, end + 1).join('\n');
|
||||
}
|
||||
|
||||
@@ -36,7 +85,7 @@ function runResolver({ cwd, runtimeDir, pathDir }) {
|
||||
'set -e',
|
||||
extractResolverSnippet(),
|
||||
'printf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"',
|
||||
'$GSD_SDK query state.json',
|
||||
'gsd_run query state.json',
|
||||
].join('\n');
|
||||
|
||||
return execFileSync('bash', ['-c', script], {
|
||||
@@ -57,18 +106,28 @@ function writeExecutable(file, content) {
|
||||
|
||||
describe('bug-3668: workflow SDK resolver supports installed user projects', () => {
|
||||
test('falls back to installed gsd-tools when project-local runtime copy is absent', () => {
|
||||
// Bug #3668: when a user project has no local get-shit-done/bin/gsd-tools.cjs,
|
||||
// the elif branch must resolve to the gsd-tools binary on PATH.
|
||||
// RUNTIME_DIR points to a dir that has no gsd-tools.cjs.
|
||||
const tmp = makeTempDir();
|
||||
const project = path.join(tmp, 'user-project');
|
||||
const bin = path.join(tmp, 'bin');
|
||||
const runtimeNoLocal = path.join(tmp, 'runtime-no-local');
|
||||
const pathBin = path.join(tmp, 'bin');
|
||||
fs.mkdirSync(project, { recursive: true });
|
||||
writeExecutable(path.join(bin, 'gsd-tools'), '#!/bin/sh\nprintf "installed:%s %s\\n" "$1" "$2"\n');
|
||||
fs.mkdirSync(runtimeNoLocal, { recursive: true });
|
||||
|
||||
const output = runResolver({ cwd: project, pathDir: bin });
|
||||
// Place gsd-tools stub on PATH (installed binary)
|
||||
writeExecutable(
|
||||
path.join(pathBin, 'gsd-tools'),
|
||||
'#!/bin/sh\nprintf "installed:%s %s\\n" "$1" "$2"\n',
|
||||
);
|
||||
|
||||
// Normalize separators so the assertion works on Windows (Git bash emits POSIX paths)
|
||||
const norm = output.replace(/\\/g, '/');
|
||||
// subtest 1: the resolved bin is the PATH-fallback gsd-tools (suffix /bin/gsd-tools, no .cjs)
|
||||
assert.match(norm, /GSD_TOOLS=\S*\/bin\/gsd-tools(?:\s|$)/m);
|
||||
// NO get-shit-done/bin/gsd-tools.cjs in runtimeNoLocal
|
||||
const output = runResolver({ cwd: project, runtimeDir: runtimeNoLocal, pathDir: pathBin });
|
||||
|
||||
// GSD_TOOLS must have been reassigned to the PATH binary (not the missing .cjs)
|
||||
assert.match(output, /GSD_TOOLS=.+gsd-tools(?:\s|$)/m);
|
||||
// The PATH stub must have been invoked
|
||||
assert.match(output, /installed:query state\.json/);
|
||||
});
|
||||
|
||||
@@ -78,7 +137,7 @@ describe('bug-3668: workflow SDK resolver supports installed user projects', ()
|
||||
const runtime = path.join(tmp, 'runtime');
|
||||
const pathBin = path.join(tmp, 'bin');
|
||||
fs.mkdirSync(project, { recursive: true });
|
||||
writeExecutable(path.join(pathBin, 'gsd-tools'), '#!/bin/sh\nprintf "installed:%s %s\\n" "$1" "$2"\n');
|
||||
writeExecutable(path.join(pathBin, 'gsd-tools'), '#!/bin/sh\nprintf "path-installed:%s %s\\n" "$1" "$2"\n');
|
||||
writeExecutable(
|
||||
path.join(runtime, 'get-shit-done', 'bin', 'gsd-tools.cjs'),
|
||||
'#!/usr/bin/env node\nconsole.log(`runtime:${process.argv[2]} ${process.argv[3]}`);\n',
|
||||
@@ -88,9 +147,10 @@ describe('bug-3668: workflow SDK resolver supports installed user projects', ()
|
||||
|
||||
// Normalize separators so the assertion works on Windows (Git bash emits POSIX paths)
|
||||
const norm = output.replace(/\\/g, '/');
|
||||
// subtest 2: the resolved bin is the RUNTIME_DIR local runtime (suffix /get-shit-done/bin/gsd-tools.cjs)
|
||||
assert.match(norm, /GSD_TOOLS=\S*\/get-shit-done\/bin\/gsd-tools\.cjs(?:\s|$)/m);
|
||||
// The resolved bin is the RUNTIME_DIR local runtime (suffix /get-shit-done/bin/gsd-tools.cjs)
|
||||
// Use .+ instead of \S* to handle paths with spaces (e.g. /Volumes/Mini Me/...)
|
||||
assert.match(norm, /GSD_TOOLS=.+\/get-shit-done\/bin\/gsd-tools\.cjs(?:\s|$)/m);
|
||||
assert.match(output, /runtime:query state\.json/);
|
||||
assert.doesNotMatch(output, /installed:query state\.json/);
|
||||
assert.doesNotMatch(output, /path-installed:query state\.json/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,8 +44,8 @@ test('new-milestone.md: todo linking is best-effort and leaves unmatched todos u
|
||||
});
|
||||
|
||||
test('new-milestone.md: step 10.5 commits tagged todos', () => {
|
||||
// After #3797 architectural fix, callsites use $GSD_SDK — accept either form
|
||||
assert.ok(NEW_MILESTONE.includes('$GSD_SDK query commit') || NEW_MILESTONE.includes('gsd-sdk query commit'), 'should commit tagged todos');
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
assert.ok(NEW_MILESTONE.includes('gsd_run query commit'), 'should commit tagged todos');
|
||||
assert.ok(NEW_MILESTONE.includes('resolves_phase after milestone'), 'commit message should mention resolves_phase');
|
||||
});
|
||||
|
||||
|
||||
@@ -197,11 +197,11 @@ describe('gsd-health --context flag is wired into command + workflow', () => {
|
||||
const stepMatch = raw.match(/<step name="context_check">([\s\S]*?)<\/step>/);
|
||||
assert.ok(stepMatch, 'context_check step must be a closed <step>...</step> block');
|
||||
const stepBody = stepMatch[1];
|
||||
// After #3797 architectural fix, callsites use $GSD_SDK — accept either form
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
assert.match(
|
||||
stepBody,
|
||||
/(?:\$GSD_SDK|gsd-sdk)\s+query\s+validate\.context/,
|
||||
'context_check must call `gsd-sdk query validate.context`',
|
||||
/gsd_run\s+query\s+validate\.context/,
|
||||
'context_check must call `gsd_run query validate.context`',
|
||||
);
|
||||
assert.match(stepBody, /--tokens-used/, 'context_check must pass --tokens-used');
|
||||
assert.match(stepBody, /--context-window/, 'context_check must pass --context-window');
|
||||
|
||||
385
tests/runtime-launcher-parity.test.cjs
Normal file
385
tests/runtime-launcher-parity.test.cjs
Normal file
@@ -0,0 +1,385 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Parity test for bug #373: space-safe gsd_run launcher
|
||||
*
|
||||
* Asserts:
|
||||
* (A) No retired GSD_SDK token remains in any workflow .md file.
|
||||
* (B) Each workflow .md that uses gsd_run contains EXACTLY ONE canonical preamble
|
||||
* (byte-equal to _runtime-launcher.snippet.sh), and it appears before the first
|
||||
* gsd_run call. NOT every bash block — exactly one per file (define once, use
|
||||
* across blocks — original footprint).
|
||||
* (C) Space-safe behavioral: a RUNTIME_DIR path with spaces in it resolves
|
||||
* and calls gsd-tools.cjs correctly (no word-split, no {}).
|
||||
* (D) Loud guard behavioral: missing gsd-tools.cjs exits non-zero and emits
|
||||
* "not found" to stderr.
|
||||
* (E) PATH fallback behavioral: when no local gsd-tools.cjs, the elif branch
|
||||
* resolves to the gsd-tools binary on PATH (#3668).
|
||||
* (F) Regression locks: the snippet file contains no /gsd-tools substring; and
|
||||
* no line in workflows/do.md matches /\/gsd[:-][a-z]/ (dispatcher-parity
|
||||
* scanner must not read the preamble as a slash-command stub).
|
||||
*/
|
||||
|
||||
// allow-test-rule: structural parity/drift guard — asserts literal presence/absence of the canonical gsd_run launcher and the retired $GSD_SDK / `/gsd-tools` tokens across workflow markdown; there is no typed IR for "this source file does not contain substring X".
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const WORKFLOWS_DIR = path.join(__dirname, '..', 'get-shit-done', 'workflows');
|
||||
const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh');
|
||||
|
||||
/**
|
||||
* Read the canonical preamble from the snippet file (all lines, no trailing newline).
|
||||
*/
|
||||
function expectedPreamble() {
|
||||
const raw = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
const lines = raw.split('\n');
|
||||
// Strip trailing empty element produced by a trailing newline.
|
||||
const content = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
|
||||
assert.ok(content.length >= 1, `_runtime-launcher.snippet.sh must not be empty`);
|
||||
return content; // array of strings
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all bash/sh/shell fenced blocks from markdown content.
|
||||
* Returns array of { index, lines } where index is 0-based block count,
|
||||
* and lines is the array of content lines (without the fence markers).
|
||||
*
|
||||
* Handles both column-0 fences (```bash) and indented fences ( ```bash).
|
||||
*/
|
||||
function extractShellBlocks(content) {
|
||||
const allLines = content.split('\n');
|
||||
const blocks = [];
|
||||
let inBlock = false;
|
||||
let blockLang = null;
|
||||
let blockLines = [];
|
||||
let blockIndex = 0;
|
||||
let blockIndent = '';
|
||||
let closingPattern = null;
|
||||
|
||||
for (let i = 0; i < allLines.length; i++) {
|
||||
const line = allLines[i];
|
||||
if (!inBlock) {
|
||||
const fenceOpen = line.match(/^(\s*)```(\w+)?\s*$/);
|
||||
if (fenceOpen) {
|
||||
inBlock = true;
|
||||
blockIndent = fenceOpen[1];
|
||||
blockLang = (fenceOpen[2] || '').toLowerCase();
|
||||
blockLines = [];
|
||||
// Closing pattern: same indent prefix + ```
|
||||
closingPattern = new RegExp('^' + blockIndent.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '```\\s*$');
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (closingPattern.test(line)) {
|
||||
if (['bash', 'sh', 'shell', 'zsh', ''].includes(blockLang)) {
|
||||
blocks.push({ index: blockIndex, lang: blockLang, lines: blockLines });
|
||||
blockIndex++;
|
||||
}
|
||||
inBlock = false;
|
||||
blockLang = null;
|
||||
blockLines = [];
|
||||
blockIndent = '';
|
||||
closingPattern = null;
|
||||
continue;
|
||||
}
|
||||
blockLines.push(line);
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all workflow .md files recursively under WORKFLOWS_DIR.
|
||||
* Excludes _runtime-launcher.snippet.sh (not a markdown file).
|
||||
*/
|
||||
function collectWorkflowFiles() {
|
||||
const results = [];
|
||||
function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full);
|
||||
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(WORKFLOWS_DIR);
|
||||
return results;
|
||||
}
|
||||
|
||||
describe('runtime-launcher-parity (#373)', () => {
|
||||
// ─── (A) No retired GSD_SDK token ────────────────────────────────────────
|
||||
test('(A) no GSD_SDK token in any workflow .md file', () => {
|
||||
const files = collectWorkflowFiles();
|
||||
assert.ok(files.length > 0, 'expected at least one workflow .md file');
|
||||
|
||||
const offending = [];
|
||||
for (const f of files) {
|
||||
const content = fs.readFileSync(f, 'utf8');
|
||||
if (content.includes('GSD_SDK')) {
|
||||
offending.push(path.relative(WORKFLOWS_DIR, f));
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(
|
||||
offending,
|
||||
[],
|
||||
'Found GSD_SDK (retired token) in workflow files — run `node scripts/sync-runtime-launcher.cjs` to fix:\n' +
|
||||
offending.join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
// ─── (B) Exactly ONE canonical preamble per using file ───────────────────
|
||||
test('(B) each workflow .md using gsd_run contains exactly ONE canonical preamble, before the first gsd_run call', () => {
|
||||
const preamble = expectedPreamble();
|
||||
const preambleStr = preamble.join('\n');
|
||||
const files = collectWorkflowFiles();
|
||||
assert.ok(files.length > 0, 'expected at least one workflow .md file');
|
||||
|
||||
const violations = [];
|
||||
|
||||
for (const f of files) {
|
||||
const rel = path.relative(WORKFLOWS_DIR, f);
|
||||
const content = fs.readFileSync(f, 'utf8');
|
||||
const blocks = extractShellBlocks(content);
|
||||
|
||||
// Collect all block lines in document order for flat analysis
|
||||
const allBlockLines = [];
|
||||
for (const blk of blocks) {
|
||||
allBlockLines.push(...blk.lines);
|
||||
}
|
||||
|
||||
// Does this file use gsd_run at all?
|
||||
const fileHasGsdRun = allBlockLines.some((l) => /\bgsd_run\b/.test(l));
|
||||
if (!fileHasGsdRun) continue;
|
||||
|
||||
// Count preamble occurrences across all shell content of this file
|
||||
// Flatten all block lines with a separator so multi-block boundary doesn't create false match
|
||||
const allContent = allBlockLines.join('\n');
|
||||
let preambleCount = 0;
|
||||
let searchPos = 0;
|
||||
while (true) {
|
||||
const idx = allContent.indexOf(preambleStr, searchPos);
|
||||
if (idx === -1) break;
|
||||
preambleCount++;
|
||||
searchPos = idx + preambleStr.length;
|
||||
}
|
||||
|
||||
if (preambleCount !== 1) {
|
||||
violations.push(
|
||||
`${rel}: expected exactly 1 canonical preamble occurrence in bash blocks, found ${preambleCount}. ` +
|
||||
`Run \`node scripts/sync-runtime-launcher.cjs\` to fix.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verify preamble appears BEFORE the first gsd_run call (in document order)
|
||||
// Find the line index of the preamble start vs the first gsd_run call in the flat content
|
||||
const preamblePos = allContent.indexOf(preambleStr);
|
||||
const firstGsdRunPos = allContent.search(/\bgsd_run\b/);
|
||||
|
||||
// The first gsd_run WITHIN the preamble itself (the function definition) is fine.
|
||||
// We need to verify that no gsd_run CALL (i.e. gsd_run used as a command, not in a
|
||||
// function definition body) appears before the preamble starts.
|
||||
// Simple check: preamble starts at or before the first gsd_run occurrence
|
||||
if (preamblePos > firstGsdRunPos) {
|
||||
violations.push(
|
||||
`${rel}: preamble appears AFTER the first gsd_run reference — it must precede all gsd_run calls.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(
|
||||
violations,
|
||||
[],
|
||||
'Files with gsd_run calls have wrong preamble count or ordering:\n' +
|
||||
violations.join('\n---\n'),
|
||||
);
|
||||
});
|
||||
|
||||
// ─── (C) Space-safe behavioral test ──────────────────────────────────────
|
||||
test('(C) gsd_run works with a RUNTIME_DIR path containing spaces', () => {
|
||||
// Create temp dir whose path contains a space
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd 373 '));
|
||||
try {
|
||||
const binDir = path.join(base, 'get-shit-done', 'bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
// Stub gsd-tools.cjs that prints its argv
|
||||
const stub = path.join(binDir, 'gsd-tools.cjs');
|
||||
fs.writeFileSync(stub, '#!/usr/bin/env node\nconsole.log("STUB:" + process.argv.slice(2).join(","));\n');
|
||||
fs.chmodSync(stub, 0o755);
|
||||
|
||||
// Build a shell script: set RUNTIME_DIR, source preamble, run gsd_run
|
||||
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
const scriptContent =
|
||||
`export RUNTIME_DIR=${JSON.stringify(base)}\n` +
|
||||
snippet +
|
||||
`\ngsd_run query state.json\n`;
|
||||
|
||||
const scriptPath = path.join(base, 'test-space.sh');
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
const stdout = execFileSync('bash', [scriptPath], { encoding: 'utf8' });
|
||||
assert.ok(
|
||||
stdout.includes('STUB:query,state.json'),
|
||||
`Expected stdout to contain "STUB:query,state.json" but got: ${stdout.trim()}`,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── (D) Loud guard: missing runtime is fatal ─────────────────────────────
|
||||
test('(D) missing gsd-tools.cjs and no PATH gsd-tools causes loud non-zero exit with "not found" on stderr', () => {
|
||||
// Create temp dir with a space in the name, but NO gsd-tools.cjs.
|
||||
// We ensure gsd-tools is not on PATH by prepending a dir that has no
|
||||
// gsd-tools binary (system binaries remain on PATH so bash/node work).
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd 373 notools '));
|
||||
// Place a no-op dir first in PATH; no gsd-tools stub there.
|
||||
const noToolsBin = path.join(base, 'nobin');
|
||||
fs.mkdirSync(noToolsBin, { recursive: true });
|
||||
try {
|
||||
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
// The script must also unset any GSD_TOOLS env var that might leak in
|
||||
const scriptContent =
|
||||
`unset GSD_TOOLS\n` +
|
||||
`export RUNTIME_DIR=${JSON.stringify(base)}\n` +
|
||||
snippet +
|
||||
`\ngsd_run query state.json\n`;
|
||||
|
||||
const scriptPath = path.join(base, 'test-guard.sh');
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
// Build a PATH that has noToolsBin first (no gsd-tools stub there) but retains
|
||||
// system paths needed for bash. Exclude any PATH entry that contains a gsd-tools binary.
|
||||
const systemPaths = (process.env.PATH || '/usr/bin:/bin')
|
||||
.split(path.delimiter)
|
||||
.filter((p) => {
|
||||
try { fs.accessSync(path.join(p, 'gsd-tools'), fs.constants.X_OK); return false; }
|
||||
catch { return true; }
|
||||
});
|
||||
const isolatedPath = [noToolsBin, ...systemPaths].join(path.delimiter);
|
||||
|
||||
let threw = false;
|
||||
let stderrOutput = '';
|
||||
try {
|
||||
execFileSync('bash', [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, PATH: isolatedPath },
|
||||
});
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
stderrOutput = err.stderr || '';
|
||||
}
|
||||
|
||||
assert.ok(threw, 'Expected the script to exit non-zero when gsd-tools.cjs is missing and gsd-tools is not on PATH');
|
||||
assert.ok(
|
||||
stderrOutput.includes('not found') || stderrOutput.includes('ERROR'),
|
||||
`Expected stderr to contain "not found" or "ERROR", got: ${stderrOutput.trim()}`,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── (E) PATH fallback behavioral (#3668) ────────────────────────────────
|
||||
test('(E) PATH fallback: uses installed gsd-tools when no local gsd-tools.cjs present', () => {
|
||||
// Create a temp dir with NO local get-shit-done/bin/gsd-tools.cjs.
|
||||
// Place an executable gsd-tools stub on a dedicated PATH dir.
|
||||
// RUNTIME_DIR points somewhere that has no gsd-tools.cjs.
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd 373 pathfb '));
|
||||
try {
|
||||
const pathBinDir = path.join(base, 'bin');
|
||||
fs.mkdirSync(pathBinDir, { recursive: true });
|
||||
|
||||
// Stub installed gsd-tools binary that prints a marker
|
||||
const stubPath = path.join(pathBinDir, 'gsd-tools');
|
||||
fs.writeFileSync(stubPath, '#!/bin/sh\necho "installed:$*"\n');
|
||||
fs.chmodSync(stubPath, 0o755);
|
||||
|
||||
// RUNTIME_DIR points to base — no get-shit-done/bin/gsd-tools.cjs there
|
||||
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
const scriptContent =
|
||||
`export RUNTIME_DIR=${JSON.stringify(base)}\n` +
|
||||
snippet +
|
||||
`\nprintf "GSD_TOOLS=%s\\n" "$GSD_TOOLS"\n` +
|
||||
`gsd_run query state.json\n`;
|
||||
|
||||
const scriptPath = path.join(base, 'test-pathfb.sh');
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
const stdout = execFileSync('bash', [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: `${pathBinDir}${path.delimiter}${process.env.PATH || ''}` },
|
||||
});
|
||||
|
||||
// The PATH fallback must have resolved GSD_TOOLS to the stub binary.
|
||||
// Normalize backslashes → forward slashes so the assertion works on Windows
|
||||
// (git-bash emits POSIX paths while Node's os.tmpdir() returns the Windows form).
|
||||
// Assert by suffix (/bin/gsd-tools, no .cjs extension) rather than absolute prefix
|
||||
// because the prefix differs between Windows and POSIX.
|
||||
// Use .+ (not \S*) to tolerate paths that contain spaces.
|
||||
const normStdout = stdout.replace(/\\/g, '/');
|
||||
assert.match(
|
||||
normStdout,
|
||||
/GSD_TOOLS=.+\/bin\/gsd-tools(?:\s|$)/m,
|
||||
`Expected GSD_TOOLS to resolve to the installed PATH stub (suffix /bin/gsd-tools), got: ${stdout.trim()}`,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
normStdout,
|
||||
/GSD_TOOLS=.+\.cjs/m,
|
||||
`Expected GSD_TOOLS NOT to point to a .cjs file in PATH fallback, got: ${stdout.trim()}`,
|
||||
);
|
||||
// The stub must have been invoked with the query arguments
|
||||
assert.ok(
|
||||
stdout.includes('installed:query state.json'),
|
||||
`Expected stdout to contain "installed:query state.json" (PATH stub output), got: ${stdout.trim()}`,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── (F) Regression locks: no /gsd-tools substring; no do.md dispatcher false-positive ──
|
||||
test('(F) snippet has no /gsd-tools substring; do.md has no /gsd[:-][a-z] matches', () => {
|
||||
// (F1) The snippet must not contain the literal substring /gsd-tools.
|
||||
// The _GSD_SHIM_NAME indirection ensures bin/${_GSD_SHIM_NAME} instead of
|
||||
// bin/gsd-tools.cjs — so the do.md dispatcher regex /\/gsd[:-]([a-z]...)/ never
|
||||
// misreads a preamble line as a slash-command stub.
|
||||
const snippetContent = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
assert.ok(
|
||||
!snippetContent.includes('/gsd-tools'),
|
||||
`_runtime-launcher.snippet.sh must not contain the literal "/gsd-tools" substring. ` +
|
||||
`Use bin/\${_GSD_SHIM_NAME} indirection to keep the /gsd[:-] scanner from ` +
|
||||
`misreading it as a slash-command stub. Found in snippet:\n` +
|
||||
snippetContent.split('\n').filter((l) => l.includes('/gsd-tools')).join('\n'),
|
||||
);
|
||||
|
||||
// (F2) workflows/do.md must not contain the literal substring /gsd-tools
|
||||
// (the specific path that leaks when _GSD_SHIM_NAME indirection is bypassed).
|
||||
// The bug-2954 dispatcher scanner /\/gsd[:-]([a-z]...)/ would misread
|
||||
// /gsd-tools as a slash-command stub named "tools" — which is not shipped.
|
||||
// Note: /gsd:command references (with colon) in the dispatch table are
|
||||
// legitimate and are NOT checked here.
|
||||
const doMdPath = path.join(WORKFLOWS_DIR, 'do.md');
|
||||
const doMdContent = fs.readFileSync(doMdPath, 'utf8');
|
||||
const offendingLines = doMdContent
|
||||
.split('\n')
|
||||
.filter((l) => /\/gsd-tools/.test(l));
|
||||
assert.deepStrictEqual(
|
||||
offendingLines,
|
||||
[],
|
||||
`workflows/do.md contains the literal "/gsd-tools" substring which the dispatcher-parity ` +
|
||||
`scanner (bug-2954) misreads as a slash-command stub. Use \${_GSD_SHIM_NAME} indirection. ` +
|
||||
`Offending lines:\n` +
|
||||
offendingLines.join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -111,10 +111,10 @@ describe('ultraplan-phase workflow initialization', () => {
|
||||
const content = fs.readFileSync(WF_PATH, 'utf-8');
|
||||
|
||||
test('loads GSD phase context via gsd-sdk query init.plan-phase', () => {
|
||||
// After #3797 architectural fix, callsites use $GSD_SDK — accept either form
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
assert.ok(
|
||||
content.includes('$GSD_SDK query init.plan-phase') || content.includes('gsd-sdk query init.plan-phase'),
|
||||
'workflow must load phase context via gsd-sdk query init.plan-phase',
|
||||
content.includes('gsd_run query init.plan-phase'),
|
||||
'workflow must load phase context via gsd_run query init.plan-phase',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -500,7 +500,7 @@ describe('worktree cleanup after executor completes (#1496)', () => {
|
||||
// deletion internally). The manual shell cleanup loop has been removed.
|
||||
assert.ok(
|
||||
content.includes('worktree.cleanup-wave'),
|
||||
'quick cleanup must delegate to $GSD_SDK query worktree.cleanup-wave (#3797)',
|
||||
'quick cleanup must delegate to gsd_run query worktree.cleanup-wave (#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -510,7 +510,7 @@ describe('worktree cleanup after executor completes (#1496)', () => {
|
||||
// A soft || { warn } fallback would silently swallow blocked cleanups.
|
||||
assert.match(
|
||||
content,
|
||||
/\$GSD_SDK query worktree\.cleanup-wave.*\|\| exit 1/,
|
||||
/gsd_run query worktree\.cleanup-wave.*\|\| exit 1/,
|
||||
'quick.md cleanup-wave must use || exit 1 — SDK safety refusals must surface (#3797)',
|
||||
);
|
||||
});
|
||||
@@ -535,8 +535,8 @@ describe('worktree merge: orchestrator file protection (#1756)', () => {
|
||||
// worktree.cleanup-wave handles STATE.md/ROADMAP.md backup + restore internally.
|
||||
assert.match(
|
||||
content,
|
||||
/\$GSD_SDK query worktree\.cleanup-wave --manifest "\$WAVE_WORKTREE_MANIFEST" \|\| exit 1/,
|
||||
'execute-phase.md must delegate to $GSD_SDK query worktree.cleanup-wave with || exit 1 (#3797)',
|
||||
/gsd_run query worktree\.cleanup-wave --manifest "\$WAVE_WORKTREE_MANIFEST" \|\| exit 1/,
|
||||
'execute-phase.md must delegate to gsd_run query worktree.cleanup-wave with || exit 1 (#3797)',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -570,8 +570,8 @@ describe('worktree merge: orchestrator file protection (#1756)', () => {
|
||||
// backup code — that is handled internally by worktree.cleanup-wave.
|
||||
assert.match(
|
||||
content,
|
||||
/\$GSD_SDK query worktree\.cleanup-wave --manifest "\$QUICK_WORKTREE_MANIFEST" \|\| exit 1/,
|
||||
'quick.md must delegate to $GSD_SDK query worktree.cleanup-wave with || exit 1 (#3797)',
|
||||
/gsd_run query worktree\.cleanup-wave --manifest "\$QUICK_WORKTREE_MANIFEST" \|\| exit 1/,
|
||||
'quick.md must delegate to gsd_run query worktree.cleanup-wave with || exit 1 (#3797)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -677,9 +677,9 @@ describe('bug #3384: worktree cleanup workflow contracts', () => {
|
||||
assert.match(content, /append its returned `\{agent_id, worktree_path, branch, expected_base\}`/);
|
||||
// After #3797 architectural fix: quick.md delegates entirely to the SDK's cleanup-wave
|
||||
// command (which handles manifest parsing internally). The shell fallback with manual
|
||||
// QUICK_WORKTREE_MANIFEST node-e code is removed — the SDK call with || exit 1 is the
|
||||
// QUICK_WORKTREE_MANIFEST node-e code is removed — the gsd_run call with || exit 1 is the
|
||||
// only cleanup path, enforcing safety-refusal semantics (#3174/#3384).
|
||||
assert.match(content, /\$GSD_SDK query worktree\.cleanup-wave --manifest "\$QUICK_WORKTREE_MANIFEST" \|\| exit 1/);
|
||||
assert.match(content, /gsd_run query worktree\.cleanup-wave --manifest "\$QUICK_WORKTREE_MANIFEST" \|\| exit 1/);
|
||||
assert.doesNotMatch(content, /done < <\(node -e 'const fs=require\("fs"\);const p=process\.env\.QUICK_WORKTREE_MANIFEST/);
|
||||
assert.doesNotMatch(content, /done < <\(git worktree list --porcelain \| grep "\^worktree " \| grep "\\\.claude\/worktrees\/agent-"/);
|
||||
});
|
||||
@@ -696,8 +696,8 @@ test('#3425: helper cleanup path pins orchestrator CWD to primary worktree and c
|
||||
assert.match(content, /cd "\$PRIMARY_WT" \|\| \{ echo "FATAL: cannot cd to primary worktree \$PRIMARY_WT" >&2; exit 1; \}/);
|
||||
assert.match(content, /ORCH_BRANCH=\$\(git rev-parse --abbrev-ref HEAD\)/);
|
||||
assert.match(content, /FATAL: orchestrator on '\$ORCH_BRANCH' but expected '\$EXPECTED_BRANCH' before worktree cleanup — refusing to merge \(#3174-class drift\)/);
|
||||
// After #3797 architectural fix, callsites use $GSD_SDK — accept either form
|
||||
assert.match(content, /(?:\$GSD_SDK|gsd-sdk) query worktree\.cleanup-wave --manifest "\$WAVE_WORKTREE_MANIFEST"/);
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
assert.match(content, /gsd_run query worktree\.cleanup-wave --manifest "\$WAVE_WORKTREE_MANIFEST"/);
|
||||
});
|
||||
|
||||
test('#3425: cleanup-tail snippet carries the same primary-worktree pin before removal', () => {
|
||||
|
||||
Reference in New Issue
Block a user