fix(trekexecute): make parallel-mode plan delivery portable and gitignore-tolerant
Two independent defects took down a real voyage on macOS today
(llm-ingestion-okf, wave 1, zero steps executed). Both paths led to the same
end state: the worktree had no plan.
1. Phase 2.6 Step 2a' derived the project relpath with
`realpath --relative-to`, which is GNU coreutils only. On BSD realpath
(macOS default) the substitution fails silently: PROJECT_REL ends up
empty, `mkdir -p "$wt/"` and `cp ... "$wt//"` both succeed, and
brief.md/plan.md land at the worktree root where no child session looks.
The portable form existed only as a prose note saying an operator "may
substitute" it — the note was prose, the code was what ran. The block now
uses `python3 -c os.path.relpath` (stdlib), resolves both operands with
bare realpath (identical on BSD and GNU, and correct across the macOS
/var -> /private/var symlink), and aborts loudly when the relpath is
empty or escapes the repo instead of dropping files at the root. The
coreutils note is deleted rather than extended: two forms means the next
reader picks, and that pick is what failed.
Measured on this machine 2026-08-31 (Intel Mac, no coreutils):
`realpath --relative-to=...` -> `realpath: illegal option -- -`;
bare `realpath /Users/ktg/.claude` -> correct path, exit 0.
2. Phase 2.55 Check 2 ran `git add {plan-path}` unconditionally. When the
project directory is gitignored (`.claude/projects/` is tool-managed and
local-only — normal, not exotic) the add refuses and the plan never
reaches HEAD. Check 2 now probes with `git check-ignore` and branches:
tracked -> nothing; untracked -> add + commit as before; ignored -> step
aside and let Step 2a' be the delivery path. `git add -f` was rejected as
the fix: forcing operator-local artifacts into history publishes them to
whatever remote the repo pushes to. A `check-ignore` exit code other than
0 or 1 is fatal and stops execution — a probe that failed is not a probe
that answered "not ignored".
Also fixed in the same block: `[ -d research ] && cp -r ...` as the last
statement of the loop body made a project without research/ exit the wave
non-zero.
TDD, Iron Law: tests/commands/trekexecute-parallel-portability.test.mjs
extracts both shell blocks from commands/trekexecute.md by grep-able anchor
and executes them, so the tests bind to what an agent actually copies. The
assertions check FILE PLACEMENT, not exit status — the whole defect is that
the broken form exits 0. Controls, all present before the fix: a
known-positive BSD-realpath stub (bare path resolves, GNU flag rejected with
`illegal option`), a negative control running the old GNU form under that
stub and asserting plan.md lands at the worktree root, a known-positive
Check 2 arm where a non-ignored plan is still added and committed, and a
stubbed fatal `git check-ignore` (128). Red first: 7 failed, 2 passed (the
two controls). Green after: 9/9.
Suite 1013 (1011/0/2) -> 1022 (1020/0/2). No version bump, no release.
Order: 20260831T214411Z-941965142-from-.claude (from .claude).
Co-Authored-By: Claude <claude-opus-5>
This commit is contained in:
parent
2ee7b76430
commit
63e78c5ec0
2 changed files with 389 additions and 17 deletions
|
|
@ -419,19 +419,47 @@ to stderr but do NOT block the stop; `progress.json` is still authoritative.
|
|||
`status: stopped`) so the next-session producer-mismatch check has both
|
||||
candidates available. Use the same combined ESM block pattern as Phase 8.
|
||||
|
||||
### Check 2 — Plan file is tracked by git
|
||||
### Check 2 — Plan file reaches every worktree
|
||||
|
||||
Run `git ls-files --error-unmatch {plan-path} 2>/dev/null`. If the plan file is
|
||||
untracked (exit code != 0):
|
||||
Worktrees are created from HEAD, so tracking the plan file is the cheapest way
|
||||
to make it visible in each one. But the project directory may be **gitignored**
|
||||
— `.claude/projects/` is tool-managed and local-only, and a repo that ignores it
|
||||
is normal, not exotic. `git add -f` is **not** the answer there: it would push
|
||||
operator-local artifacts into history, and into whatever remote the repo
|
||||
publishes to. When the plan file is ignored, Phase 2.6 Step 2a' (which copies
|
||||
brief/plan/research into each worktree) is the delivery path, and this check
|
||||
must step aside instead of failing.
|
||||
|
||||
Set `PLAN_PATH` to the plan path, then run:
|
||||
|
||||
```bash
|
||||
git add {plan-path}
|
||||
git commit -m "chore: track plan file for parallel execution"
|
||||
if git ls-files --error-unmatch "$PLAN_PATH" >/dev/null 2>&1; then
|
||||
PLAN_TRACKING="tracked"
|
||||
else
|
||||
git check-ignore -q "$PLAN_PATH"
|
||||
case "$?" in
|
||||
0) PLAN_TRACKING="ignored" ;;
|
||||
1) PLAN_TRACKING="untracked" ;;
|
||||
*) echo "Error: git check-ignore failed on $PLAN_PATH — a fatal probe is not an answer about ignore status." >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
if [ "$PLAN_TRACKING" = "untracked" ]; then
|
||||
git add "$PLAN_PATH"
|
||||
git commit -m "chore: track plan file for parallel execution"
|
||||
fi
|
||||
```
|
||||
|
||||
Report: `Plan file committed for worktree visibility.`
|
||||
Report by outcome:
|
||||
|
||||
This ensures every worktree created from HEAD will have the plan file.
|
||||
| `PLAN_TRACKING` | Report |
|
||||
|---|---|
|
||||
| `tracked` | `Plan file already tracked.` |
|
||||
| `untracked` | `Plan file committed for worktree visibility.` |
|
||||
| `ignored` | `Plan file is gitignored — not forced into history. Step 2a' copies it into each worktree.` |
|
||||
|
||||
Any other `git check-ignore` exit code is fatal and stops execution: a probe
|
||||
that failed is not a probe that answered "not ignored".
|
||||
|
||||
### Check 3 — Scope fence overlap validation
|
||||
|
||||
|
|
@ -478,7 +506,7 @@ If cleanup fails, report the manual commands and stop.
|
|||
|
||||
After all 4 checks pass:
|
||||
```
|
||||
Pre-flight: PASS (clean tree, plan tracked, no overlaps, no stale worktrees)
|
||||
Pre-flight: PASS (clean tree, plan reaches worktrees, no overlaps, no stale worktrees)
|
||||
```
|
||||
|
||||
## Phase 2.6 — Multi-session orchestration (worktree-isolated)
|
||||
|
|
@ -604,27 +632,45 @@ Insert this block AFTER the worktree-creation loop and BEFORE wave dispatch
|
|||
|
||||
```bash
|
||||
PROJECT_SOURCE="$(realpath "${PROJECT_DIR}")"
|
||||
REPO_ROOT_REAL="$(realpath "${REPO_ROOT}")"
|
||||
# Compute destination relpath: PROJECT_DIR relative to REPO_ROOT.
|
||||
# This makes $wt/$PROJECT_REL valid regardless of whether the operator
|
||||
# passed --project as relative (.claude/projects/...) or absolute.
|
||||
PROJECT_REL="$(realpath --relative-to="$REPO_ROOT" "$PROJECT_SOURCE")"
|
||||
# python3 + os.path.relpath is stdlib and portable — see the note below the
|
||||
# block for why no realpath flag may be used here.
|
||||
PROJECT_REL="$(python3 -c 'import os.path,sys; print(os.path.relpath(sys.argv[1], sys.argv[2]))' "$PROJECT_SOURCE" "$REPO_ROOT_REAL")"
|
||||
case "$PROJECT_REL" in
|
||||
""|..*)
|
||||
echo "Error: cannot derive a project relpath inside the repo ($PROJECT_SOURCE vs $REPO_ROOT_REAL)." >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
for wt in "$WORKTREE_DIR"/session-*; do
|
||||
[ -d "$wt" ] || continue
|
||||
mkdir -p "$wt/$PROJECT_REL"
|
||||
cp "$PROJECT_SOURCE"/brief.md "$wt/$PROJECT_REL/"
|
||||
cp "$PROJECT_SOURCE"/plan.md "$wt/$PROJECT_REL/"
|
||||
[ -d "$PROJECT_SOURCE/research" ] && \
|
||||
if [ -d "$PROJECT_SOURCE/research" ]; then
|
||||
cp -r "$PROJECT_SOURCE/research" "$wt/$PROJECT_REL/"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
Note: `realpath --relative-to` is GNU coreutils. macOS users without
|
||||
`coreutils` (Homebrew `brew install coreutils` provides `grealpath`) may
|
||||
substitute a Python fallback:
|
||||
`python3 -c "import os.path,sys; print(os.path.relpath(sys.argv[1], sys.argv[2]))" "$PROJECT_SOURCE" "$REPO_ROOT"`.
|
||||
Do not "improve" the relpath line into `realpath --relative-to=...`. That flag
|
||||
is GNU coreutils only; BSD `realpath` (the macOS default) rejects it, and the
|
||||
failure is **silent** — the command substitution leaves `PROJECT_REL` empty, so
|
||||
`mkdir -p "$wt/"` and `cp ... "$wt//"` both succeed and drop `brief.md`/`plan.md`
|
||||
at the worktree root, where no child session looks for them. Measured on an
|
||||
Intel Mac 2026-08-31: `realpath --relative-to=... ` → `realpath: illegal option
|
||||
-- -`, while bare `realpath <path>` works; a whole wave ran with zero steps
|
||||
executed. Both `realpath` calls above are bare path resolution, which BSD and
|
||||
GNU handle identically; resolving both operands before the relpath is what keeps
|
||||
it correct when one side goes through a symlink (macOS `/var` → `/private/var`).
|
||||
|
||||
Failure mode: any `cp` failure exits the wave non-zero; reported via Step 4
|
||||
cleanup. Source: brief Constraint 2.
|
||||
Failure modes: an underivable relpath (empty, or outside the repo) aborts
|
||||
before anything is copied — better a loud stop than files delivered where no
|
||||
session reads them; any `cp` failure exits the wave non-zero, reported via
|
||||
Step 4 cleanup. A project without `research/` is not a failure.
|
||||
Source: brief Constraint 2.
|
||||
|
||||
**2b. Launch sessions in this wave (each in its own worktree):**
|
||||
|
||||
|
|
|
|||
326
tests/commands/trekexecute-parallel-portability.test.mjs
Normal file
326
tests/commands/trekexecute-parallel-portability.test.mjs
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
// tests/commands/trekexecute-parallel-portability.test.mjs
|
||||
//
|
||||
// Executable tests for the two shell blocks /trekexecute Phase 2.6 Step 2a' and
|
||||
// Phase 2.55 Check 2 tell the agent to run. Both blocks wrecked a real voyage on
|
||||
// macOS (order 20260831T214411Z-941965142-from-.claude):
|
||||
//
|
||||
// Defect 1: `realpath --relative-to` is GNU coreutils. On BSD realpath the
|
||||
// command substitution fails, PROJECT_REL becomes EMPTY, and `mkdir -p
|
||||
// "$wt/"` + `cp ... "$wt//"` both SUCCEED — brief.md/plan.md land at the
|
||||
// worktree root instead of the project relpath. Exit status stays 0; only
|
||||
// file location tells the truth. Every assertion here checks placement.
|
||||
// Defect 2: Check 2 ran `git add {plan-path}` unconditionally. When the
|
||||
// project directory is gitignored (normal — .claude/projects/ is tool-
|
||||
// managed and local-only) the add fails and the plan never reaches HEAD.
|
||||
//
|
||||
// The blocks are EXTRACTED from commands/trekexecute.md and executed, so the
|
||||
// test binds to what an agent actually copies, not to prose about it. Anchors
|
||||
// are grep-able strings, never line numbers.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, chmodSync } from 'node:fs';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..');
|
||||
const COMMAND_FILE = join(ROOT, 'commands', 'trekexecute.md');
|
||||
|
||||
const COPY_BLOCK_ANCHOR = "**2a'. Copy gitignored project artifacts";
|
||||
const CHECK2_ANCHOR = '### Check 2 —';
|
||||
|
||||
// The pre-fix form, kept ONLY as the negative control for Defect 1.
|
||||
const LEGACY_GNU_LINE = 'PROJECT_REL="$(realpath --relative-to="$REPO_ROOT" "$PROJECT_SOURCE")"';
|
||||
|
||||
// --- helpers -------------------------------------------------------------
|
||||
|
||||
/** Extract the first ```bash fence that follows `anchor` in commands/trekexecute.md. */
|
||||
function extractBashBlock(anchor) {
|
||||
const text = readFileSync(COMMAND_FILE, 'utf8');
|
||||
const at = text.indexOf(anchor);
|
||||
assert.ok(at >= 0, `anchor not found in trekexecute.md: ${anchor}`);
|
||||
const fenceOpen = text.indexOf('```bash', at);
|
||||
assert.ok(fenceOpen >= 0, `no bash fence after anchor: ${anchor}`);
|
||||
const bodyStart = text.indexOf('\n', fenceOpen) + 1;
|
||||
const fenceClose = text.indexOf('```', bodyStart);
|
||||
assert.ok(fenceClose > bodyStart, `unterminated bash fence after anchor: ${anchor}`);
|
||||
return text.slice(bodyStart, fenceClose);
|
||||
}
|
||||
|
||||
/**
|
||||
* A PATH directory whose `realpath` behaves like BSD realpath: it rejects every
|
||||
* GNU long option and resolves bare paths correctly. Stubbed, never assumed —
|
||||
* the machine running the suite may or may not have GNU coreutils.
|
||||
*/
|
||||
function bsdRealpathStubDir() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'trek-bsdstub-'));
|
||||
const stub = join(dir, 'realpath');
|
||||
writeFileSync(stub, [
|
||||
'#!/bin/sh',
|
||||
'# BSD realpath stand-in: no GNU long options.',
|
||||
'for a in "$@"; do',
|
||||
' case "$a" in',
|
||||
' --*) echo "realpath: illegal option -- -" >&2; exit 1 ;;',
|
||||
' esac',
|
||||
'done',
|
||||
"exec python3 -c 'import os,sys",
|
||||
'for p in sys.argv[1:]: print(os.path.realpath(p))',
|
||||
"' \"$@\"",
|
||||
'',
|
||||
].join('\n'));
|
||||
chmodSync(stub, 0o755);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** A PATH directory whose `git check-ignore` dies fatally (128), everything else real. */
|
||||
function fatalCheckIgnoreGitStubDir() {
|
||||
const realGit = execFileSync('/usr/bin/env', ['sh', '-c', 'command -v git'], { encoding: 'utf8' }).trim();
|
||||
assert.ok(realGit, 'git not on PATH — cannot build the check-ignore stub');
|
||||
const dir = mkdtempSync(join(tmpdir(), 'trek-gitstub-'));
|
||||
const stub = join(dir, 'git');
|
||||
writeFileSync(stub, [
|
||||
'#!/bin/sh',
|
||||
'# `git check-ignore` fatal (128): NOT an answer about ignore status.',
|
||||
'for a in "$@"; do',
|
||||
' if [ "$a" = "check-ignore" ]; then',
|
||||
' echo "fatal: simulated check-ignore failure" >&2',
|
||||
' exit 128',
|
||||
' fi',
|
||||
'done',
|
||||
`exec ${realGit} "$@"`,
|
||||
'',
|
||||
].join('\n'));
|
||||
chmodSync(stub, 0o755);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function git(cwd, ...args) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
}
|
||||
|
||||
/**
|
||||
* A temp repo with a committed file plus a project directory holding brief/plan
|
||||
* (+ research unless opts.research === false). `gitignoreProject: true` adds the
|
||||
* .gitignore line that makes the project directory invisible to `git add` —
|
||||
* and, note, invisible to `git status --porcelain` too, which is exactly why
|
||||
* this topology sails through Check 1 and only trips on Check 2.
|
||||
*/
|
||||
function makeRepo(opts = {}) {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'trek-exec-')));
|
||||
git(root, 'init', '-q', '-b', 'main');
|
||||
git(root, 'config', 'user.email', 'test@example.invalid');
|
||||
git(root, 'config', 'user.name', 'Test');
|
||||
writeFileSync(join(root, 'README.md'), '# fixture\n');
|
||||
if (opts.gitignoreProject) writeFileSync(join(root, '.gitignore'), '.claude/projects/\n');
|
||||
git(root, 'add', 'README.md', ...(opts.gitignoreProject ? ['.gitignore'] : []));
|
||||
git(root, 'commit', '-qm', 'init');
|
||||
|
||||
const projectRel = join('.claude', 'projects', 'demo');
|
||||
const projectDir = join(root, projectRel);
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
writeFileSync(join(projectDir, 'brief.md'), '# brief\n');
|
||||
writeFileSync(join(projectDir, 'plan.md'), '# plan\n');
|
||||
if (opts.research !== false) {
|
||||
mkdirSync(join(projectDir, 'research'), { recursive: true });
|
||||
writeFileSync(join(projectDir, 'research', '01-x.md'), '# r\n');
|
||||
}
|
||||
|
||||
const worktreeDir = join(root, '.claude', 'trekplan-sessions', 'demo', 'worktrees');
|
||||
mkdirSync(join(worktreeDir, 'session-1'), { recursive: true });
|
||||
mkdirSync(join(worktreeDir, 'session-2'), { recursive: true });
|
||||
|
||||
return { root, projectRel, projectDir, worktreeDir };
|
||||
}
|
||||
|
||||
function runBlock(script, { cwd, env }) {
|
||||
return spawnSync('bash', ['-c', script], { cwd, env: { ...process.env, ...env }, encoding: 'utf8' });
|
||||
}
|
||||
|
||||
function copyBlockEnv(repo, pathPrefixDir) {
|
||||
return {
|
||||
REPO_ROOT: repo.root,
|
||||
PROJECT_DIR: repo.projectDir,
|
||||
WORKTREE_DIR: repo.worktreeDir,
|
||||
PATH: `${pathPrefixDir}:${process.env.PATH}`,
|
||||
};
|
||||
}
|
||||
|
||||
function exists(p) {
|
||||
try { readFileSync(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
// --- Defect 1: portable relpath derivation -------------------------------
|
||||
|
||||
test("2a' — the BSD realpath stub is known-positive: bare path resolves, --relative-to is rejected", () => {
|
||||
const stubDir = bsdRealpathStubDir();
|
||||
try {
|
||||
const bare = runBlock('realpath "$HOME"', { cwd: ROOT, env: { PATH: `${stubDir}:${process.env.PATH}` } });
|
||||
assert.equal(bare.status, 0, 'stub must resolve a bare path (proves it can succeed)');
|
||||
assert.equal(bare.stdout.trim(), realpathSync(process.env.HOME));
|
||||
|
||||
const gnu = runBlock('realpath --relative-to=/ "$HOME"', { cwd: ROOT, env: { PATH: `${stubDir}:${process.env.PATH}` } });
|
||||
assert.notEqual(gnu.status, 0, 'stub must reject the GNU long option');
|
||||
assert.match(gnu.stderr, /illegal option/, 'stub must fail the way BSD realpath fails');
|
||||
assert.equal(gnu.stdout.trim(), '', 'no stdout — this is what leaves PROJECT_REL empty');
|
||||
} finally {
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("2a' — NEGATIVE CONTROL: the GNU form drops plan.md at the worktree ROOT under BSD realpath", () => {
|
||||
const repo = makeRepo();
|
||||
const stubDir = bsdRealpathStubDir();
|
||||
try {
|
||||
const legacy = [
|
||||
'PROJECT_SOURCE="$(realpath "${PROJECT_DIR}")"',
|
||||
LEGACY_GNU_LINE,
|
||||
'for wt in "$WORKTREE_DIR"/session-*; do',
|
||||
' [ -d "$wt" ] || continue',
|
||||
' mkdir -p "$wt/$PROJECT_REL"',
|
||||
' cp "$PROJECT_SOURCE"/brief.md "$wt/$PROJECT_REL/"',
|
||||
' cp "$PROJECT_SOURCE"/plan.md "$wt/$PROJECT_REL/"',
|
||||
'done',
|
||||
].join('\n');
|
||||
runBlock(legacy, { cwd: repo.root, env: copyBlockEnv(repo, stubDir) });
|
||||
|
||||
const wt = join(repo.worktreeDir, 'session-1');
|
||||
assert.equal(exists(join(wt, repo.projectRel, 'plan.md')), false,
|
||||
'the broken form must NOT put plan.md at the project relpath');
|
||||
assert.equal(exists(join(wt, 'plan.md')), true,
|
||||
'the broken form silently drops plan.md at the worktree root — the measured havari');
|
||||
} finally {
|
||||
rmSync(repo.root, { recursive: true, force: true });
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("2a' — the shipped block copies brief/plan/research to $wt/$PROJECT_REL without GNU realpath", () => {
|
||||
const repo = makeRepo();
|
||||
const stubDir = bsdRealpathStubDir();
|
||||
try {
|
||||
const r = runBlock(extractBashBlock(COPY_BLOCK_ANCHOR), { cwd: repo.root, env: copyBlockEnv(repo, stubDir) });
|
||||
assert.equal(r.status, 0, `block must succeed without GNU realpath. stderr: ${r.stderr}`);
|
||||
for (const s of ['session-1', 'session-2']) {
|
||||
const dest = join(repo.worktreeDir, s, repo.projectRel);
|
||||
assert.equal(exists(join(dest, 'plan.md')), true, `${s}: plan.md must reach $wt/$PROJECT_REL`);
|
||||
assert.equal(exists(join(dest, 'brief.md')), true, `${s}: brief.md must reach $wt/$PROJECT_REL`);
|
||||
assert.equal(exists(join(dest, 'research', '01-x.md')), true, `${s}: research/ must reach $wt/$PROJECT_REL`);
|
||||
assert.equal(exists(join(repo.worktreeDir, s, 'plan.md')), false,
|
||||
`${s}: nothing may land at the worktree root`);
|
||||
}
|
||||
} finally {
|
||||
rmSync(repo.root, { recursive: true, force: true });
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("2a' — a project without research/ is not a failure (block still exits 0)", () => {
|
||||
const repo = makeRepo({ research: false });
|
||||
const stubDir = bsdRealpathStubDir();
|
||||
try {
|
||||
const r = runBlock(extractBashBlock(COPY_BLOCK_ANCHOR), { cwd: repo.root, env: copyBlockEnv(repo, stubDir) });
|
||||
assert.equal(r.status, 0, `missing research/ must not fail the wave. stderr: ${r.stderr}`);
|
||||
assert.equal(exists(join(repo.worktreeDir, 'session-1', repo.projectRel, 'plan.md')), true);
|
||||
} finally {
|
||||
rmSync(repo.root, { recursive: true, force: true });
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("2a' — an underivable relpath fails LOUDLY instead of dropping files at the worktree root", () => {
|
||||
const repo = makeRepo();
|
||||
const stubDir = bsdRealpathStubDir();
|
||||
const outside = realpathSync(mkdtempSync(join(tmpdir(), 'trek-outside-')));
|
||||
try {
|
||||
mkdirSync(join(outside, 'p'), { recursive: true });
|
||||
writeFileSync(join(outside, 'p', 'brief.md'), 'b');
|
||||
writeFileSync(join(outside, 'p', 'plan.md'), 'p');
|
||||
const env = { ...copyBlockEnv(repo, stubDir), PROJECT_DIR: join(outside, 'p') };
|
||||
const r = runBlock(extractBashBlock(COPY_BLOCK_ANCHOR), { cwd: repo.root, env });
|
||||
assert.notEqual(r.status, 0, 'a project outside REPO_ROOT must abort the wave');
|
||||
assert.match(r.stderr, /relpath/i, 'the abort must name the cause');
|
||||
assert.equal(exists(join(repo.worktreeDir, 'session-1', 'plan.md')), false,
|
||||
'nothing may be dropped at the worktree root');
|
||||
} finally {
|
||||
rmSync(repo.root, { recursive: true, force: true });
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("2a' — the GNU-only form is gone from the block agents copy", () => {
|
||||
const block = extractBashBlock(COPY_BLOCK_ANCHOR);
|
||||
assert.equal(block.includes('--relative-to'), false,
|
||||
'the copied block must not contain `realpath --relative-to` (GNU-only)');
|
||||
assert.ok(block.includes('os.path.relpath'), 'the copied block must derive the relpath portably');
|
||||
});
|
||||
|
||||
// --- Defect 2: gitignored project directory ------------------------------
|
||||
|
||||
function check2Env(repo, planPath, pathPrefixDir) {
|
||||
const env = { REPO_ROOT: repo.root, PLAN_PATH: planPath };
|
||||
if (pathPrefixDir) env.PATH = `${pathPrefixDir}:${process.env.PATH}`;
|
||||
return env;
|
||||
}
|
||||
|
||||
test('Check 2 — gitignored project dir: no commit, no failure, and 2a\' still delivers the plan', () => {
|
||||
const repo = makeRepo({ gitignoreProject: true });
|
||||
const stubDir = bsdRealpathStubDir();
|
||||
try {
|
||||
// Known-positive on the premise: the plan file really is ignored here.
|
||||
const ci = spawnSync('git', ['check-ignore', '-v', join(repo.projectRel, 'plan.md')],
|
||||
{ cwd: repo.root, encoding: 'utf8' });
|
||||
assert.equal(ci.status, 0, 'fixture premise: the plan file must actually be gitignored');
|
||||
|
||||
const head = git(repo.root, 'rev-parse', 'HEAD').trim();
|
||||
const r = runBlock(extractBashBlock(CHECK2_ANCHOR),
|
||||
{ cwd: repo.root, env: check2Env(repo, join(repo.projectRel, 'plan.md')) });
|
||||
assert.equal(r.status, 0, `Check 2 must tolerate a gitignored plan file. stderr: ${r.stderr}`);
|
||||
assert.equal(git(repo.root, 'rev-parse', 'HEAD').trim(), head,
|
||||
'an ignored plan file must NOT be forced into history (origin is a public mirror)');
|
||||
|
||||
const copy = runBlock(extractBashBlock(COPY_BLOCK_ANCHOR), { cwd: repo.root, env: copyBlockEnv(repo, stubDir) });
|
||||
assert.equal(copy.status, 0, `copy step must succeed. stderr: ${copy.stderr}`);
|
||||
assert.equal(exists(join(repo.worktreeDir, 'session-1', repo.projectRel, 'plan.md')), true,
|
||||
'the plan must reach the worktree even though git never tracked it');
|
||||
} finally {
|
||||
rmSync(repo.root, { recursive: true, force: true });
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Check 2 — KNOWN-POSITIVE: an untracked, NOT-ignored plan file is still added and committed', () => {
|
||||
const repo = makeRepo();
|
||||
try {
|
||||
const planPath = join(repo.projectRel, 'plan.md');
|
||||
const head = git(repo.root, 'rev-parse', 'HEAD').trim();
|
||||
const r = runBlock(extractBashBlock(CHECK2_ANCHOR), { cwd: repo.root, env: check2Env(repo, planPath) });
|
||||
assert.equal(r.status, 0, `Check 2 must succeed on a normal untracked plan. stderr: ${r.stderr}`);
|
||||
assert.notEqual(git(repo.root, 'rev-parse', 'HEAD').trim(), head,
|
||||
'a trackable plan file must still be committed for worktree visibility');
|
||||
const ls = spawnSync('git', ['ls-files', '--error-unmatch', planPath], { cwd: repo.root, encoding: 'utf8' });
|
||||
assert.equal(ls.status, 0, 'the plan file must now be tracked');
|
||||
} finally {
|
||||
rmSync(repo.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Check 2 — a FATAL git check-ignore (128) is not read as "not ignored"', () => {
|
||||
const repo = makeRepo({ gitignoreProject: true });
|
||||
const stubDir = fatalCheckIgnoreGitStubDir();
|
||||
try {
|
||||
const head = git(repo.root, 'rev-parse', 'HEAD').trim();
|
||||
const r = runBlock(extractBashBlock(CHECK2_ANCHOR),
|
||||
{ cwd: repo.root, env: check2Env(repo, join(repo.projectRel, 'plan.md'), stubDir) });
|
||||
assert.notEqual(r.status, 0, 'a fatal check-ignore must stop, not fall through to git add');
|
||||
assert.match(r.stderr, /check-ignore/, 'the stop must name the failing probe');
|
||||
assert.equal(git(repo.root, 'rev-parse', 'HEAD').trim(), head, 'no commit may be made on a fatal probe');
|
||||
} finally {
|
||||
rmSync(repo.root, { recursive: true, force: true });
|
||||
rmSync(stubDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue