From d0d542e4788a8aec3c51dcb4d18e8be65efb1ccb Mon Sep 17 00:00:00 2001
From: Dennis Alexis Valin Dittrich
Date: Sat, 5 Sep 2026 09:32:17 +0200
Subject: [PATCH] fix(#4183): resolve root phase Fallow base (#4215)
* test(#4183): reproduce root phase Fallow base failure
* fix(#4183): resolve root phase Fallow base
Fall back to the phase root commit when its parent is unresolvable.
* chore(#4183): add release note
* chore(#4183): bind changeset to fork PR
* docs(#4183): describe root fallback precisely
* chore(#4183): bind changeset to upstream PR
---------
Co-authored-by: CI Rebase Check
Co-authored-by: Tom Boucher
---
.changeset/bold-otters-run.md | 5 ++
.../code-review/steps/structural-pre-pass.md | 6 ++-
.../code-review-pipeline-regression.test.cjs | 53 +++++++++++++++++++
3 files changed, 63 insertions(+), 1 deletion(-)
create mode 100644 .changeset/bold-otters-run.md
diff --git a/.changeset/bold-otters-run.md b/.changeset/bold-otters-run.md
new file mode 100644
index 000000000..61ae65251
--- /dev/null
+++ b/.changeset/bold-otters-run.md
@@ -0,0 +1,5 @@
+---
+type: Fixed
+pr: 4215
+---
+**Structural pre-pass no longer aborts for phases introduced in the repository's root commit** — Fallow uses the root commit itself when no parent exists instead of receiving an invalid parent revision. (#4183)
diff --git a/gsd-core/workflows/code-review/steps/structural-pre-pass.md b/gsd-core/workflows/code-review/steps/structural-pre-pass.md
index 10c033629..f6621413b 100644
--- a/gsd-core/workflows/code-review/steps/structural-pre-pass.md
+++ b/gsd-core/workflows/code-review/steps/structural-pre-pass.md
@@ -39,7 +39,11 @@ if [ \"$FALLOW_SCOPE\" = \"phase\" ]; then
# same-numbered phases and tail -1 selected the oldest.
FALLOW_PHASE_START=$(git log --format=\"%H\" --diff-filter=A -- \"${PHASE_DIR}\" 2>/dev/null | tail -1)
if [ -n \"$FALLOW_PHASE_START\" ]; then
- FALLOW_BASE=\"${FALLOW_PHASE_START}^\"
+ if git rev-parse \"${FALLOW_PHASE_START}^\" >/dev/null 2>&1; then
+ FALLOW_BASE=\"${FALLOW_PHASE_START}^\"
+ else
+ FALLOW_BASE=\"${FALLOW_PHASE_START}\"
+ fi
FALLOW_SCOPE_ARGS=(--changed-since \"$FALLOW_BASE\")
fi
fi
diff --git a/tests/code-review-pipeline-regression.test.cjs b/tests/code-review-pipeline-regression.test.cjs
index 8805c3f9f..8feacc38a 100644
--- a/tests/code-review-pipeline-regression.test.cjs
+++ b/tests/code-review-pipeline-regression.test.cjs
@@ -990,6 +990,59 @@ describe('Bug 5 (#3191) — same anchored, portable phase-scope grep at all thre
}
);
+ test(
+ 'root commit: fallow phase scope uses a resolvable root SHA',
+ SKIP_WIN32,
+ () => {
+ const repo = createTempDir('gsd-4183-fallow-root-');
+ try {
+ gitOrThrow(['init', '-b', 'main'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
+ gitOrThrow(['config', 'user.email', 'test@test.com'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
+ gitOrThrow(['config', 'user.name', 'Test'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
+ gitOrThrow(['config', 'commit.gpgsign', 'false'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
+
+ const phaseFile = path.join(repo, '.planning', 'phases', '06-ctx', 'PLAN.md');
+ fs.mkdirSync(path.dirname(phaseFile), { recursive: true });
+ fs.writeFileSync(phaseFile, '# phase context\n');
+ gitOrThrow(['add', '.planning'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
+ gitOrThrow(['commit', '-m', 'docs(06): initial phase context'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
+ const rootSha = gitOrThrow(['rev-parse', 'HEAD'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS }).trim();
+
+ fs.writeFileSync(path.join(repo, 'index.js'), 'module.exports = 1;\n');
+ gitOrThrow(['add', 'index.js'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
+ gitOrThrow(['commit', '-m', 'feat: add source'], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
+
+ const result = runDerivation(repo, extractFallowDerivation(), '06');
+ assert.equal(result.status, 0, `snippet exited ${result.status}; stderr=${result.stderr}`);
+ const fallowBase = parseSentinel(result.stdout, 'FALLOW_BASE');
+ assert.deepStrictEqual(
+ fallowBase,
+ [rootSha],
+ `root-parent FALLOW_BASE regression: expected ${rootSha}, got ${JSON.stringify(fallowBase)}`,
+ );
+ assert.equal(
+ gitOrThrow(['rev-parse', '--verify', `${fallowBase[0]}^{commit}`], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS }).trim(),
+ rootSha,
+ 'FALLOW_BASE must resolve to the root commit',
+ );
+
+ if (process.env.CI) {
+ const { requireFallowBinary } = require('../gsd-core/bin/lib/fallow-runner.cjs');
+ const { execTool } = require('../gsd-core/bin/lib/shell-command-projection.cjs');
+ const audit = execTool(
+ requireFallowBinary({ cwd: ROOT, envPath: '' }),
+ ['audit', '--changed-since', fallowBase[0], '--format', 'json'],
+ { cwd: repo, timeout: 120000 },
+ );
+ assert.ok([0, 1].includes(audit.exitCode), `fallow root audit exit=${audit.exitCode}; stderr=${audit.stderr}`);
+ console.log(`fallow-root-audit normal-exit=${audit.exitCode}`);
+ }
+ } finally {
+ cleanup(repo);
+ }
+ },
+ );
+
test(
'T5: with no genuine phase scope commit, every derivation yields NO base (fail-closed preserved)',
SKIP_WIN32,