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 <ci@gsd-redux>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Dennis Alexis Valin Dittrich
2026-09-05 09:32:17 +02:00
committed by GitHub
parent eedb6b5431
commit d0d542e478
3 changed files with 63 additions and 1 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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,