* chore(#604): rename get-shit-done/ runtime directory to gsd-core/ Renames the installed runtime directory `get-shit-done/` to `gsd-core/` so the on-disk name matches the package (`@opengsd/gsd-core`), repo, and binary (`gsd-tools`). The npm package name and binary are unchanged; npx/npm consumers are unaffected. Mechanical (bulk, ~90% of the diff): - `git mv get-shit-done gsd-core` - Swept path/identifier references across the repo via `perl -pe 's/get-shit-done(?!-\w)/gsd-core/g'`. The negative lookahead preserves the five legitimate slug variants that are NOT the directory: get-shit-done-{OLD,cc,classic,cli,redux} (old package/repo names). - Build/manifest wiring: package.json (bin, files, coverage globs), tsconfig.build.json (outDir), ~86 .gitignore build-output entries, stryker.config.mjs, scan-ignore files, install.js path strings. - Frozen (not rewritten): CHANGELOG.md history; translated docs (README.<locale>.md and docs/{ja-JP,ko-KR,pt-BR,zh-CN}/). New logic (review here): - src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts: a proper ADR-0008 installer migration. On upgrade it walks the legacy `~/.claude/get-shit-done/` tree, classifies each file via the prior install manifest, and emits remove-managed / backup-and-remove for managed files while PRESERVING unknown user-added files. Symlink-safe (skips a symlinked root and symlinked entries; bounds-checks every path under configDir). The framework rolls back on install failure. Emptied dirs may remain (framework has no recursive dir-removal primitive) — documented. - scripts/lint-legacy-dir-name.cjs: CI regression guard forbidding the bare `get-shit-done` directory token (split token to avoid self-match; case- insensitive; `(?!-\w)` lookahead allows the slug variants; allowlists CHANGELOG, translated docs, and `gsd-allow-legacy-name` marker lines). Wired into the lint-tests CI job. - Restored scripts/lint-package-identity-drift.cjs detection regexes (the mechanical sweep had wrongly rewritten the old-name patterns it exists to detect) and marked them as intentional legacy references. - TDD tests for the migration and the guard; do.md slash-command guard regex tightened so a `/gsd-core/bin` path segment is not mistaken for a command; changeset + docs/installer-migrations.md row added. Breaking: the installed runtime path moves `~/.claude/get-shit-done/` -> `~/.claude/gsd-core/`. Migration 003 removes the stale legacy dir's managed files (preserving user files) on upgrade. Users with custom hooks/configs hardcoding the old path must update them. Closes #604 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): unsweep pending changesets + allowlist injection-example docs CI fixes for the rename PR: - Do not sweep pending .changeset/*.md (ephemeral release-note fragments, like CHANGELOG); reverted those body edits so 5 pre-existing malformed fragments (missing type/pr) no longer enter the PR diff and trip docs-lint. Allowlisted .changeset/ in the legacy-name guard accordingly. - Allowlisted TEST-EXAMPLES.md and docs/explanation/security-model.md in prompt-injection-scan.sh: they contain intentional injection examples / security-model prose; the path-reference rewrites are kept. CodeQL alerts on this PR are pre-existing (alert lines unchanged by this PR; none in the new migration/guard) and are out of scope for the rename. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): resolve CodeQL alerts surfaced on this PR The rename diff touched files carrying pre-existing CodeQL findings; per the no-pre-existing-dismissal rule, fixing every surfaced alert rather than waving them off. All behavior-preserving: - scripts/ci-test-scope.cjs: build the config-path match from string .includes() instead of a RegExp over an arg-derived value (js/regex-injection). - src/profile-output.cts: escape backslashes before pipe-escaping desc/safeName so the table-cell escape is complete (js/incomplete-sanitization). - tests/{bug-2643,bug-2808,docs-parity-live-registry}: two-pass HTML-comment strip so a bare/unclosed `<!--` cannot survive (js/incomplete-multi-character-sanitization). - tests/inline-plan-threshold: drop the no-op `\s`->`\s` identity replace, keep the meaningful POSIX-class conversion (js/identity-replacement). Verified: build:lib green; the touched test files + ci-test-scope + profile-output suites pass; lint:legacy-name clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): correctly resolve remaining CodeQL alerts (regex-injection + sanitization) The prior commit's fixes for two alerts were ineffective: - ci-test-scope.cjs js/regex-injection: the alert is the CLI-arg-derived `file` reaching static regex `.test(file)` calls (not the config rule). Removed ALL regex over file/t — startsWith/includes/=== string checks + an isWindowsHint helper — so there is no regex sink for the tainted value. - js/incomplete-multi-character-sanitization (3 test files): a single `.replace(/<!--...-->/g,'')` can let `<!--` re-form. Replaced with a fixpoint loop (replace until stable) plus a final bare-opener strip. Verified: no regex over file/t remains; ci-test-scope + the 3 test suites pass; lint:legacy-name clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): make ci-test-scope + comment-strippers regex-free to clear CodeQL CodeQL flags the regex PATTERNS syntactically (regex-injection on the --files arg split; incomplete-multi-character-sanitization on the <!--...--> replace), so loop fixes do not satisfy it. Made these paths regex-free: - ci-test-scope.cjs splitFiles: char-by-char separator tokenizer (no /[,\\s]+/). - 3 test files: indexOf/slice HTML-comment stripper (no .replace(/<!--/)). Behavior preserved; ci-test-scope + the 3 suites pass; guard clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): unblock security base64 scan on the large rename diff The security job hit its 10m timeout: base64-scan.sh choked on the binary test fixture tests/feat-3594-parser-property-style.test.cjs (embedded NUL/ non-UTF8 bytes -> thousands of bogus blobs + "ignored null byte" warnings), and the ~800-file rename diff is slow to scan regardless. - scripts/base64-scan.sh: skip binary-by-content files (grep -Iq .) — they can't carry base64-obfuscated *text* and feeding NUL bytes through the per-line scanner is pathologically slow. collect_files already filtered binary *extensions*; this catches binary *content* in text extensions. - .github/workflows/security-scan.yml: raise the security job timeout 10m->30m to accommodate very large diffs (the scan itself is unchanged). Verified locally: scan skips the fixture, 0 "ignored null byte" warnings, 0 findings, exit 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): sweep get-shit-done refs introduced by merging next The branch was updated with next (#614/#384/#618 etc.), which reference the get-shit-done/ dir (still named that on next). Swept the stale references in the merged files to gsd-core so the rename stays consistent and lint:legacy-name passes: - commands/gsd/discuss-phase.md (runtime-launcher shim paths) - src/core.cts (getAgentsDir layout comments) - tests/bug-384-agents-runtime-aware.test.cjs (require path to runtime lib) Verified: guard 0 violations; build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): exclude gsd-core/ path segments from bug-3683 command cross-ref invariant The #614 runtime-launcher shim added to discuss-phase.md references `${_GSD_RUNTIME_ROOT}/gsd-core/bin/...`. bug-3683's REF_PATTERN excluded path-y refs only via lookbehind, but `}` precedes `/gsd-core/` in the shim, so it mis-read the directory path as a dangling `/gsd-core` command ref (same class as the #604 bug-2954 fix). Added a trailing `(?![\w-]*\/)` so `/gsd-<x>/...` path segments are not treated as slash-command references. Verified locally on BOTH platforms before pushing: - mac (node 26) full suite: 0 failures - gsd-test-runner (linux, node22 image) full suite: 0 failures - bug-3683 + bug-2954 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): lazily resolve findProjectRoot in gsd-tools (harden flaky CI) CI intermittently failed state.test's gsd-tools subprocess with "findProjectRoot is not a function" (flip-flopping across legs; not reproducible on mac full suite, gsd-test linux full suite, test:unit, or state.test x8). findProjectRoot is a re-export from core.cjs (sourced from project-root.cjs); binding it via destructure at module-load can be undefined under a load-ordering edge. Resolve it lazily at call time via a small wrapper so the lookup happens after core.cjs is fully initialized. Verified green on BOTH platforms before pushing: - mac (node 26) full suite: 0 failures - gsd-test-runner (linux, node22) full suite: 0 failures - state.test.cjs: 106/106; gsd-tools loads cleanly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): allowlist verification-patterns.md placeholder examples in secret scan The rename git-mv'd references/verification-patterns.md into gsd-core/, pulling it into the secret-scan diff. It documents stub/placeholder RED-FLAG env-var examples (illustrative Stripe test-key / database-URL / API-key placeholders) — not real credentials. Added it to .secretscanignore with the strict annotation, mirroring the existing gsd-core/workflows/plan-phase.md exception. Verified locally: secret-scan-lint --strict OK; secret-scan --diff origin/next exits 0 with 0 findings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
852 lines
35 KiB
JavaScript
852 lines
35 KiB
JavaScript
// allow-test-rule: source-text-is-the-product
|
|
// Workflow markdown is the installed orchestration contract.
|
|
|
|
'use strict';
|
|
|
|
/**
|
|
* Worktree Lifecycle Module — branch-check and workspace-safety tests
|
|
*
|
|
* Seam: gsd-core/workflows/{execute-phase,execute-plan,quick}.md,
|
|
* agents/gsd-executor.md
|
|
*
|
|
* Split from the consolidated 13→2 worktree cluster (≤800 LOC/file):
|
|
* - tests/bug-2015-worktree-base-branch.test.cjs (#2015: reset --hard)
|
|
* - tests/bug-2075-worktree-deletion-safeguards.test.cjs (#2075: git clean prohibition)
|
|
* - tests/bug-2431-worktree-locked-surfacing.test.cjs (#2431: locked-worktree errors)
|
|
* - tests/bug-2774-worktree-cleanup-workspace-safety.test.cjs (#2774: discovery pipeline)
|
|
*
|
|
* See also: worktree-cleanup.test.cjs (#2924, #1496, #1756, #1977, #1511, #3384, #3425)
|
|
* worktree-safety.test.cjs (safety function unit tests)
|
|
*/
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { execSync } = require('child_process');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'execute-phase.md');
|
|
const EXECUTE_PLAN_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'execute-plan.md');
|
|
const QUICK_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'quick.md');
|
|
const EXECUTOR_AGENT_PATH = path.join(REPO_ROOT, 'agents', 'gsd-executor.md');
|
|
const DIAGNOSE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'diagnose-issues.md');
|
|
const GIT_INTEGRATION_PATH = path.join(REPO_ROOT, 'gsd-core', 'references', 'git-integration.md');
|
|
const WORKTREE_BRANCH_CHECK_FRAGMENT = path.join(REPO_ROOT, 'gsd-core', 'references', 'worktree-branch-check.md');
|
|
|
|
const isWindows = process.platform === 'win32';
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function extractNamedBlock(markdown, blockName) {
|
|
const open = `<${blockName}>`;
|
|
const close = `</${blockName}>`;
|
|
const start = markdown.indexOf(open);
|
|
if (start === -1) return null;
|
|
const end = markdown.indexOf(close, start + open.length);
|
|
if (end === -1) return null;
|
|
return markdown.slice(start + open.length, end);
|
|
}
|
|
|
|
/**
|
|
* Extract all fenced code blocks (```...```) from a markdown chunk.
|
|
* Returns array of { lang, body } objects.
|
|
*/
|
|
function extractFencedCodeBlocks(markdown) {
|
|
const blocks = [];
|
|
const lines = markdown.split('\n');
|
|
let inFence = false;
|
|
let fenceLang = '';
|
|
let buffer = [];
|
|
for (const line of lines) {
|
|
const trimmed = line.trimStart();
|
|
if (trimmed.startsWith('```')) {
|
|
if (!inFence) {
|
|
inFence = true;
|
|
fenceLang = trimmed.slice(3).trim();
|
|
buffer = [];
|
|
} else {
|
|
blocks.push({ lang: fenceLang, body: buffer.join('\n') });
|
|
inFence = false;
|
|
fenceLang = '';
|
|
buffer = [];
|
|
}
|
|
} else if (inFence) {
|
|
buffer.push(line);
|
|
}
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
/**
|
|
* Tokenize a shell-like script into individual statements (split on `;`, `&&`, `||`, newlines)
|
|
* and return commands as arrays of word tokens. Handles `$(cmd ...)` command substitution
|
|
* and `VAR=$(cmd ...)` assignments by extracting the inner command. This is intentionally
|
|
* simple — adequate for asserting on the presence of well-known git invocations.
|
|
*/
|
|
function shellStatements(script) {
|
|
const statements = [];
|
|
const lines = script.split('\n');
|
|
for (let raw of lines) {
|
|
const line = raw.replace(/#.*$/, '').trim();
|
|
if (!line) continue;
|
|
// Split on shell statement separators
|
|
const parts = line.split(/(?:&&|\|\||;)/);
|
|
for (const part of parts) {
|
|
let trimmed = part.trim();
|
|
if (!trimmed) continue;
|
|
// Strip leading `VAR=` assignments so the substituted command surfaces as cmd[0].
|
|
// Then unwrap `$(...)` command substitution.
|
|
const assignMatch = trimmed.match(/^[A-Za-z_][A-Za-z0-9_]*=(.*)$/);
|
|
if (assignMatch) trimmed = assignMatch[1];
|
|
const subMatch = trimmed.match(/^\$\((.*?)\)?$/);
|
|
if (subMatch) trimmed = subMatch[1];
|
|
// Also handle leading `$(` without closing paren (paren may have been split off)
|
|
if (trimmed.startsWith('$(')) trimmed = trimmed.slice(2);
|
|
// Strip trailing closing parens left over from substitution
|
|
trimmed = trimmed.replace(/\)+\s*$/, '').trim();
|
|
if (!trimmed) continue;
|
|
// Strip surrounding quotes on the leading word
|
|
statements.push(trimmed.split(/\s+/).filter(Boolean));
|
|
}
|
|
}
|
|
return statements;
|
|
}
|
|
|
|
/**
|
|
* Find the line index of the first command matching a predicate.
|
|
* Returns -1 when not found.
|
|
*/
|
|
function findCommandIndex(statements, predicate) {
|
|
for (let i = 0; i < statements.length; i++) {
|
|
if (predicate(statements[i])) return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
|
|
// ─── Canonical fragment: single source of truth ─────────────────────────────
|
|
|
|
describe('canonical worktree-branch-check fragment is the single source of truth', () => {
|
|
const fragmentContent = fs.readFileSync(WORKTREE_BRANCH_CHECK_FRAGMENT, 'utf-8');
|
|
const blockMatch = fragmentContent.match(/<worktree_branch_check>([\s\S]*?)<\/worktree_branch_check>/);
|
|
const block = blockMatch ? blockMatch[1] : '';
|
|
|
|
test('fragment file exists and contains a <worktree_branch_check> block', () => {
|
|
assert.ok(blockMatch, 'worktree-branch-check.md must contain a <worktree_branch_check> block');
|
|
});
|
|
|
|
test('fragment block is verify-only (no git reset)', () => {
|
|
assert.ok(!/git\s+reset/.test(block), 'fragment block must NOT run git reset — orchestrator owns base recovery (#48)');
|
|
});
|
|
|
|
test('fragment block does NOT contain reset --soft', () => {
|
|
assert.ok(!block.includes('reset --soft'), 'fragment block must not use reset --soft');
|
|
});
|
|
|
|
test('fragment block protected-ref alternation contains main', () => {
|
|
assert.ok(/\bmain\b/.test(block), 'fragment protected-ref alternation must include main');
|
|
});
|
|
|
|
test('fragment block protected-ref alternation contains master', () => {
|
|
assert.ok(/\bmaster\b/.test(block), 'fragment protected-ref alternation must include master');
|
|
});
|
|
|
|
test('fragment block protected-ref alternation contains develop', () => {
|
|
assert.ok(/\bdevelop\b/.test(block), 'fragment protected-ref alternation must include develop');
|
|
});
|
|
|
|
test('fragment block protected-ref alternation contains trunk', () => {
|
|
assert.ok(/\btrunk\b/.test(block), 'fragment protected-ref alternation must include trunk');
|
|
});
|
|
|
|
test('fragment block protected-ref alternation contains release', () => {
|
|
assert.ok(/\brelease\b/.test(block), 'fragment protected-ref alternation must include release');
|
|
});
|
|
|
|
test('fragment block positive allow-list matches ^worktree-agent- pattern', () => {
|
|
const allowListRe = /grep\s+-Eq?\s+'\^worktree-agent-/;
|
|
assert.ok(allowListRe.test(block), 'fragment block must enforce a positive allow-list matching ^worktree-agent-');
|
|
});
|
|
|
|
test('fragment block contains update-ref prohibition text', () => {
|
|
assert.ok(block.includes('update-ref'), 'fragment block must reference update-ref prohibition');
|
|
});
|
|
|
|
test('fragment block asserts exact base and fails closed with exit 42 (#48)', () => {
|
|
assert.ok(block.includes('git rev-parse HEAD') && block.includes('{EXPECTED_BASE}'), 'fragment must assert HEAD equals {EXPECTED_BASE} exactly (#48)');
|
|
assert.ok(/exit 42/.test(block), 'fragment must fail closed with exit 42 on mismatch (#48)');
|
|
});
|
|
});
|
|
|
|
// ─── #48: execute-plan mandate is verify-only ───────────────────────────────
|
|
|
|
describe('bug #48: execute-plan.md worktree mandate is verify-only', () => {
|
|
test('execute-plan.md worktree mandate is verify-only (no reset --hard self-recovery) (#48)', () => {
|
|
const content = fs.readFileSync(EXECUTE_PLAN_PATH, 'utf-8');
|
|
assert.ok(content.includes('worktree-branch-check.md'), 'execute-plan.md must reference the canonical fragment');
|
|
assert.ok(!/hard-reset/.test(content) && !/reset --hard/.test(content), 'execute-plan.md must not describe reset --hard self-recovery — verify-only per #48');
|
|
assert.ok(/exit 42/.test(content), 'execute-plan.md mandate must specify fail-closed exit 42 (#48)');
|
|
});
|
|
});
|
|
|
|
const DISCOVERY_PIPELINE =
|
|
'grep "^worktree " | grep "\\.claude/worktrees/agent-" | sed \'s/^worktree //\'';
|
|
|
|
function runDiscoveryAgainstFixture(porcelain) {
|
|
const out = execSync(DISCOVERY_PIPELINE, {
|
|
input: porcelain,
|
|
encoding: 'utf-8',
|
|
});
|
|
return out.split('\n').filter((l) => l.length > 0);
|
|
}
|
|
|
|
function runDiscoveryAgainstRepo(repoCwd) {
|
|
const out = execSync(
|
|
`git worktree list --porcelain | ${DISCOVERY_PIPELINE}`,
|
|
{ cwd: repoCwd, encoding: 'utf-8' }
|
|
);
|
|
return out.split('\n').filter((l) => l.length > 0);
|
|
}
|
|
|
|
function makeTempUpstreamRepo(prefix) {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
execSync('git init -b main', { cwd: tmpDir, stdio: 'pipe' });
|
|
execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'pipe' });
|
|
execSync('git config user.name "Test"', { cwd: tmpDir, stdio: 'pipe' });
|
|
execSync('git config commit.gpgsign false', { cwd: tmpDir, stdio: 'pipe' });
|
|
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# upstream\n');
|
|
execSync('git add -A', { cwd: tmpDir, stdio: 'pipe' });
|
|
execSync('git commit -m "initial"', { cwd: tmpDir, stdio: 'pipe' });
|
|
return tmpDir;
|
|
}
|
|
|
|
// ─── #2015: reset --hard not --soft ─────────────────────────────────────────
|
|
|
|
describe('verify-only: worktree_branch_check must NOT run git reset (#48, supersedes #2015)', () => {
|
|
|
|
test('execute-phase.md worktree_branch_check does not use reset --soft', () => {
|
|
const fragmentContent = fs.readFileSync(WORKTREE_BRANCH_CHECK_FRAGMENT, 'utf-8');
|
|
|
|
// Extract the worktree_branch_check block from the canonical fragment
|
|
const blockMatch = fragmentContent.match(/<worktree_branch_check>([\s\S]*?)<\/worktree_branch_check>/);
|
|
assert.ok(blockMatch, 'worktree-branch-check.md must contain a <worktree_branch_check> block');
|
|
|
|
const block = blockMatch[1];
|
|
assert.ok(
|
|
!block.includes('reset --soft'),
|
|
'worktree_branch_check must not use reset --soft (leaves working tree files unchanged).'
|
|
);
|
|
});
|
|
|
|
test('verify-only: execute-phase.md worktree_branch_check must not run git reset at all (#48, supersedes #2015)', () => {
|
|
const fragmentContent = fs.readFileSync(WORKTREE_BRANCH_CHECK_FRAGMENT, 'utf-8');
|
|
const blockMatch = fragmentContent.match(/<worktree_branch_check>([\s\S]*?)<\/worktree_branch_check>/);
|
|
assert.ok(blockMatch, 'worktree-branch-check.md must contain a <worktree_branch_check> block');
|
|
|
|
const block = blockMatch[1];
|
|
assert.ok(
|
|
!/git\s+reset/.test(block),
|
|
'worktree_branch_check must NOT run git reset — orchestrator owns base recovery (#48, supersedes #2015)'
|
|
);
|
|
});
|
|
|
|
test('quick.md worktree_branch_check does not use reset --soft', () => {
|
|
const fragmentContent = fs.readFileSync(WORKTREE_BRANCH_CHECK_FRAGMENT, 'utf-8');
|
|
const blockMatch = fragmentContent.match(/<worktree_branch_check>([\s\S]*?)<\/worktree_branch_check>/);
|
|
assert.ok(blockMatch, 'worktree-branch-check.md must contain a <worktree_branch_check> block');
|
|
|
|
const block = blockMatch[1];
|
|
assert.ok(
|
|
!block.includes('reset --soft'),
|
|
'quick.md worktree_branch_check must not use reset --soft.'
|
|
);
|
|
});
|
|
|
|
test('verify-only: quick.md worktree_branch_check must not run git reset at all (#48, supersedes #2015)', () => {
|
|
const fragmentContent = fs.readFileSync(WORKTREE_BRANCH_CHECK_FRAGMENT, 'utf-8');
|
|
const blockMatch = fragmentContent.match(/<worktree_branch_check>([\s\S]*?)<\/worktree_branch_check>/);
|
|
assert.ok(blockMatch, 'worktree-branch-check.md must contain a <worktree_branch_check> block');
|
|
|
|
const block = blockMatch[1];
|
|
assert.ok(
|
|
!/git\s+reset/.test(block),
|
|
'quick.md worktree_branch_check must NOT run git reset — orchestrator owns base recovery (#48, supersedes #2015)'
|
|
);
|
|
});
|
|
|
|
test('execute-phase.md references the canonical fragment', () => {
|
|
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
|
assert.ok(
|
|
content.includes('worktree-branch-check.md'),
|
|
'execute-phase.md must reference the canonical worktree-branch-check.md fragment'
|
|
);
|
|
});
|
|
|
|
test('quick.md references the canonical fragment', () => {
|
|
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
|
|
assert.ok(
|
|
content.includes('worktree-branch-check.md'),
|
|
'quick.md must reference the canonical worktree-branch-check.md fragment'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── #2075: worktree deletion safeguards ────────────────────────────────────
|
|
|
|
describe('bug-2075: worktree deletion safeguards', () => {
|
|
|
|
describe('Failure Mode B: git clean prohibition in executor agent', () => {
|
|
test('gsd-executor.md explicitly prohibits git clean in worktree context', () => {
|
|
const content = fs.readFileSync(EXECUTOR_AGENT_PATH, 'utf-8');
|
|
|
|
// Must have an explicit prohibition section mentioning git clean
|
|
const prohibitsGitClean = (
|
|
content.includes('git clean') &&
|
|
(
|
|
/NEVER.*git clean/i.test(content) ||
|
|
/git clean.*NEVER/i.test(content) ||
|
|
/do not.*git clean/i.test(content) ||
|
|
/git clean.*prohibited/i.test(content) ||
|
|
/prohibited.*git clean/i.test(content) ||
|
|
/forbidden.*git clean/i.test(content) ||
|
|
/git clean.*forbidden/i.test(content) ||
|
|
/must not.*git clean/i.test(content) ||
|
|
/git clean.*must not/i.test(content)
|
|
)
|
|
);
|
|
|
|
assert.ok(
|
|
prohibitsGitClean,
|
|
'gsd-executor.md must explicitly prohibit git clean — running it inside a worktree deletes files committed on the feature branch (#2075 Failure Mode B)'
|
|
);
|
|
});
|
|
|
|
test('gsd-executor.md git clean prohibition explains the worktree data-loss risk', () => {
|
|
const content = fs.readFileSync(EXECUTOR_AGENT_PATH, 'utf-8');
|
|
|
|
// The prohibition must be accompanied by a reason — not just a bare rule
|
|
// Look for the word "worktree" near the git clean prohibition
|
|
const gitCleanIdx = content.indexOf('git clean');
|
|
assert.ok(gitCleanIdx > -1, 'gsd-executor.md must mention git clean (to prohibit it)');
|
|
|
|
// Extract context around the git clean mention (500 chars either side)
|
|
const contextStart = Math.max(0, gitCleanIdx - 500);
|
|
const contextEnd = Math.min(content.length, gitCleanIdx + 500);
|
|
const context = content.slice(contextStart, contextEnd);
|
|
|
|
const hasWorktreeRationale = (
|
|
/worktree/i.test(context) ||
|
|
/delete/i.test(context) ||
|
|
/untracked/i.test(context)
|
|
);
|
|
|
|
assert.ok(
|
|
hasWorktreeRationale,
|
|
'The git clean prohibition in gsd-executor.md must explain why: git clean in a worktree deletes files that appear untracked but are committed on the feature branch'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Failure Mode A: worktree_branch_check audit across all worktree-spawning workflows', () => {
|
|
test('execute-phase.md has worktree_branch_check block (verify-only, no git reset) (#48)', () => {
|
|
const executePhaseContent = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
|
assert.ok(
|
|
executePhaseContent.includes('worktree-branch-check.md'),
|
|
'execute-phase.md must reference the canonical worktree-branch-check.md fragment'
|
|
);
|
|
|
|
const fragmentContent = fs.readFileSync(WORKTREE_BRANCH_CHECK_FRAGMENT, 'utf-8');
|
|
const blockMatch = fragmentContent.match(/<worktree_branch_check>([\s\S]*?)<\/worktree_branch_check>/);
|
|
assert.ok(
|
|
blockMatch,
|
|
'worktree-branch-check.md must contain a <worktree_branch_check> block'
|
|
);
|
|
|
|
const block = blockMatch[1];
|
|
assert.ok(
|
|
!/git\s+reset/.test(block),
|
|
'execute-phase.md worktree_branch_check must NOT run git reset — verify-only per #48'
|
|
);
|
|
assert.ok(
|
|
!block.includes('reset --soft'),
|
|
'execute-phase.md worktree_branch_check must not use git reset --soft'
|
|
);
|
|
});
|
|
|
|
test('quick.md has worktree_branch_check block (verify-only, no git reset) (#48)', () => {
|
|
const quickContent = fs.readFileSync(QUICK_PATH, 'utf-8');
|
|
assert.ok(
|
|
quickContent.includes('worktree-branch-check.md'),
|
|
'quick.md must reference the canonical worktree-branch-check.md fragment'
|
|
);
|
|
|
|
const fragmentContent = fs.readFileSync(WORKTREE_BRANCH_CHECK_FRAGMENT, 'utf-8');
|
|
const blockMatch = fragmentContent.match(/<worktree_branch_check>([\s\S]*?)<\/worktree_branch_check>/);
|
|
assert.ok(
|
|
blockMatch,
|
|
'worktree-branch-check.md must contain a <worktree_branch_check> block'
|
|
);
|
|
|
|
const block = blockMatch[1];
|
|
assert.ok(
|
|
!/git\s+reset/.test(block),
|
|
'quick.md worktree_branch_check must NOT run git reset — verify-only per #48'
|
|
);
|
|
assert.ok(
|
|
!block.includes('reset --soft'),
|
|
'quick.md worktree_branch_check must not use git reset --soft'
|
|
);
|
|
});
|
|
|
|
test('diagnose-issues.md has worktree_branch_check instruction for spawned agents', () => {
|
|
const diagnoseContent = fs.readFileSync(DIAGNOSE_PATH, 'utf-8');
|
|
assert.ok(
|
|
diagnoseContent.includes('worktree-branch-check.md'),
|
|
'diagnose-issues.md must reference the canonical worktree-branch-check.md fragment for spawned debug agents'
|
|
);
|
|
|
|
const fragmentContent = fs.readFileSync(WORKTREE_BRANCH_CHECK_FRAGMENT, 'utf-8');
|
|
const blockMatch = fragmentContent.match(/<worktree_branch_check>([\s\S]*?)<\/worktree_branch_check>/);
|
|
assert.ok(blockMatch, 'worktree-branch-check.md must contain a <worktree_branch_check> block');
|
|
const block = blockMatch[1];
|
|
assert.ok(
|
|
!/git\s+reset/.test(block),
|
|
'diagnose-issues.md worktree_branch_check must NOT run git reset — verify-only per #48'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Defense-in-depth: post-commit deletion check (from #1977)', () => {
|
|
test('gsd-executor.md task_commit_protocol has post-commit deletion verification', () => {
|
|
const content = fs.readFileSync(EXECUTOR_AGENT_PATH, 'utf-8');
|
|
|
|
assert.ok(
|
|
content.includes('--diff-filter=D'),
|
|
'gsd-executor.md must include --diff-filter=D to detect accidental file deletions after each commit'
|
|
);
|
|
|
|
// Must have a warning about unexpected deletions
|
|
assert.ok(
|
|
content.includes('DELETIONS') || content.includes('WARNING'),
|
|
'gsd-executor.md must emit a warning when a commit includes unexpected file deletions'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Defense-in-depth: pre-merge deletion check (from #1977)', () => {
|
|
test('execute-phase.md worktree merge section has pre-merge deletion check', () => {
|
|
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
|
|
|
const worktreeCleanupStart = content.indexOf('Worktree cleanup');
|
|
assert.ok(
|
|
worktreeCleanupStart > -1,
|
|
'execute-phase.md must have a worktree cleanup section'
|
|
);
|
|
|
|
const cleanupSection = content.slice(worktreeCleanupStart);
|
|
|
|
// After #3797 architectural fix: deletion check is handled by SDK worktree.cleanup-wave.
|
|
// Accept either (a) --diff-filter=D inline OR (b) SDK delegation with deletion mention.
|
|
const hasInlineDiffFilterCheck = cleanupSection.includes('--diff-filter=D');
|
|
const hasSdkDelegationWithDeletionMention = (
|
|
cleanupSection.includes('worktree.cleanup-wave') &&
|
|
(cleanupSection.includes('deletion') || cleanupSection.includes('BLOCKED'))
|
|
);
|
|
assert.ok(
|
|
hasInlineDiffFilterCheck || hasSdkDelegationWithDeletionMention,
|
|
'execute-phase.md cleanup section must either include --diff-filter=D or delegate to SDK (worktree.cleanup-wave) with documented deletion-diff validation (#2384/#3797)',
|
|
);
|
|
});
|
|
|
|
test('quick.md worktree merge section has pre-merge deletion check', () => {
|
|
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
|
|
|
|
// Find the worktree cleanup block (starts after "Worktree cleanup")
|
|
const worktreeCleanupStart = content.indexOf('Worktree cleanup');
|
|
assert.ok(
|
|
worktreeCleanupStart > -1,
|
|
'quick.md must have a worktree cleanup section'
|
|
);
|
|
|
|
const cleanupSection = content.slice(worktreeCleanupStart);
|
|
|
|
// After #3797 architectural fix: deletion check is handled by SDK worktree.cleanup-wave.
|
|
// Accept either (a) --diff-filter=D / diff-filter inline OR (b) SDK delegation with deletion mention.
|
|
const hasInlineDiffFilterCheck = (
|
|
cleanupSection.includes('--diff-filter=D') || cleanupSection.includes('diff-filter')
|
|
);
|
|
const hasSdkDelegationWithDeletionMention = (
|
|
cleanupSection.includes('worktree.cleanup-wave') &&
|
|
(cleanupSection.includes('deletion') || cleanupSection.includes('BLOCKED'))
|
|
);
|
|
assert.ok(
|
|
hasInlineDiffFilterCheck || hasSdkDelegationWithDeletionMention,
|
|
'quick.md cleanup section must either check for file deletions inline or delegate to SDK (worktree.cleanup-wave) with documented deletion-diff validation (#2384/#3797)',
|
|
);
|
|
});
|
|
});
|
|
|
|
});
|
|
|
|
// ─── #2431: locked-worktree error surfacing ──────────────────────────────────
|
|
|
|
describe('bug-2431: worktree teardown must surface locked-worktree errors', () => {
|
|
test('quick.md exists', () => {
|
|
assert.ok(fs.existsSync(QUICK_PATH), 'quick.md should exist');
|
|
});
|
|
|
|
test('execute-phase.md exists', () => {
|
|
assert.ok(fs.existsSync(EXECUTE_PHASE_PATH), 'execute-phase.md should exist');
|
|
});
|
|
|
|
test('quick.md: no silent worktree remove pattern', () => {
|
|
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
|
|
const silentRemovePattern = /git worktree remove[^\n]*--force\s+2>\/dev\/null\s*\|\|\s*true/;
|
|
assert.ok(!silentRemovePattern.test(content), 'quick.md: must not contain silent git worktree remove pattern');
|
|
});
|
|
|
|
test('execute-phase.md: no silent worktree remove pattern', () => {
|
|
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
|
const silentRemovePattern = /git worktree remove[^\n]*--force\s+2>\/dev\/null\s*\|\|\s*true/;
|
|
assert.ok(!silentRemovePattern.test(content), 'execute-phase.md: must not contain silent git worktree remove pattern');
|
|
});
|
|
|
|
test('quick.md: has lock-aware detection block', () => {
|
|
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
|
|
// After #3797 architectural fix: quick.md delegates entirely to SDK worktree.cleanup-wave,
|
|
// which handles lock detection internally. Accept either inline .git/worktrees/.../locked
|
|
// check OR SDK delegation (the SDK documents locked-worktree handling).
|
|
const hasInlineLockCheck = content.includes('.git/worktrees/') && content.includes('locked');
|
|
const hasSdkDelegation = content.includes('worktree.cleanup-wave');
|
|
assert.ok(
|
|
hasInlineLockCheck || hasSdkDelegation,
|
|
'quick.md: must include lock-aware detection (.git/worktrees/.../locked check) or delegate to SDK worktree.cleanup-wave (#2431/#3797)'
|
|
);
|
|
});
|
|
|
|
test('execute-phase.md: has lock-aware detection block', () => {
|
|
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
|
assert.ok(
|
|
content.includes('.git/worktrees/') && content.includes('locked'),
|
|
'execute-phase.md: must include lock-aware detection'
|
|
);
|
|
});
|
|
|
|
test('quick.md: has git worktree unlock retry', () => {
|
|
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
|
|
// After #3797 architectural fix: quick.md delegates entirely to SDK worktree.cleanup-wave.
|
|
// Accept either inline "git worktree unlock" OR SDK delegation.
|
|
const hasInlineUnlock = content.includes('git worktree unlock');
|
|
const hasSdkDelegation = content.includes('worktree.cleanup-wave');
|
|
assert.ok(
|
|
hasInlineUnlock || hasSdkDelegation,
|
|
'quick.md: must include "git worktree unlock" retry attempt or delegate to SDK worktree.cleanup-wave (#2431/#3797)'
|
|
);
|
|
});
|
|
|
|
test('execute-phase.md: has git worktree unlock retry', () => {
|
|
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
|
assert.ok(content.includes('git worktree unlock'), 'execute-phase.md: must include "git worktree unlock" retry attempt');
|
|
});
|
|
|
|
test('quick.md: has user-visible warning on residual worktree', () => {
|
|
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
|
|
// After #3797 architectural fix: quick.md delegates entirely to SDK worktree.cleanup-wave,
|
|
// which surfaces residual worktree warnings internally. Accept either inline warning
|
|
// OR SDK delegation.
|
|
const hasInlineWarning = content.includes('Residual worktree') || content.includes('manual cleanup');
|
|
const hasSdkDelegation = content.includes('worktree.cleanup-wave');
|
|
assert.ok(
|
|
hasInlineWarning || hasSdkDelegation,
|
|
'quick.md: must include user-visible warning when worktree removal fails, or delegate to SDK worktree.cleanup-wave (#2431/#3797)'
|
|
);
|
|
});
|
|
|
|
test('execute-phase.md: has user-visible warning on residual worktree', () => {
|
|
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
|
assert.ok(
|
|
content.includes('Residual worktree') || content.includes('manual cleanup'),
|
|
'execute-phase.md: must include user-visible warning when worktree removal fails'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── #2774: cleanup pipeline workspace safety ────────────────────────────────
|
|
|
|
describe('bug #2774 — worktree cleanup pipeline must not target the parent workspace', () => {
|
|
describe('discovery pipeline (unit)', () => {
|
|
test('selects only the agent worktree when workspace itself is a worktree', () => {
|
|
// Fixture mirrors the multi-workspace setup: upstream main + sibling
|
|
// workspace worktree + agent worktree under workspace's
|
|
// `.claude/worktrees/agent-` namespace.
|
|
const porcelain = [
|
|
'worktree /Users/dev/upstream/gsd-core',
|
|
'HEAD abc123',
|
|
'branch refs/heads/main',
|
|
'',
|
|
'worktree /Users/dev/workspaces/feature-x',
|
|
'HEAD def456',
|
|
'branch refs/heads/workspace/feature-x',
|
|
'',
|
|
'worktree /Users/dev/workspaces/feature-x/.claude/worktrees/agent-deadbeef',
|
|
'HEAD 789abc',
|
|
'branch refs/heads/worktree-agent-deadbeef',
|
|
'',
|
|
].join('\n');
|
|
|
|
const discovered = runDiscoveryAgainstFixture(porcelain);
|
|
|
|
assert.deepEqual(
|
|
discovered,
|
|
['/Users/dev/workspaces/feature-x/.claude/worktrees/agent-deadbeef'],
|
|
'pipeline must select only the agent-spawned worktree, never the ' +
|
|
'workspace or upstream main repo'
|
|
);
|
|
});
|
|
|
|
test('selects nothing when no agent worktrees exist', () => {
|
|
const porcelain = [
|
|
'worktree /Users/dev/upstream/gsd-core',
|
|
'HEAD abc123',
|
|
'branch refs/heads/main',
|
|
'',
|
|
'worktree /Users/dev/workspaces/feature-x',
|
|
'HEAD def456',
|
|
'branch refs/heads/workspace/feature-x',
|
|
'',
|
|
].join('\n');
|
|
|
|
const discovered = runDiscoveryAgainstFixture(porcelain);
|
|
|
|
assert.deepEqual(discovered, []);
|
|
});
|
|
|
|
test('selects multiple agent worktrees and excludes non-agent paths', () => {
|
|
const porcelain = [
|
|
'worktree /repo/main',
|
|
'HEAD a',
|
|
'branch refs/heads/main',
|
|
'',
|
|
'worktree /repo/main/.claude/worktrees/agent-aaa',
|
|
'HEAD b',
|
|
'branch refs/heads/agent-aaa',
|
|
'',
|
|
'worktree /repo/main/.claude/worktrees/agent-bbb',
|
|
'HEAD c',
|
|
'branch refs/heads/agent-bbb',
|
|
'',
|
|
'worktree /repo/main/some-other-dir',
|
|
'HEAD d',
|
|
'branch refs/heads/feature',
|
|
'',
|
|
].join('\n');
|
|
|
|
const discovered = runDiscoveryAgainstFixture(porcelain);
|
|
|
|
assert.deepEqual(discovered.sort(), [
|
|
'/repo/main/.claude/worktrees/agent-aaa',
|
|
'/repo/main/.claude/worktrees/agent-bbb',
|
|
]);
|
|
});
|
|
|
|
test('selects agent worktree even when path contains whitespace', () => {
|
|
// Regression for CodeRabbit feedback on PR #2778: `for WT in $WORKTREES`
|
|
// splits on whitespace and would emit broken half-paths like
|
|
// "/Users/dev/My" and "Workspace/.claude/worktrees/agent-xyz". The
|
|
// pipeline output itself is line-delimited and preserves the full path —
|
|
// the workflow's loop must consume it line-by-line via `while IFS= read`.
|
|
const porcelain = [
|
|
'worktree /Users/dev/My Workspace',
|
|
'HEAD def456',
|
|
'branch refs/heads/workspace/feature-x',
|
|
'',
|
|
'worktree /Users/dev/My Workspace/.claude/worktrees/agent-deadbeef',
|
|
'HEAD 789abc',
|
|
'branch refs/heads/worktree-agent-deadbeef',
|
|
'',
|
|
].join('\n');
|
|
|
|
const discovered = runDiscoveryAgainstFixture(porcelain);
|
|
|
|
assert.deepEqual(
|
|
discovered,
|
|
['/Users/dev/My Workspace/.claude/worktrees/agent-deadbeef'],
|
|
'pipeline output must preserve whitespace-bearing agent worktree path on a single line'
|
|
);
|
|
});
|
|
|
|
test('while/read loop iterates each whitespace-bearing path exactly once',
|
|
{ skip: isWindows ? 'POSIX bash process-substitution `< <(...)` under test; not portable to cmd.exe / git-bash variance' : false },
|
|
() => {
|
|
// Verify the actual consumer pattern from quick.md / execute-phase.md:
|
|
// while IFS= read -r WT; do ...; done < <(<pipeline>)
|
|
// Counts the lines yielded to the loop body. With the previous
|
|
// `for WT in $WORKTREES` form, a path containing one space would yield
|
|
// 2 iterations (broken halves). The `while/read` form yields exactly 1.
|
|
const porcelain = [
|
|
'worktree /tmp/has space/.claude/worktrees/agent-aaa',
|
|
'HEAD a',
|
|
'branch refs/heads/agent-aaa',
|
|
'',
|
|
'worktree /tmp/two spaces/.claude/worktrees/agent-bbb',
|
|
'HEAD b',
|
|
'branch refs/heads/agent-bbb',
|
|
'',
|
|
].join('\n');
|
|
|
|
// Mirror the workflow's loop verbatim. Print one line per iteration with
|
|
// a sentinel so we can count and inspect what the loop actually saw.
|
|
const script = `
|
|
while IFS= read -r WT; do
|
|
[ -z "$WT" ] && continue
|
|
printf 'ITER:%s\\n' "$WT"
|
|
done < <(${DISCOVERY_PIPELINE})
|
|
`;
|
|
// bash needed for process substitution `< <(...)`.
|
|
const out = execSync(`bash -c '${script.replace(/'/g, `'\\''`)}'`, {
|
|
input: porcelain,
|
|
encoding: 'utf-8',
|
|
});
|
|
const iterations = out
|
|
.split('\n')
|
|
.filter((l) => l.startsWith('ITER:'))
|
|
.map((l) => l.slice('ITER:'.length));
|
|
|
|
assert.deepEqual(
|
|
iterations,
|
|
[
|
|
'/tmp/has space/.claude/worktrees/agent-aaa',
|
|
'/tmp/two spaces/.claude/worktrees/agent-bbb',
|
|
],
|
|
'while/read loop must yield exactly one iteration per worktree, with whitespace preserved'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('end-to-end against real git worktrees',
|
|
{ skip: isWindows ? 'POSIX shell discovery pipeline under test + Windows 8.3 short-name (RUNNER~1) vs long-name path mismatch in temp dirs' : false },
|
|
() => {
|
|
let upstream;
|
|
let workspace;
|
|
let agentWorktree;
|
|
let workspacesParent;
|
|
|
|
beforeEach(() => {
|
|
// Build the multi-worktree scenario from #2774:
|
|
// upstream/ <- main repo
|
|
// workspace/ <- worktree of upstream (the "workspace")
|
|
// workspace/.claude/worktrees/agent-XXXX/ <- agent worktree
|
|
upstream = makeTempUpstreamRepo('gsd-2774-upstream-');
|
|
|
|
workspacesParent = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'gsd-2774-workspaces-')
|
|
);
|
|
workspace = path.join(workspacesParent, 'feature-x');
|
|
execSync(`git worktree add -b workspace/feature-x "${workspace}"`, {
|
|
cwd: upstream,
|
|
stdio: 'pipe',
|
|
});
|
|
|
|
const agentDir = path.join(workspace, '.claude', 'worktrees');
|
|
fs.mkdirSync(agentDir, { recursive: true });
|
|
agentWorktree = path.join(agentDir, 'agent-deadbeef');
|
|
execSync(
|
|
`git worktree add -b worktree-agent-deadbeef "${agentWorktree}"`,
|
|
{ cwd: upstream, stdio: 'pipe' }
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
try {
|
|
execSync('git worktree prune', { cwd: upstream, stdio: 'pipe' });
|
|
} catch (_) {
|
|
/* ignore */
|
|
}
|
|
cleanup(upstream);
|
|
cleanup(workspacesParent);
|
|
});
|
|
|
|
test('discovery from inside workspace returns only the agent worktree', () => {
|
|
const discovered = runDiscoveryAgainstRepo(workspace);
|
|
|
|
// Resolve symlinks (macOS /var → /private/var) for stable comparison.
|
|
const expected = fs.realpathSync(agentWorktree);
|
|
const actual = discovered.map((p) => fs.realpathSync(p));
|
|
|
|
assert.deepEqual(
|
|
actual,
|
|
[expected],
|
|
'pipeline must list only the agent worktree, not the workspace or upstream'
|
|
);
|
|
});
|
|
|
|
test('running cleanup loop on discovered paths preserves workspace .git', () => {
|
|
const workspaceGitBefore = fs.readFileSync(
|
|
path.join(workspace, '.git'),
|
|
'utf-8'
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(upstream, '.git')),
|
|
'precondition: upstream .git must exist'
|
|
);
|
|
|
|
const discovered = runDiscoveryAgainstRepo(workspace);
|
|
assert.equal(
|
|
discovered.length,
|
|
1,
|
|
'precondition: exactly one agent worktree should be discovered'
|
|
);
|
|
|
|
// Execute the cleanup behavior end-to-end: `git worktree remove --force`
|
|
// each discovered path. This mirrors the workflow's cleanup loop.
|
|
for (const wt of discovered) {
|
|
execSync(`git worktree remove --force "${wt}"`, {
|
|
cwd: workspace,
|
|
stdio: 'pipe',
|
|
});
|
|
}
|
|
|
|
// Agent worktree dir must be gone.
|
|
assert.equal(
|
|
fs.existsSync(agentWorktree),
|
|
false,
|
|
'agent worktree dir should be removed by cleanup'
|
|
);
|
|
|
|
// Workspace `.git` pointer file must still exist and be unchanged —
|
|
// the regression we are guarding against.
|
|
assert.ok(
|
|
fs.existsSync(path.join(workspace, '.git')),
|
|
'workspace .git pointer must survive cleanup (regression #2774)'
|
|
);
|
|
assert.equal(
|
|
fs.readFileSync(path.join(workspace, '.git'), 'utf-8'),
|
|
workspaceGitBefore,
|
|
'workspace .git pointer contents must be unchanged'
|
|
);
|
|
|
|
// Upstream repo's .git directory must also be intact.
|
|
assert.ok(
|
|
fs.existsSync(path.join(upstream, '.git')),
|
|
'upstream .git must survive cleanup'
|
|
);
|
|
|
|
// Workspace must still be a functional git worktree.
|
|
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
cwd: workspace,
|
|
encoding: 'utf-8',
|
|
}).trim();
|
|
assert.equal(
|
|
branch,
|
|
'workspace/feature-x',
|
|
'workspace must still be a functional worktree on its branch'
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|