* fix(#4208): add --files-removed so commit --files can record a move without a directory pathspec `cmdCommit`'s `--files` list can stage an addition but never a deletion: the #2014 guard skips a missing explicit entry because the filesystem cannot tell "moved away" from "not written yet". A caller that moves a file therefore had two forms, both wrong — a directory entry records the move but also commits every unrelated file in that directory (a concurrent session's in-flight todo, in the unattended execute-phase sweep), and a file entry leaves the old path's deletion dangling with the todo tracked at both paths. `--files-removed <paths>` is the caller-declared delete intent. Each entry names a file, or a directory whose tracked-but-absent files are the removals; those paths are staged with `git rm --cached` and join the commit pathspec. `--files` keeps its skip-if-missing contract untouched. A file entry still present on disk fails the commit closed with the existing staging-failure rollback; a never-tracked path is a no-op. `--files-removed` alone is a declared scope, not the unscoped .planning/ sweep. The dispatcher previously folded every non-flag token after `--files` into that list, so a second list flag could not exist; each list now runs from its flag to the next `--` token. The execute-phase todo sweep names the moved todos on both sides from CLOSED[@], and cleanup's archive commit moves .planning/phases/ and .planning/quick/ under --files-removed. Fixes #4208 Emitted-Drift-Ack-Growth: cleanup.md — the archive commit moves phases/ and quick/ under --files-removed; the growth is one paragraph stating why those two directories must not be --files entries * chore(#4208): set changeset fragment pr to 4253 * fix(#4208): fit execute-phase.md under the ADR-857 ceiling and re-point the #2415 guard Three CI failures, all consequences of this PR's own change. 1. gsd-core/workflows/execute-phase.md was 93,577 bytes against the ADR-857 Phase 6 margin gate's <= 93,400 (hard ceiling 93,600). The three-line rationale comment plus the four-line array-building block added 318 bytes to a file that had only 141 of headroom on next. Move the rationale to docs/CLI-TOOLS.md -- which this PR already extends with the --files-removed contract, and which is where the ADR-857 gate wants call-site detail to live rather than in the host workflow -- and fold the array build onto one line. 93,577 -> 93,372. 2/3. tests/close-phase-todos-stage-deletion.test.cjs pinned the #2415 guarantee to its old MECHANISM: it regex-matched the literal .planning/todos/{completed,pending}/ directory pathspecs in the commit --files list. This PR deliberately replaced those with named files (a directory entry also committed an unrelated todo a concurrent session dropped in mid-close), so the guard failed on a change it should have accepted. Re-point it at the new mechanism without weakening it: assert the ADDED array reaches --files, the REMOVED array reaches --files-removed, STATE.md is still committed, and -- newly -- that the two arrays are built from $COMPLETED_DIR and $PENDING_DIR respectively. Verified by negative control: deleting --files-removed "${REMOVED[@]}" from the workflow still fails the test, so the #2415 regression remains caught. Note for the merge queue: #4233 also grows execute-phase.md (+114). The two are additive -- different regions, no textual conflict -- so with both landed the file reaches ~93,486, over the 93,400 margin though under the 93,600 hard ceiling. Whichever merges second will need to reclaim ~86 bytes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0183892Y3fxxirte4WNmBKbv * fix(#4208): reclaim execute-phase.md bytes so the PR is net-neutral under the ADR-857 margin Rebasing onto next surfaced the byte-gate collision flagged earlier on this PR: #4284 grew execute-phase.md by 95 bytes (93,259 -> 93,354), so this PR's +113 landed at 93,467 against the <= 93,400 margin in tests/claude-orchestration.test.cjs. Compact the close_phase_todos step this PR already edits -- drop the PHASE_NUM indirection, fold the normaliser and the match guard, print the closed list with one printf, shorten the step's prose -- without touching the mechanism the #2415 guard pins (ADDED/REMOVED arrays, the plain mv). 93,467 -> 93,349: 5 bytes under the base, so the PR no longer spends any of next's 46 bytes of headroom. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkU9ueBNHQzCpc3du5rKXm * fix(#4208): classify absent index entries before staging a removal; restore removed entries exactly on rollback Review of #4253 found three Majors with one root cause: the removal side judged presence by fs.lstatSync alone, where the addition side already reads `git ls-files -v` state. Absence from the worktree is not removal: - a submodule gitlink (mode 160000) whose directory was deleted by hand lists like a file and was `rm --cached` with no .gitmodules cleanup; - a skip-worktree path is never materialised by a cone-mode sparse checkout, so a directory entry over a sparse-excluded tree dropped that whole tree from the index; - an assume-unchanged path's worktree state is not something git itself consults; - an intent-to-add entry (`git add -N`) renders as a plain cached entry on the empty blob, yet nothing tracked exists to remove and no rollback can restore the flag. The index listing now carries each entry's `ls-files -v -s` tag, mode and stage. Only a plain cached (H), stage-0, non-gitlink entry is a removal candidate; every other state is left alone under a directory entry (exactly like a present file) and fails closed when named directly, with the state in the error. "Named directly" is decided on RESOLVED paths, not strings -- realpath of the longest existing prefix with the absent tail re-appended: an absolute path, `./x`, `--cwd`, or a symlinked spelling of the tree (macOS `/var` -> `/private/var`, where `process.cwd()` is the real path and the caller's absolute path is not -- CI on this round's first push) all resolve to the same entry, where a string compare against git's cwd-relative output silently took the directory polarity (pre-push review, driven; the symlink case is driven with an aliased fixture directory). The enumeration's domain is what `ls-files -v -s` can emit for an index entry, stated at the classifier. The third Major -- on an unborn HEAD a successful `rm --cached` was never rolled back when a later entry failed -- is fixed differently from the review's suggestion. Pushing the path into stagedPaths would put it on the commit pathspec, which a root commit refuses ("pathspec did not match", driven), and `git reset -- <path>` cannot restore an entry with no HEAD anyway. Instead every index entry this call removes is recorded (mode, blob) before the `rm` and put back with `update-index --cacheinfo` on rollback. That also restores a caller-pre-staged blob at a removed path exactly, where a reset would have silently replaced it with HEAD's version. The rollback is best-effort, as the addition-side reset already was, and the docs say so. Eight tests: gitlink under a directory entry, named directly, and named by absolute path; skip-worktree both forms; intent-to-add both forms; assume-unchanged named; unborn-HEAD partial failure restores the removal; pre-staged blob survives the rollback. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkU9ueBNHQzCpc3du5rKXm * fix(#4208): drop the empty fenced block left dangling in cleanup.md's commit step Review nit on #4253: inserting the --files-removed rationale between the original bash block and its closing fence left an empty ```bash``` pair before </step>. Harmless at runtime, a formatting artifact of this PR's own diff; removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkU9ueBNHQzCpc3du5rKXm * fix(#4208): a boolean flag inside a commit path list no longer ends the list Review minor on #4253: collectList stopped at the next `--` token, so a positional wedged between a boolean flag and the next list flag (`--files a --amend b --files-removed c`) was claimed by neither list and silently dropped -- a regression in shape against the old slice-to-end parse, which filtered `--` tokens and kept `b`. No current call site interleaves that way, but the gap was real. A list now runs to the next LIST flag (`--files` / `--files-removed`) and skips boolean flags on the way, and a REPEATED list flag merges its runs (`--files a --files b` -> [a, b]) as the slice-to-end parse did -- a first cut stopped at the repeat and dropped `b`, the same silent-drop shape one level over (pre-post comment audit). The only change #4208 makes to parsing is that a second list flag can exist. Tests: STATE.md wedged between --no-verify and --files-removed lands in the commit; both runs of a repeated --files reach it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkU9ueBNHQzCpc3du5rKXm * test(#4208): drive the reappearance window with a post-index-change hook Review nit on #4253: the defensive re-check for a file recreated between the absence test and `git rm --cached` -- the concurrent-session race this PR's own changeset names -- had no test. git fires post-index-change the moment `rm --cached` writes the index, so a hook that copies the file back exactly then exercises the window deterministically. The call reports staging_failed / "reappeared on disk", commits nothing, and the rollback restores the removed entry. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkU9ueBNHQzCpc3du5rKXm * fix(#4208): restore a staged removal when the call records nothing A `git rm --cached` that succeeds mutates the index whether or not a commit follows. Only the staging-failure rollback put those entries back, so a call that reached `nothing_to_commit` reported no state change while the removal sat staged -- riding along on the caller's next commit. The review named the unborn-HEAD, removal-only shape. Keying on `headExists` would have fixed half of it: the guard also fires with a real HEAD when the removed path is index-only (added, never committed), because `diff HEAD` reads clean with the path absent on both sides. Both shapes now restore, at both `nothing_to_commit` exits. The failure exits are deliberately left alone -- they report a failure rather than no-change, and the addition side leaves its own staged paths there too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj * refactor(#4208): lift declared-removal staging out of the cmdCommit hotspot `cmdCommit` was a critical-risk hotspot before this flag existed, and #4208 had inlined another ~270 lines into it. `stageDeclaredRemovals(cwd, removedDeclared)` now owns the index-state classification, path canonicalisation and entry recording, returning the pathspec entries and the recorded removals its caller merges. Pure motion: no branch, message or probe changed. Only the two accumulators became local names, and `restoreRemovedEntries` stays with the caller because the exits that restore are the caller's. cmdCommit 888 -> 625 lines here; the extracted helper is 277. (Figures corrected after publication: an earlier version of this message said 854 -> 591 and claimed the result was below cmdCommit's pre-#4208 shape. Both were wrong -- the count came from a faulty brace scanner, and `next`'s cmdCommit is 581, so this is above it, not below.) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj * test(#4208): property-test the two-list commit parser RULESET.TESTS.property-based-testing asks a parser for at least one property test asserting a domain invariant; `collectList` had only hand-picked examples, one per shape a review round had already broken. Hoisted it to module scope as `collectListFlagValues` and exported it in the file's existing exported-for-tests convention -- a parser reachable only by spawning the CLI can be tested one example at a time and no faster. Three properties over generated argv: every positional lands in exactly the run open at it whatever the flag order or count; no positional after the first list flag is dropped or double-claimed; and with `--files-removed` absent the parse equals the pre-#4208 slice-to-end parse. Controlled against two mutants -- a run ending at any `--` token, and a repeated list flag that does not merge -- each of which the properties catch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj * test(#4208): pin cleanup.md's archive commit to --files-removed execute-phase.md's rewrite is pinned by the #2415 guard in this file; cleanup.md's equivalent was not, so reverting its routing would have been caught by nothing -- the mechanism's unit tests never read this file and pass either way. Asserts the two archived directories are under --files-removed and NOT under --files (where a directory entry sweeps in a concurrent session's in-flight writes), and that the destinations and STATE.md stay on the additive half. Controlled by restoring the pre-#4208 sweep, which fails it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj * test(#4208): pin that a symlink to a directory is one tracked path Review of #4253 read the `lstatSync(...).isDirectory()` test as a symlink-following defect. Driving it says the opposite: git tracks the link as a single blob (mode 120000) and does not traverse it, so the tracked paths "under" it live at the real directory and were never named by the caller. Following the link would stage those -- the directory sweep #4208 exists to remove -- while the named entry still sat present on disk. Pinned rather than changed, with the premise driven in the test body. Swapping `lstatSync` for `statSync` -- the prescription as written -- fails it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj * chore(#4208): refresh the compact-content baseline for this PR's execute-phase edit The base range added `tests/benchmark-compact-content.test.cjs` and a committed token baseline over the compacted workflows. This PR edits `gsd-core/workflows/execute-phase.md`, so the baseline drifts by +12 tokens on that entry and on the aggregate. Refreshed with `node scripts/benchmark-compact-content.cjs --write`; the diff is those two entries and nothing else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj * fix(#4208): report a removal the call could not put back Round review of this round found the restore itself unchecked: the helper ignored `update-index`'s exit code, so a FAILED restore still reported `nothing_to_commit` -- the same false "no state changed" the restore exists to prevent, surviving one level down on the restore-failure path. It now returns a boolean. The two no-change exits report `staging_failed` naming the paths left staged; the staging-failure rollback still ignores it, deliberately, because it is already reporting a failure and an unwritable index is usually the failure being reported. Driven with a post-index-change hook that makes the git dir unwritable the moment `rm --cached` lands, so the restore cannot take its lock. Reverting both guards fails the test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj * fix(#4208): disclose a removal the rollback could not restore Round review refuted the reasoning behind leaving the rollback path's restore unchecked. The claim was that this exit is already reporting a failure, so the restore's result adds nothing. The counterexample is the ordinary case: the reported failure is usually a DIFFERENT cause -- a contradictory declaration, a reappeared path -- so a caller reading `failures` sees only that cause and learns nothing about the removal still sitting in its index. The rollback now appends a disclosure entry per un-restored removal, naming the path. The reason and `file` still report the failure that caused the rollback; the disclosure is additive. Also moves the restore-failure test's chmod into a `finally`: `t.after` runs AFTER the parent `afterEach`, so a throw before it left the fixture undeletable. Both driven; reverting the disclosure fails the new test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj * fix(#4208): decide index state by observation, never by an exit code The restore added two commits earlier keyed both its record decision and its success verdict on git's exit code. An exit code answers "did the command succeed", never "did the index change" -- execGit collapses a spawn timeout to a non-zero exit, and a killed git can already have written the index. Round review drove four failures from that one assumption, in both directions: - a failed `rm` still contributed an entry, so the rollback disclosed a removal that was never staged (stale index.lock); - a timed-out `rm` whose write DID land contributed none, so a real mutation was neither restored nor disclosed; - a timed-out `update-index` whose write landed reported failure, publishing a "could NOT be restored" disclosure that was false; - and the read-back that replaced it omitted `-z`, so core.quotePath rendered `café.md` as `"caf\303\251.md"` and an exactly-restored entry read as not restored -- the same quoting defect this PR already fixed for `preStaged`. Everything now observes the index. A failed `rm` re-reads `ls-files -z` for the path: gone means this call owns the removal and records it; still there means nothing was staged; a probe that cannot answer becomes its own failure entry rather than an assumption. The restore verifies the same way, comparing the WHOLE entry (mode, blob, stage), because `--cacheinfo` restores all three and a path-only test accepts an entry that came back as something else. The verdict is three-valued -- `restored` / `not-restored` / `unverified` -- and the unverified wording says the restore could not be VERIFIED rather than that it failed. The rm's own failure is pushed ahead of any probe diagnostic so a timed-out removal keeps `timed_out: true` and its own message as the reported cause. Five regression cases, each negative-controlled against the shape it pins. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj * fix(#4208): treat a declared removal path as a path, not a pathspec An index path handed back to git is parsed as a PATHSPEC, and the removal side handed several back. Three driven harms, all of them the sweep-in this flag exists to remove, arriving through the operand rather than through a directory entry: - a tracked file literally named `.planning/*.md` made `rm --cached` GLOB: it removed `peer.md` and `stays.md` too, only the declared entry was recorded, so the rollback restored one of three and the other two rode out as staged deletions the result disclosed nowhere; - the same name reached `git commit -- <paths>`, which globbed and committed an undeclared `M peer.md` alongside the declared removal; - and the intent-to-add probe (`diff --cached` over the path) matched a STAGED PEER instead of itself, so an `add -N` entry was misclassified as ordinary content, removed, and restored by `--cacheinfo` -- which cannot restore the intent flag. It came back as a real staged addition. Every operand on this path is now `:(literal)`: the `rm`, both index probes, the intent-to-add probe, the restore read-back, the entry-level `ls-files` / `ls-tree`, and -- for the REMOVAL-derived entries only -- the downstream `ls-files` / dry-run / `diff HEAD` / `commit` pathspec. `--files` entries keep whatever pathspec behaviour they have today; that is not this change's to alter. `:(literal)` still resolves a directory to its descendants (driven), so the directory form is unchanged. Closes what an earlier cut of this commit declared as a residual: a filename beginning with `:` is now removable end to end, because the commit pathspec no longer reinterprets it. Also fixes a MINOR from the same review: cleanup.md's contract test checked the destinations' position relative to `--files-removed` but never that `--files` was present at all, so deleting the flag still passed. Un-literalising the seven sites fails three of the new tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj * fix(#4208): scope the rollback to the caller's own name space Round review drove a rollback that destroyed the caller's own staged work. Two causes, one of them pre-existing: - `git diff --cached` prints REPO-relative paths whatever the cwd, while `stagedPaths` holds the caller's cwd-relative names. In a project nested inside its repo (`<repo>/sub/.planning/...`) the two name spaces never intersect, so `preStaged` matched NOTHING, every path landed in `toUnstage`, and the reset unstaged a caller-staged deletion and modification that this call had never touched. `--relative` makes the two sets comparable, and is a no-op when the project IS the repo root. This governs the `--files` side too and predates this flag. - the rollback's `reset` was the last place a removal-derived name reached git as a bare pathspec; it takes `asPathspec` like every other site. Driven on a nested fixture; dropping `--relative` fails the new test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj * test(#4208): gate six fixtures that Windows cannot construct CI's `test (windows-latest, 24, shard 2/3)` went red on this round. Two primitives the new fixtures rely on do not exist on Windows, both driven on a real Windows host rather than inferred: - a filename containing `*` or `:` cannot be created at all (`IOException` / `FileNotFoundException`), which is four of the pathspec fixtures; - `chmod` cannot make a directory unwritable — a write into a ReadOnly directory succeeds — so the two restore-failure fixtures cannot drive the failure they exist to drive. Each is skipped on win32 with its measured reason, in the repo's existing `{ skip: process.platform === 'win32' ? '<reason>' : false }` form. The behaviours they pin are platform-independent; only the fixtures are not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj * test(#4208): build git's index-syntax path with forward slashes The remaining Windows red was mine, not the platform's: `git rev-parse :<path>` takes a forward-slash path, and `path.join` yields backslashes there, so git rejected it as an ambiguous argument. The hook in the same test already used the slash form. Not gated — the behaviour it pins is portable; only the argument was not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016gyGdweAdAG6nFv9Jx32vj * chore(#4208): refresh the compact-content baseline against the rebased base `next` moved the `new-project` split and the aggregate under this PR's execute-phase entry; regenerated with `scripts/benchmark-compact-content.cjs --write` so the only leaves differing from the base's copy are the execute-phase split and the aggregate it feeds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FUcGM4FWeZV4cqvR7QBtJh * chore(#4208): regenerate the macOS conformance tier for this PR's fixtures `next` gained the macOS-specific conformance tier (#4593) after this branch was cut. Its classifier (`scripts/gen-platform-conformance-tier.cjs --target macos`) now selects `tests/commit-files-deletion.test.cjs` on the `chmod-mode-bit` and `symlink-keyword` signals the PR's fixtures carry (the chmod-driven failed-restore cases and the symlink-to-directory case). Regenerated with `--target macos --write`; the platform tier was already in sync. The file was modified, not added, which is why the added-files check did not surface it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FUcGM4FWeZV4cqvR7QBtJh --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: CI Rebase Check <ci@gsd-redux> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
1289 lines
67 KiB
JavaScript
1289 lines
67 KiB
JavaScript
/**
|
|
* Regression test for #2014: gsd-tools commit --files silently deletes
|
|
* planning files when a filename passed via --files does not exist on disk.
|
|
*
|
|
* Prior to this fix, when --files STATE.md was passed and STATE.md did not
|
|
* exist on disk, the code called `git rm --cached --ignore-unmatch STATE.md`
|
|
* which staged and committed a deletion. The caller passed explicit --files
|
|
* expecting only those specific files to be staged -- missing files should
|
|
* be skipped, not deleted.
|
|
*/
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { createTempGitProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
|
const fc = require('fast-check');
|
|
const { collectListFlagValues, COMMIT_LIST_FLAGS } = require('../gsd-core/bin/gsd-tools.cjs');
|
|
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
|
|
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
|
|
const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
describe('commit --files: missing files must not stage deletions (#2014)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempGitProject();
|
|
// Commit STATE.md so it exists in git history
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State\n\nInitial state.\n');
|
|
gitOrThrow(['add', '.planning/STATE.md'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['commit', '-m', 'add STATE.md'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
// Delete STATE.md from disk -- now missing but tracked in git
|
|
fs.unlinkSync(path.join(tmpDir, '.planning', 'STATE.md'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('passing --files for a missing tracked file does not commit a deletion', () => {
|
|
// STATE.md is tracked in git but deleted from disk.
|
|
// commit --files .planning/STATE.md should skip it (no deletion committed).
|
|
runGsdTools(
|
|
['commit', 'test commit', '--files', '.planning/STATE.md'],
|
|
tmpDir
|
|
);
|
|
|
|
// Check git log: the new commit (HEAD) must NOT have deleted STATE.md.
|
|
// git diff HEAD~1 HEAD --name-status shows what changed between commits.
|
|
let diffOutput = '';
|
|
try {
|
|
diffOutput = gitOrThrow(['diff', 'HEAD~1', 'HEAD', '--name-status'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
} catch (e) {
|
|
// If nothing to commit, there is no HEAD~1 -- that's also acceptable
|
|
return;
|
|
}
|
|
assert.ok(
|
|
!diffOutput.includes('D\t.planning/STATE.md'),
|
|
'commit --files must not commit a deletion of a missing file, diff was:\n' + diffOutput
|
|
);
|
|
});
|
|
|
|
test('passing --files for a file that exists stages and commits it normally', () => {
|
|
// Create ROADMAP.md -- this file exists, should be staged normally
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\nPhase 01.\n');
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'add roadmap', '--files', '.planning/ROADMAP.md'],
|
|
tmpDir
|
|
);
|
|
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.committed, true, 'should have committed when file exists');
|
|
|
|
// Verify ROADMAP.md was added in the commit
|
|
const diffOutput = gitOrThrow(['diff', 'HEAD~1', 'HEAD', '--name-status'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
assert.ok(
|
|
diffOutput.includes('A\t.planning/ROADMAP.md'),
|
|
'ROADMAP.md should appear as added in the commit'
|
|
);
|
|
});
|
|
|
|
test('--files with mix of existing and missing files only stages the existing ones', () => {
|
|
// ROADMAP.md exists on disk, STATE.md does not
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
|
|
|
runGsdTools(
|
|
['commit', 'partial files', '--files', '.planning/ROADMAP.md', '.planning/STATE.md'],
|
|
tmpDir
|
|
);
|
|
|
|
// The commit must not include a deletion of STATE.md
|
|
let diffOutput = '';
|
|
try {
|
|
diffOutput = gitOrThrow(['diff', 'HEAD~1', 'HEAD', '--name-status'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
} catch (e) {
|
|
return; // nothing committed is fine
|
|
}
|
|
assert.ok(
|
|
!diffOutput.includes('D\t.planning/STATE.md'),
|
|
'missing file in --files list must not be committed as a deletion'
|
|
);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Regression tests for #4208: `commit --files` could not record a file move.
|
|
*
|
|
* The #2014 guard above skips a missing `--files` entry, so the only form that
|
|
* recorded a move was a DIRECTORY entry — which also committed any unrelated
|
|
* file sitting in that directory (a concurrent session's in-flight todo, in
|
|
* the execute-phase sweep). `--files-removed` is the caller-declared deletion
|
|
* intent that lets a move be recorded at file granularity, with the #2014
|
|
* skip-if-missing contract on `--files` left untouched.
|
|
*/
|
|
describe('commit --files-removed: caller-declared deletions record a move (#4208)', () => {
|
|
let tmpDir;
|
|
const PENDING = path.join('.planning', 'todos', 'pending');
|
|
const COMPLETED = path.join('.planning', 'todos', 'completed');
|
|
|
|
function nameStatus() {
|
|
// `--no-renames`: a clean move would otherwise collapse to one `R100` row
|
|
// and hide whether the old path's deletion was actually recorded.
|
|
return gitOrThrow(['diff', '--no-renames', 'HEAD~1', 'HEAD', '--name-status'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS })
|
|
.trim().split('\n').filter(Boolean).sort();
|
|
}
|
|
|
|
function status() {
|
|
// `-uall`: once the move empties pending/ of tracked files, plain
|
|
// `--porcelain` collapses its untracked contents to the bare directory.
|
|
return gitOrThrow(['status', '--porcelain', '-uall'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempGitProject();
|
|
fs.mkdirSync(path.join(tmpDir, PENDING), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, COMPLETED), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'mine.md'), '---\nresolves_phase: 5\n---\nmine\n');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State\n');
|
|
gitOrThrow(['add', '.planning/'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['commit', '-m', 'seed todo'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
// The move this phase performs, plus a peer's unrelated in-flight todo.
|
|
fs.renameSync(path.join(tmpDir, PENDING, 'mine.md'), path.join(tmpDir, COMPLETED, 'mine.md'));
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'peer-inflight.md'), '---\nresolves_phase: 99\n---\npeer\n');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State\n\nphase 5 closed\n');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('records the move at file granularity and leaves the peer file alone', () => {
|
|
const result = runGsdTools(
|
|
['commit', 'docs(phase-5): close 1 resolved todo(s)',
|
|
'--files', '.planning/todos/completed/mine.md', '.planning/STATE.md',
|
|
'--files-removed', '.planning/todos/pending/mine.md'],
|
|
tmpDir,
|
|
);
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.committed, true, 'move commit must succeed: ' + result.output);
|
|
|
|
assert.deepStrictEqual(
|
|
nameStatus(),
|
|
['A\t.planning/todos/completed/mine.md', 'D\t.planning/todos/pending/mine.md', 'M\t.planning/STATE.md'],
|
|
'commit must contain exactly the move and STATE.md',
|
|
);
|
|
// The peer's file is untouched: still untracked, never committed — and
|
|
// nothing about the moved todo is left dangling.
|
|
const st = status();
|
|
assert.ok(st.includes('?? .planning/todos/pending/peer-inflight.md'), 'peer file must stay untracked: ' + st);
|
|
assert.ok(!st.includes('mine.md'), 'no dangling state for the moved todo: ' + st);
|
|
// No dual-tracking: the todo is tracked at the new path only.
|
|
const tracked = gitOrThrow(['ls-files', '--', '.planning/todos'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
assert.strictEqual(tracked, '.planning/todos/completed/mine.md');
|
|
});
|
|
|
|
test('a directory entry stages only the tracked files that are absent from disk', () => {
|
|
// A second tracked todo that stays put must NOT be touched by the
|
|
// directory form, and the untracked peer file must stay invisible to it.
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'stays.md'), 'stays\n');
|
|
gitOrThrow(['add', path.join(PENDING, 'stays.md')], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['commit', '-m', 'seed a todo that stays'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
fs.appendFileSync(path.join(tmpDir, PENDING, 'stays.md'), 'edited by a peer, uncommitted\n');
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs(phase-5): close 1 resolved todo(s)',
|
|
'--files', '.planning/todos/completed/mine.md',
|
|
'--files-removed', '.planning/todos/pending/'],
|
|
tmpDir,
|
|
);
|
|
assert.strictEqual(JSON.parse(result.output).committed, true, result.output);
|
|
assert.deepStrictEqual(
|
|
nameStatus(),
|
|
['A\t.planning/todos/completed/mine.md', 'D\t.planning/todos/pending/mine.md'],
|
|
);
|
|
const st = status();
|
|
assert.ok(st.includes(' M .planning/todos/pending/stays.md'), 'present tracked file must stay uncommitted: ' + st);
|
|
assert.ok(st.includes('?? .planning/todos/pending/peer-inflight.md'), 'untracked peer file must stay untracked: ' + st);
|
|
});
|
|
|
|
test('a --files-removed file entry that is still on disk fails closed and rolls back', () => {
|
|
// The declaration is wrong: pending/mine.md was put back.
|
|
fs.copyFileSync(path.join(tmpDir, COMPLETED, 'mine.md'), path.join(tmpDir, PENDING, 'mine.md'));
|
|
const head = gitOrThrow(['rev-parse', 'HEAD'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs(phase-5): bad declaration',
|
|
'--files', '.planning/todos/completed/mine.md',
|
|
'--files-removed', '.planning/todos/pending/mine.md'],
|
|
tmpDir,
|
|
);
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.committed, false);
|
|
assert.strictEqual(parsed.reason, 'staging_failed');
|
|
assert.strictEqual(parsed.file, '.planning/todos/pending/mine.md');
|
|
assert.strictEqual(
|
|
gitOrThrow(['rev-parse', 'HEAD'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim(),
|
|
head,
|
|
'nothing may be committed on a refused declaration',
|
|
);
|
|
// Rollback: the addition this call staged is unstaged again.
|
|
assert.strictEqual(
|
|
gitOrThrow(['diff', '--cached', '--name-only'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim(),
|
|
'',
|
|
);
|
|
});
|
|
|
|
test('a --files-removed path git never tracked is a no-op, not an error', () => {
|
|
const result = runGsdTools(
|
|
['commit', 'docs(phase-5): close 1 resolved todo(s)',
|
|
'--files', '.planning/todos/completed/mine.md',
|
|
'--files-removed', '.planning/todos/pending/mine.md', '.planning/todos/pending/never-tracked.md'],
|
|
tmpDir,
|
|
);
|
|
assert.strictEqual(JSON.parse(result.output).committed, true, result.output);
|
|
assert.deepStrictEqual(
|
|
nameStatus(),
|
|
['A\t.planning/todos/completed/mine.md', 'D\t.planning/todos/pending/mine.md'],
|
|
);
|
|
});
|
|
|
|
test('--files-removed alone is a declared scope, not the unscoped .planning/ sweep', () => {
|
|
const result = runGsdTools(
|
|
['commit', 'docs: drop a todo', '--files-removed', '.planning/todos/pending/mine.md'],
|
|
tmpDir,
|
|
);
|
|
assert.strictEqual(JSON.parse(result.output).committed, true, result.output);
|
|
// Only the deletion — not completed/mine.md, STATE.md, or the peer file.
|
|
assert.deepStrictEqual(nameStatus(), ['D\t.planning/todos/pending/mine.md']);
|
|
});
|
|
|
|
test('--files keeps its #2014 skip-if-missing contract when --files-removed is also given', () => {
|
|
// A tracked file that is temporarily absent (NOT moved) named via --files
|
|
// must still be skipped, even though the same call declares a removal.
|
|
fs.unlinkSync(path.join(tmpDir, '.planning', 'STATE.md'));
|
|
const result = runGsdTools(
|
|
['commit', 'docs(phase-5): close 1 resolved todo(s)',
|
|
'--files', '.planning/todos/completed/mine.md', '.planning/STATE.md',
|
|
'--files-removed', '.planning/todos/pending/mine.md'],
|
|
tmpDir,
|
|
);
|
|
assert.strictEqual(JSON.parse(result.output).committed, true, result.output);
|
|
assert.deepStrictEqual(
|
|
nameStatus(),
|
|
['A\t.planning/todos/completed/mine.md', 'D\t.planning/todos/pending/mine.md'],
|
|
'the temporarily-absent STATE.md must not be committed as a deletion',
|
|
);
|
|
});
|
|
|
|
test('a tracked symlink whose target is gone is present, not a removal', () => {
|
|
// `stat`/`existsSync` follow the link and read it as absent; `lstat` does
|
|
// not. Declaring it removed while it still sits in the worktree must fail
|
|
// closed like any other present entry, with nothing left staged.
|
|
const link = path.join(PENDING, 'dangling');
|
|
fs.symlinkSync('target-that-will-vanish.md', path.join(tmpDir, link));
|
|
gitOrThrow(['add', link], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['commit', '-m', 'seed a symlink'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
const head = gitOrThrow(['rev-parse', 'HEAD'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs: bad declaration',
|
|
'--files', '.planning/todos/completed/mine.md',
|
|
'--files-removed', '.planning/todos/pending/dangling'],
|
|
tmpDir,
|
|
);
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.committed, false, result.output);
|
|
assert.strictEqual(parsed.reason, 'staging_failed');
|
|
assert.strictEqual(parsed.file, '.planning/todos/pending/dangling');
|
|
assert.strictEqual(gitOrThrow(['rev-parse', 'HEAD'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim(), head);
|
|
assert.strictEqual(gitOrThrow(['diff', '--cached', '--name-only'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim(), '');
|
|
});
|
|
|
|
test('a deletion the caller already staged is committed, not reported as nothing to commit', () => {
|
|
// `git rm` before the call empties the index entry; `ls-files` alone would
|
|
// never list it, so the path would miss the pathspec.
|
|
gitOrThrow(['rm', '-q', '--cached', path.join(PENDING, 'mine.md')], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
const result = runGsdTools(
|
|
['commit', 'docs: drop a todo', '--files-removed', '.planning/todos/pending/mine.md'],
|
|
tmpDir,
|
|
);
|
|
assert.strictEqual(JSON.parse(result.output).committed, true, result.output);
|
|
assert.deepStrictEqual(nameStatus(), ['D\t.planning/todos/pending/mine.md']);
|
|
});
|
|
|
|
test('a deletion staged by this call is rolled back when a later entry fails', () => {
|
|
// Ordering: the good removal is processed first, then the contradicted one.
|
|
fs.copyFileSync(path.join(tmpDir, COMPLETED, 'mine.md'), path.join(tmpDir, PENDING, 'stays-put.md'));
|
|
gitOrThrow(['add', path.join(PENDING, 'stays-put.md')], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['commit', '-m', 'seed a second todo'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
const head = gitOrThrow(['rev-parse', 'HEAD'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs: bad declaration',
|
|
'--files-removed', '.planning/todos/pending/mine.md', '.planning/todos/pending/stays-put.md'],
|
|
tmpDir,
|
|
);
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.reason, 'staging_failed', result.output);
|
|
assert.strictEqual(parsed.file, '.planning/todos/pending/stays-put.md');
|
|
assert.strictEqual(gitOrThrow(['rev-parse', 'HEAD'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim(), head);
|
|
// The staged deletion of mine.md was restored to the index by the rollback.
|
|
assert.strictEqual(gitOrThrow(['diff', '--cached', '--name-only'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim(), '');
|
|
assert.ok(
|
|
gitOrThrow(['ls-files', '--', PENDING], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).includes('mine.md'),
|
|
'mine.md must be back in the index after rollback',
|
|
);
|
|
});
|
|
|
|
test('a caller-pre-staged deletion with a non-ASCII name survives the rollback', () => {
|
|
// `diff --cached --name-only` without -z quotes `café.md` as
|
|
// `"caf\303\251.md"`, which never matched the raw path, so the rollback
|
|
// treated the caller's own staged deletion as this call's and undid it.
|
|
const cafe = path.join(PENDING, 'café.md');
|
|
fs.writeFileSync(path.join(tmpDir, cafe), 'accent\n');
|
|
gitOrThrow(['add', cafe], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['commit', '-m', 'seed café'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['rm', '-q', cafe], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }); // caller-staged deletion
|
|
fs.copyFileSync(path.join(tmpDir, COMPLETED, 'mine.md'), path.join(tmpDir, PENDING, 'mine.md')); // contradiction
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs: bad declaration',
|
|
'--files-removed', '.planning/todos/pending/café.md', '.planning/todos/pending/mine.md'],
|
|
tmpDir,
|
|
);
|
|
assert.strictEqual(JSON.parse(result.output).reason, 'staging_failed', result.output);
|
|
const cached = gitOrThrow(['diff', '--cached', '--name-only', '-z'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).split('\0').filter(Boolean);
|
|
assert.deepStrictEqual(cached, ['.planning/todos/pending/café.md'], 'the caller\'s own staged deletion must survive');
|
|
});
|
|
|
|
test('on an unborn HEAD an absent index-only path is unstaged, never a pathspec entry', (t) => {
|
|
// A root commit has no parent to delete from; naming the path would make
|
|
// `git commit` refuse with "pathspec did not match".
|
|
const fresh = createTempGitProject();
|
|
t.after(() => cleanup(fresh));
|
|
// The fixture may seed commits; make an unborn branch explicitly.
|
|
gitOrThrow(['checkout', '-q', '--orphan', 'unborn'], { cwd: fresh, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['rm', '-rfq', '--cached', '.'], { cwd: fresh, timeoutMs: GIT_TIMEOUT_MS });
|
|
fs.mkdirSync(path.join(fresh, PENDING), { recursive: true });
|
|
fs.writeFileSync(path.join(fresh, PENDING, 'a.md'), 'a\n');
|
|
fs.writeFileSync(path.join(fresh, PENDING, 'gone.md'), 'gone\n');
|
|
gitOrThrow(['add', PENDING], { cwd: fresh, timeoutMs: GIT_TIMEOUT_MS });
|
|
fs.unlinkSync(path.join(fresh, PENDING, 'gone.md'));
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs: root commit',
|
|
'--files', '.planning/todos/pending/a.md',
|
|
'--files-removed', '.planning/todos/pending/gone.md'],
|
|
fresh,
|
|
);
|
|
assert.strictEqual(JSON.parse(result.output).committed, true, result.output);
|
|
const tree = gitOrThrow(['ls-tree', '-r', '--name-only', 'HEAD'], { cwd: fresh, timeoutMs: GIT_TIMEOUT_MS }).trim().split('\n');
|
|
assert.ok(tree.includes('.planning/todos/pending/a.md'), tree.join(','));
|
|
assert.ok(!tree.includes('.planning/todos/pending/gone.md'), tree.join(','));
|
|
assert.strictEqual(gitOrThrow(['diff', '--cached', '--name-only'], { cwd: fresh, timeoutMs: GIT_TIMEOUT_MS }).trim(), '');
|
|
});
|
|
|
|
test('a boolean flag inside a list does not end it: the positional after it stays in that list', () => {
|
|
// `--files a --no-verify b --files-removed c`: before #4208 the single
|
|
// slice-to-end list swept `b` into --files; a list that stops at ANY
|
|
// `--` token silently drops it instead (review of #4253). A list runs to
|
|
// the next LIST flag and skips boolean flags on the way.
|
|
const result = runGsdTools(
|
|
['commit', 'docs(phase-5): close 1 resolved todo(s)',
|
|
'--files', '.planning/todos/completed/mine.md', '--no-verify', '.planning/STATE.md',
|
|
'--files-removed', '.planning/todos/pending/mine.md'],
|
|
tmpDir,
|
|
);
|
|
assert.strictEqual(JSON.parse(result.output).committed, true, result.output);
|
|
assert.deepStrictEqual(
|
|
nameStatus(),
|
|
['A\t.planning/todos/completed/mine.md', 'D\t.planning/todos/pending/mine.md', 'M\t.planning/STATE.md'],
|
|
'STATE.md, wedged between --no-verify and --files-removed, must still be in the --files list',
|
|
);
|
|
});
|
|
|
|
test('a repeated list flag merges its runs, as the old parser did', () => {
|
|
// `--files a --files b`: the pre-#4208 slice-to-end parse yielded [a, b];
|
|
// a parser that stops at the next list flag — including a repeat of the
|
|
// same one — silently dropped b (found by the round's comment audit).
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
|
|
const result = runGsdTools(
|
|
['commit', 'docs(phase-5): close 1 resolved todo(s)',
|
|
'--files', '.planning/todos/completed/mine.md', '--files', '.planning/ROADMAP.md',
|
|
'--files-removed', '.planning/todos/pending/mine.md'],
|
|
tmpDir,
|
|
);
|
|
assert.strictEqual(JSON.parse(result.output).committed, true, result.output);
|
|
assert.deepStrictEqual(
|
|
nameStatus(),
|
|
['A\t.planning/ROADMAP.md', 'A\t.planning/todos/completed/mine.md', 'D\t.planning/todos/pending/mine.md'],
|
|
'both --files runs must reach the commit',
|
|
);
|
|
});
|
|
|
|
test('--files-removed before --files parses both lists and the message', () => {
|
|
const result = runGsdTools(
|
|
['commit', 'docs(phase-5): close 1 resolved todo(s)',
|
|
'--files-removed', '.planning/todos/pending/mine.md',
|
|
'--files', '.planning/todos/completed/mine.md'],
|
|
tmpDir,
|
|
);
|
|
assert.strictEqual(JSON.parse(result.output).committed, true, result.output);
|
|
assert.deepStrictEqual(
|
|
nameStatus(),
|
|
['A\t.planning/todos/completed/mine.md', 'D\t.planning/todos/pending/mine.md'],
|
|
);
|
|
const subject = gitOrThrow(['log', '-1', '--format=%s'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
assert.strictEqual(subject, 'docs(phase-5): close 1 resolved todo(s)');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* #4253 review: absence from the worktree is not removal. Some index entries
|
|
* are absent BY DESIGN — a submodule gitlink whose directory was deleted by
|
|
* hand, a skip-worktree path a sparse checkout never materialised, an
|
|
* assume-unchanged path — and `lstat` cannot tell them from a moved-away file.
|
|
* Under a directory entry they are left alone, exactly like a present file;
|
|
* named directly they contradict the declaration and fail closed. And a
|
|
* staging failure restores every index entry this call removed EXACTLY,
|
|
* including on an unborn HEAD, where `git reset` has nothing to restore from.
|
|
*/
|
|
describe('commit --files-removed: index states absent by design are never removals (#4208 review)', () => {
|
|
let tmpDir;
|
|
let stray;
|
|
const PENDING = path.join('.planning', 'todos', 'pending');
|
|
const COMPLETED = path.join('.planning', 'todos', 'completed');
|
|
|
|
function git(args, cwd = tmpDir) {
|
|
return gitOrThrow(args, { cwd, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
}
|
|
function nameStatus() {
|
|
return git(['diff', '--no-renames', 'HEAD~1', 'HEAD', '--name-status']).split('\n').filter(Boolean).sort();
|
|
}
|
|
// Untrimmed: porcelain's leading column is significant (` D` = unstaged deletion).
|
|
function porcelain(...pathspec) {
|
|
return gitOrThrow(['status', '--porcelain', '--', ...pathspec], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
}
|
|
// Seed the standard move: pending/mine.md -> completed/mine.md, committed at pending/.
|
|
function seedMove() {
|
|
fs.mkdirSync(path.join(tmpDir, PENDING), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, COMPLETED), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'mine.md'), 'mine\n');
|
|
git(['add', '.planning/']);
|
|
git(['commit', '-q', '-m', 'seed todo']);
|
|
fs.renameSync(path.join(tmpDir, PENDING, 'mine.md'), path.join(tmpDir, COMPLETED, 'mine.md'));
|
|
}
|
|
// A submodule at pending/sub whose directory is then deleted by hand — the
|
|
// one gitlink shape that reads as absent (an uninitialised submodule leaves
|
|
// an empty directory behind, which lstat sees as present).
|
|
function addSubmoduleThenDeleteDir() {
|
|
const subSrc = path.join(tmpDir, '..', path.basename(tmpDir) + '-sub');
|
|
stray = subSrc;
|
|
fs.mkdirSync(subSrc, { recursive: true });
|
|
git(['init', '-q', '.'], subSrc);
|
|
git(['config', 'user.email', 't@t'], subSrc);
|
|
git(['config', 'user.name', 't'], subSrc);
|
|
fs.writeFileSync(path.join(subSrc, 'f.txt'), 'v1\n');
|
|
git(['add', 'f.txt'], subSrc);
|
|
git(['commit', '-q', '-m', 'v1'], subSrc);
|
|
git(['-c', 'protocol.file.allow=always', 'submodule', 'add', '-q', subSrc, '.planning/todos/pending/sub']);
|
|
git(['commit', '-q', '-m', 'add submodule']);
|
|
cleanup(path.join(tmpDir, PENDING, 'sub'));
|
|
assert.match(porcelain(PENDING), /^ D \.planning\/todos\/pending\/sub$/m, 'git itself reads the gitlink as deleted');
|
|
}
|
|
|
|
beforeEach(() => { tmpDir = createTempGitProject(); stray = null; });
|
|
afterEach(() => { cleanup(tmpDir); if (stray) cleanup(stray); });
|
|
|
|
test('a directory entry leaves a hand-deleted submodule gitlink in the index and records only the file move', () => {
|
|
seedMove();
|
|
addSubmoduleThenDeleteDir();
|
|
const result = runGsdTools(
|
|
['commit', 'docs: close a todo', '--files', '.planning/todos/completed/mine.md', '--files-removed', '.planning/todos/pending/'],
|
|
tmpDir,
|
|
);
|
|
assert.strictEqual(JSON.parse(result.output).committed, true, result.output);
|
|
assert.deepStrictEqual(nameStatus(), ['A\t.planning/todos/completed/mine.md', 'D\t.planning/todos/pending/mine.md']);
|
|
// The gitlink is still tracked, at mode 160000, and git still reports the
|
|
// hand-deletion as the caller's unstaged business — not this call's.
|
|
assert.match(git(['ls-files', '-s', '--', PENDING]), /^160000 [0-9a-f]+ 0\t\.planning\/todos\/pending\/sub$/m);
|
|
assert.match(porcelain(PENDING), /^ D \.planning\/todos\/pending\/sub$/m);
|
|
});
|
|
|
|
test('a submodule gitlink named directly under --files-removed fails closed, naming the state', () => {
|
|
seedMove();
|
|
addSubmoduleThenDeleteDir();
|
|
const head = git(['rev-parse', 'HEAD']);
|
|
const result = runGsdTools(
|
|
['commit', 'docs: bad declaration', '--files', '.planning/todos/completed/mine.md', '--files-removed', '.planning/todos/pending/sub'],
|
|
tmpDir,
|
|
);
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.committed, false, result.output);
|
|
assert.strictEqual(parsed.reason, 'staging_failed');
|
|
assert.strictEqual(parsed.file, '.planning/todos/pending/sub');
|
|
assert.match(parsed.error, /submodule gitlink/);
|
|
assert.strictEqual(git(['rev-parse', 'HEAD']), head);
|
|
assert.strictEqual(git(['diff', '--cached', '--name-only']), '', 'the addition this call staged is rolled back');
|
|
assert.match(git(['ls-files', '-s', '--', PENDING]), /^160000 /m, 'the gitlink is untouched');
|
|
});
|
|
|
|
test('a skip-worktree path is absent by checkout, not removed: skipped under a directory entry, refused when named', () => {
|
|
seedMove();
|
|
// A second tracked todo that a sparse checkout would not materialise.
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'sparse.md'), 'sparse\n');
|
|
git(['add', path.join(PENDING, 'sparse.md')]);
|
|
git(['commit', '-q', '-m', 'seed sparse']);
|
|
git(['update-index', '--skip-worktree', '--', '.planning/todos/pending/sparse.md']);
|
|
fs.unlinkSync(path.join(tmpDir, PENDING, 'sparse.md'));
|
|
assert.strictEqual(porcelain(path.join(PENDING, 'sparse.md')), '', 'git itself does not report a skip-worktree path as deleted');
|
|
|
|
const dirForm = runGsdTools(
|
|
['commit', 'docs: close a todo', '--files', '.planning/todos/completed/mine.md', '--files-removed', '.planning/todos/pending/'],
|
|
tmpDir,
|
|
);
|
|
assert.strictEqual(JSON.parse(dirForm.output).committed, true, dirForm.output);
|
|
assert.deepStrictEqual(nameStatus(), ['A\t.planning/todos/completed/mine.md', 'D\t.planning/todos/pending/mine.md']);
|
|
assert.match(git(['ls-files', '-v', '--', PENDING]), /^S \.planning\/todos\/pending\/sparse\.md$/m, 'the sparse entry stays in the index, still skip-worktree');
|
|
|
|
const head = git(['rev-parse', 'HEAD']);
|
|
const named = runGsdTools(['commit', 'docs: bad declaration', '--files-removed', '.planning/todos/pending/sparse.md'], tmpDir);
|
|
const parsed = JSON.parse(named.output);
|
|
assert.strictEqual(parsed.reason, 'staging_failed', named.output);
|
|
assert.strictEqual(parsed.file, '.planning/todos/pending/sparse.md');
|
|
assert.match(parsed.error, /skip-worktree/);
|
|
assert.strictEqual(git(['rev-parse', 'HEAD']), head);
|
|
assert.match(git(['ls-files', '-v', '--', PENDING]), /^S \.planning\/todos\/pending\/sparse\.md$/m);
|
|
});
|
|
|
|
test('a directly named path is recognised by any spelling that resolves to it (absolute path)', () => {
|
|
// The direct-vs-directory decision is made on resolved paths. A string
|
|
// compare against git's cwd-relative output silently took the directory
|
|
// polarity for an absolute path, so a named gitlink SKIPPED instead of
|
|
// refusing (found by review, driven).
|
|
seedMove();
|
|
addSubmoduleThenDeleteDir();
|
|
const head = git(['rev-parse', 'HEAD']);
|
|
const result = runGsdTools(
|
|
['commit', 'docs: bad declaration', '--files', '.planning/todos/completed/mine.md', '--files-removed', path.join(tmpDir, PENDING, 'sub')],
|
|
tmpDir,
|
|
);
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.reason, 'staging_failed', result.output);
|
|
assert.match(parsed.error, /submodule gitlink/);
|
|
assert.strictEqual(git(['rev-parse', 'HEAD']), head);
|
|
assert.match(git(['ls-files', '-s', '--', PENDING]), /^160000 /m, 'the gitlink is untouched');
|
|
|
|
// And through a SYMLINKED spelling of the same directory — the macOS
|
|
// `/var` → `/private/var` shape, where `process.cwd()` is the real path
|
|
// and the caller's absolute path is not (CI, first push of this round).
|
|
const alias = tmpDir + '-alias';
|
|
fs.symlinkSync(tmpDir, alias, 'dir');
|
|
try {
|
|
const viaLink = runGsdTools(
|
|
['commit', 'docs: bad declaration', '--files', '.planning/todos/completed/mine.md', '--files-removed', path.join(alias, PENDING, 'sub')],
|
|
tmpDir,
|
|
);
|
|
const p2 = JSON.parse(viaLink.output);
|
|
assert.strictEqual(p2.reason, 'staging_failed', viaLink.output);
|
|
assert.match(p2.error, /submodule gitlink/);
|
|
assert.strictEqual(git(['rev-parse', 'HEAD']), head);
|
|
} finally {
|
|
fs.unlinkSync(alias);
|
|
}
|
|
});
|
|
|
|
test('an intent-to-add entry is not tracked content: skipped under a directory entry, refused when named', () => {
|
|
// `git add -N` renders as a plain `H 100644 <empty blob>` entry, yet there
|
|
// is nothing committed to remove and a cacheinfo rollback cannot restore
|
|
// the flag (found by review, driven).
|
|
seedMove();
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'planned.md'), 'planned\n');
|
|
git(['add', '-N', path.join(PENDING, 'planned.md')]);
|
|
fs.unlinkSync(path.join(tmpDir, PENDING, 'planned.md'));
|
|
const before = git(['ls-files', '-s', '--', path.join(PENDING, 'planned.md')]);
|
|
assert.match(before, /^100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0/, 'fixture: intent-to-add entry present');
|
|
|
|
const dirForm = runGsdTools(
|
|
['commit', 'docs: close a todo', '--files', '.planning/todos/completed/mine.md', '--files-removed', '.planning/todos/pending/'],
|
|
tmpDir,
|
|
);
|
|
assert.strictEqual(JSON.parse(dirForm.output).committed, true, dirForm.output);
|
|
assert.deepStrictEqual(nameStatus(), ['A\t.planning/todos/completed/mine.md', 'D\t.planning/todos/pending/mine.md']);
|
|
assert.strictEqual(git(['ls-files', '-s', '--', path.join(PENDING, 'planned.md')]), before, 'the intent-to-add entry is left alone');
|
|
|
|
const named = runGsdTools(['commit', 'docs: bad declaration', '--files-removed', '.planning/todos/pending/planned.md'], tmpDir);
|
|
const parsed = JSON.parse(named.output);
|
|
assert.strictEqual(parsed.reason, 'staging_failed', named.output);
|
|
assert.match(parsed.error, /intent-to-add/);
|
|
assert.strictEqual(git(['ls-files', '-s', '--', path.join(PENDING, 'planned.md')]), before);
|
|
});
|
|
|
|
test('an assume-unchanged path named directly fails closed and stays in the index', () => {
|
|
seedMove();
|
|
git(['update-index', '--assume-unchanged', '--', '.planning/todos/pending/mine.md']);
|
|
const result = runGsdTools(['commit', 'docs: drop a todo', '--files-removed', '.planning/todos/pending/mine.md'], tmpDir);
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.reason, 'staging_failed', result.output);
|
|
assert.match(parsed.error, /assume-unchanged/);
|
|
assert.match(git(['ls-files', '-v', '--', PENDING]), /^h \.planning\/todos\/pending\/mine\.md$/m);
|
|
});
|
|
|
|
test('on an unborn HEAD a removal staged by this call is restored when a later entry fails', (t) => {
|
|
// The rollback cannot `git reset` to a HEAD that does not exist; the
|
|
// entry is put back from the record this call kept of it.
|
|
const fresh = createTempGitProject();
|
|
t.after(() => cleanup(fresh));
|
|
git(['checkout', '-q', '--orphan', 'unborn'], fresh);
|
|
git(['rm', '-rfq', '--cached', '.'], fresh);
|
|
fs.mkdirSync(path.join(fresh, PENDING), { recursive: true });
|
|
fs.writeFileSync(path.join(fresh, PENDING, 'gone.md'), 'gone\n');
|
|
fs.writeFileSync(path.join(fresh, PENDING, 'stays.md'), 'stays\n');
|
|
git(['add', PENDING], fresh);
|
|
const before = git(['ls-files', '-s', '--', PENDING], fresh);
|
|
fs.unlinkSync(path.join(fresh, PENDING, 'gone.md'));
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs: root commit', '--files-removed', '.planning/todos/pending/gone.md', '.planning/todos/pending/stays.md'],
|
|
fresh,
|
|
);
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.reason, 'staging_failed', result.output);
|
|
assert.strictEqual(parsed.file, '.planning/todos/pending/stays.md');
|
|
assert.throws(() => git(['rev-parse', '-q', '--verify', 'HEAD'], fresh), 'nothing may be committed');
|
|
assert.strictEqual(git(['ls-files', '-s', '--', PENDING], fresh), before, 'gone.md is back in the index, same mode and blob');
|
|
});
|
|
|
|
test('on an unborn HEAD a removal-only call that stages nothing else leaves no removal behind', (t) => {
|
|
// The rollback above fires only on a staging FAILURE. On an unborn HEAD a
|
|
// removal never joins `stagedPaths` (there is no parent to delete from), so
|
|
// a removal-only call that SUCCEEDS reaches the nothing-to-commit guard with
|
|
// an empty pathspec -- and `nothing_to_commit` tells the caller no state
|
|
// changed while `rm --cached` has already mutated the index. The removal
|
|
// would then ride along on the caller's next commit.
|
|
const fresh = createTempGitProject();
|
|
t.after(() => cleanup(fresh));
|
|
git(['checkout', '-q', '--orphan', 'unborn'], fresh);
|
|
git(['rm', '-rfq', '--cached', '.'], fresh);
|
|
fs.mkdirSync(path.join(fresh, PENDING), { recursive: true });
|
|
fs.writeFileSync(path.join(fresh, PENDING, 'gone.md'), 'gone\n');
|
|
git(['add', PENDING], fresh);
|
|
const before = git(['ls-files', '-s', '--', PENDING], fresh);
|
|
fs.unlinkSync(path.join(fresh, PENDING, 'gone.md'));
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs: root commit', '--files-removed', '.planning/todos/pending/gone.md'],
|
|
fresh,
|
|
);
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.committed, false, result.output);
|
|
assert.throws(() => git(['rev-parse', '-q', '--verify', 'HEAD'], fresh), 'nothing may be committed');
|
|
assert.strictEqual(
|
|
git(['ls-files', '-s', '--', PENDING], fresh), before,
|
|
'a call reporting no commit must leave the index as it found it',
|
|
);
|
|
});
|
|
|
|
test('a removal of an index-only path leaves no removal behind when nothing is recorded', () => {
|
|
// The same defect with a real HEAD, so the fix cannot key on `headExists`.
|
|
// gone.md was `git add`ed and never committed, then deleted from disk: the
|
|
// removal DOES join the pathspec here, but `diff HEAD -- gone.md` reads
|
|
// clean because the path is absent from the worktree and from HEAD alike,
|
|
// so the guard reports nothing_to_commit over a staged removal.
|
|
fs.mkdirSync(path.join(tmpDir, PENDING), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'seed.md'), 'seed\n');
|
|
git(['add', '.planning/']);
|
|
git(['commit', '-q', '-m', 'seed todo']);
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'gone.md'), 'gone\n');
|
|
git(['add', path.join(PENDING, 'gone.md')]);
|
|
const before = git(['ls-files', '-s', '--', PENDING]);
|
|
const head = git(['rev-parse', 'HEAD']);
|
|
fs.unlinkSync(path.join(tmpDir, PENDING, 'gone.md'));
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs: remove an uncommitted path', '--files-removed', '.planning/todos/pending/gone.md'],
|
|
tmpDir,
|
|
);
|
|
assert.strictEqual(JSON.parse(result.output).committed, false, result.output);
|
|
assert.strictEqual(git(['rev-parse', 'HEAD']), head, 'nothing may be committed');
|
|
assert.strictEqual(
|
|
git(['ls-files', '-s', '--', PENDING]), before,
|
|
'a call reporting no commit must leave the index as it found it',
|
|
);
|
|
});
|
|
|
|
test('a removal the call cannot put back is reported, never as nothing_to_commit',
|
|
{ skip: process.platform === 'win32' ? 'chmod cannot make a directory unwritable on Windows (driven: a write into a ReadOnly directory succeeds), so the fixture cannot drive a failed restore' : false },
|
|
(t) => {
|
|
// The restore is best-effort, so it can FAIL -- and reporting
|
|
// nothing_to_commit over a removal we tried and could not undo is the same
|
|
// false "no state changed" the restore exists to prevent, one level down.
|
|
// Driven with a post-index-change hook that makes the git dir unwritable
|
|
// the moment `rm --cached` lands, so the `update-index --cacheinfo` restore
|
|
// cannot take its lock.
|
|
fs.mkdirSync(path.join(tmpDir, PENDING), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'seed.md'), 'seed\n');
|
|
git(['add', '.planning/']);
|
|
git(['commit', '-q', '-m', 'seed todo']);
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'gone.md'), 'gone\n');
|
|
git(['add', path.join(PENDING, 'gone.md')]);
|
|
fs.unlinkSync(path.join(tmpDir, PENDING, 'gone.md'));
|
|
const gitDir = path.join(tmpDir, '.git');
|
|
const hooksDir = path.join(gitDir, 'hooks');
|
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
fs.writeFileSync(path.join(hooksDir, 'post-index-change'),
|
|
'#!/bin/sh\nchmod a-w "$(git rev-parse --git-dir)"\n', { mode: 0o755 });
|
|
// Give the dir back in a FINALLY below, not only in `t.after`: t.after runs
|
|
// AFTER the parent afterEach, so a throw between the hook and the explicit
|
|
// chmod leaves afterEach unable to delete the fixture. t.after stays as a
|
|
// belt for the case where the finally itself is skipped.
|
|
t.after(() => { try { fs.chmodSync(gitDir, 0o755); } catch { /* already writable */ } });
|
|
const emptyConfig = path.join(tmpDir, 'empty.gitconfig');
|
|
fs.writeFileSync(emptyConfig, '');
|
|
|
|
let result;
|
|
try {
|
|
result = runGsdTools(
|
|
['commit', 'docs: remove an uncommitted path', '--files-removed', '.planning/todos/pending/gone.md'],
|
|
tmpDir,
|
|
{ GIT_CONFIG_GLOBAL: emptyConfig, GIT_CONFIG_NOSYSTEM: '1' },
|
|
);
|
|
} finally {
|
|
fs.chmodSync(gitDir, 0o755);
|
|
}
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.committed, false, result.output);
|
|
assert.notStrictEqual(parsed.reason, 'nothing_to_commit', 'a removal left staged must never be reported as no state change');
|
|
assert.strictEqual(parsed.reason, 'staging_failed', result.output);
|
|
assert.match(parsed.error, /could not be restored/);
|
|
assert.match(parsed.error, /gone\.md/);
|
|
});
|
|
|
|
test('a rollback that cannot restore a removal discloses it, even when the reported failure is another entry',
|
|
{ skip: process.platform === 'win32' ? 'chmod cannot make a directory unwritable on Windows (driven: a write into a ReadOnly directory succeeds), so the fixture cannot drive a failed restore' : false },
|
|
(t) => {
|
|
// The rollback exit reports the failure that CAUSED it -- here a
|
|
// contradictory declaration about a path still on disk -- so a caller
|
|
// reading `failures` would learn nothing about the removal this call had
|
|
// already staged and then could not put back. Both must be disclosed.
|
|
seedMove();
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'stays.md'), 'stays\n');
|
|
git(['add', path.join(PENDING, 'stays.md')]);
|
|
git(['commit', '-q', '-m', 'seed a present todo']);
|
|
const gitDir = path.join(tmpDir, '.git');
|
|
const hooksDir = path.join(gitDir, 'hooks');
|
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
fs.writeFileSync(path.join(hooksDir, 'post-index-change'),
|
|
'#!/bin/sh\nchmod a-w "$(git rev-parse --git-dir)"\n', { mode: 0o755 });
|
|
t.after(() => { try { fs.chmodSync(gitDir, 0o755); } catch { /* already writable */ } });
|
|
const emptyConfig = path.join(tmpDir, 'empty.gitconfig');
|
|
fs.writeFileSync(emptyConfig, '');
|
|
|
|
let result;
|
|
try {
|
|
// mine.md was moved away (a real removal); stays.md is still on disk, so
|
|
// declaring it removed contradicts the declaration and fails the call.
|
|
result = runGsdTools(
|
|
['commit', 'docs: bad declaration',
|
|
'--files-removed', '.planning/todos/pending/mine.md', '.planning/todos/pending/stays.md'],
|
|
tmpDir,
|
|
{ GIT_CONFIG_GLOBAL: emptyConfig, GIT_CONFIG_NOSYSTEM: '1' },
|
|
);
|
|
} finally {
|
|
fs.chmodSync(gitDir, 0o755);
|
|
}
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.reason, 'staging_failed', result.output);
|
|
assert.strictEqual(parsed.file, '.planning/todos/pending/stays.md', 'the REPORTED failure is still the contradictory declaration');
|
|
const disclosed = parsed.failures.filter(f => /could NOT be restored/.test(f.error));
|
|
assert.ok(
|
|
disclosed.length > 0,
|
|
`a removal left staged by a failed rollback must be disclosed; failures were ${JSON.stringify(parsed.failures)}`,
|
|
);
|
|
assert.ok(
|
|
disclosed.some(f => f.file === '.planning/todos/pending/mine.md'),
|
|
`the disclosure must name the un-restored path; got ${JSON.stringify(disclosed)}`,
|
|
);
|
|
});
|
|
|
|
test('a removal whose own rm failed is not disclosed as still staged', () => {
|
|
// The mirror of the disclosure above. `removedEntries` is the set this call
|
|
// claims to have STAGED, so an entry recorded before a `rm --cached` that
|
|
// then FAILED would be reported as "still staged in the index" when nothing
|
|
// was staged at all. Driven with a pre-existing index.lock, which fails the
|
|
// rm and the restore alike.
|
|
seedMove();
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'stays.md'), 'stays\n');
|
|
git(['add', path.join(PENDING, 'stays.md')]);
|
|
git(['commit', '-q', '-m', 'seed a present todo']);
|
|
const before = git(['ls-files', '-s', '--', PENDING]);
|
|
fs.writeFileSync(path.join(tmpDir, '.git', 'index.lock'), '');
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs: bad declaration',
|
|
'--files-removed', '.planning/todos/pending/mine.md', '.planning/todos/pending/stays.md'],
|
|
tmpDir,
|
|
);
|
|
fs.unlinkSync(path.join(tmpDir, '.git', 'index.lock'));
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.reason, 'staging_failed', result.output);
|
|
assert.strictEqual(
|
|
parsed.failures.filter(f => /could NOT be restored/.test(f.error)).length, 0,
|
|
`nothing was staged, so nothing may be disclosed as left staged; failures were ${JSON.stringify(parsed.failures)}`,
|
|
);
|
|
assert.strictEqual(git(['ls-files', '-s', '--', PENDING]), before, 'the index is untouched');
|
|
});
|
|
|
|
test('a timed-out removal does not produce a false could-not-restore disclosure', () => {
|
|
// The exit code answers "did the command succeed", never "did the index
|
|
// change": execGit collapses a spawn timeout to a non-zero exit, and a
|
|
// killed git can already have written the index. This pins the RESTORE
|
|
// side of that -- a restore whose update-index was killed after its write
|
|
// landed must not report "could NOT be restored" over an index it did in
|
|
// fact restore. Forcing the restore verdict back onto the exit code fails
|
|
// this test.
|
|
//
|
|
// NAMED RESIDUAL: the RECORD side of the same rule -- a timed-out `rm`
|
|
// whose write DID land must still be recorded and undone -- is NOT pinned
|
|
// here. Whether that write survives the in-process kill is not
|
|
// deterministic (driven: it lands under a shell `timeout`, and did not
|
|
// under execGit's spawnSync bound), so an assertion on it would read as
|
|
// coverage and never run. It is driven by hand instead.
|
|
seedMove();
|
|
const before = git(['ls-files', '-s', '--', PENDING]);
|
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
fs.writeFileSync(path.join(hooksDir, 'post-index-change'), '#!/bin/sh\nsleep 12\n', { mode: 0o755 });
|
|
const emptyConfig = path.join(tmpDir, 'empty.gitconfig');
|
|
fs.writeFileSync(emptyConfig, '');
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs: close a todo', '--files-removed', '.planning/todos/pending/mine.md'],
|
|
tmpDir,
|
|
{ GIT_CONFIG_GLOBAL: emptyConfig, GIT_CONFIG_NOSYSTEM: '1' },
|
|
);
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.committed, false, result.output);
|
|
// Consistency only, NOT the control: whichever way the killed write went,
|
|
// the call must leave the index coherent. See the residual note above.
|
|
assert.strictEqual(git(['ls-files', '-s', '--', PENDING]), before, 'the index must be coherent after a timed-out removal');
|
|
// THE CONTROL: the restore succeeded, so nothing may claim otherwise.
|
|
assert.strictEqual(
|
|
(parsed.failures || []).filter(f => /could NOT be restored/.test(f.error)).length, 0,
|
|
`the index was restored, so no disclosure may fire; failures were ${JSON.stringify(parsed.failures)}`,
|
|
);
|
|
});
|
|
|
|
test('a restored non-ASCII path is recognised as restored, not reported as a failure', () => {
|
|
// The restore verification reads the index back, so it must read it with
|
|
// `-z`: core.quotePath renders café.md as "caf\\303\\251.md", which never
|
|
// equals the raw path, and an EXACTLY restored entry then read as not
|
|
// restored -- the same quoting defect this PR already fixed for preStaged.
|
|
fs.mkdirSync(path.join(tmpDir, PENDING), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'seed.md'), 'seed\n');
|
|
git(['add', '.planning/']);
|
|
git(['commit', '-q', '-m', 'seed todo']);
|
|
// Index-only and absent from disk: the call stages the removal, records
|
|
// nothing, and must restore -- the path the verification runs on.
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'café.md'), 'cafe\n');
|
|
git(['add', path.join(PENDING, 'café.md')]);
|
|
const before = gitOrThrow(['ls-files', '-s', '-z', '--', PENDING], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
fs.unlinkSync(path.join(tmpDir, PENDING, 'café.md'));
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs: remove an uncommitted path', '--files-removed', '.planning/todos/pending/café.md'],
|
|
tmpDir,
|
|
);
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.reason, 'nothing_to_commit', result.output);
|
|
assert.strictEqual(
|
|
gitOrThrow(['ls-files', '-s', '-z', '--', PENDING], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }), before,
|
|
'the entry is restored exactly',
|
|
);
|
|
});
|
|
|
|
test('a path restored at a different mode is not accepted as restored', () => {
|
|
// --cacheinfo restores mode, blob and stage, so a path-only membership test
|
|
// would accept an entry that came back as something else. Driven with a
|
|
// post-index-change hook that rewrites the restored entry's mode.
|
|
fs.mkdirSync(path.join(tmpDir, PENDING), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'seed.md'), 'seed\n');
|
|
git(['add', '.planning/']);
|
|
git(['commit', '-q', '-m', 'seed todo']);
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'gone.md'), 'gone\n');
|
|
git(['add', path.join(PENDING, 'gone.md')]);
|
|
// git's `:<path>` index syntax takes a FORWARD-slash path; `path.join`
|
|
// yields backslashes on Windows and git rejects them as an ambiguous
|
|
// argument. The hook below already uses the slash form for the same reason.
|
|
const GONE = '.planning/todos/pending/gone.md';
|
|
const blob = git(['rev-parse', ':' + GONE]);
|
|
fs.unlinkSync(path.join(tmpDir, PENDING, 'gone.md'));
|
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
// Fires after the restore's index write; flips the mode so the entry that
|
|
// comes back is not the entry that was removed.
|
|
fs.writeFileSync(path.join(hooksDir, 'post-index-change'),
|
|
'#!/bin/sh\n'
|
|
+ 'git ls-files -s -- .planning/todos/pending/gone.md | grep -q "^100644" '
|
|
+ '&& git update-index --add --cacheinfo 100755,' + blob + ',.planning/todos/pending/gone.md\n',
|
|
{ mode: 0o755 });
|
|
const emptyConfig = path.join(tmpDir, 'empty.gitconfig');
|
|
fs.writeFileSync(emptyConfig, '');
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs: remove an uncommitted path', '--files-removed', '.planning/todos/pending/gone.md'],
|
|
tmpDir,
|
|
{ GIT_CONFIG_GLOBAL: emptyConfig, GIT_CONFIG_NOSYSTEM: '1' },
|
|
);
|
|
const parsed = JSON.parse(result.output);
|
|
assert.notStrictEqual(
|
|
parsed.reason, 'nothing_to_commit',
|
|
`the entry came back at a different mode, so the restore is not clean: ${result.output}`,
|
|
);
|
|
});
|
|
|
|
test('a tracked filename containing a glob removes only itself, never its neighbours',
|
|
{ skip: process.platform === 'win32' ? 'a filename containing `*` cannot exist on Windows (driven: IOException)' : false },
|
|
() => {
|
|
// An index path handed back to git is parsed as a PATHSPEC. A tracked file
|
|
// literally named `*.md` therefore GLOBS: `rm --cached` on it also removed
|
|
// the peers, only the declared entry was recorded, and the rollback then
|
|
// restored one of three -- leaving the others staged as undisclosed
|
|
// deletions. :(literal) is what makes the operand mean the file it names.
|
|
fs.mkdirSync(path.join(tmpDir, PENDING), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, '*.md'), 'wildcard\n');
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'peer.md'), 'peer\n');
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'stays.md'), 'stays\n');
|
|
git(['add', '.planning/']);
|
|
git(['commit', '-q', '-m', 'seed a wildcard-named todo']);
|
|
fs.unlinkSync(path.join(tmpDir, PENDING, '*.md'));
|
|
const before = git(['ls-files', '-s', '--', PENDING]);
|
|
|
|
// stays.md is still present, so the call fails and rolls back. Whatever the
|
|
// rollback restores, the peers must never have been touched at all.
|
|
const result = runGsdTools(
|
|
['commit', 'docs: bad declaration',
|
|
'--files-removed', '.planning/todos/pending/', '.planning/todos/pending/stays.md'],
|
|
tmpDir,
|
|
);
|
|
assert.strictEqual(JSON.parse(result.output).reason, 'staging_failed', result.output);
|
|
assert.strictEqual(
|
|
git(['diff', '--cached', '--name-status']), '',
|
|
'no unrelated deletion may be left staged by a globbing pathspec',
|
|
);
|
|
assert.strictEqual(git(['ls-files', '-s', '--', PENDING]), before, 'the index is exactly as it was');
|
|
});
|
|
|
|
test('a tracked filename containing pathspec magic is removed, and commits nothing else',
|
|
{ skip: process.platform === 'win32' ? 'a filename containing `:` cannot exist on Windows (driven: FileNotFoundException)' : false },
|
|
() => {
|
|
// The quieter half of the same defect: pathspec magic binds at the START of
|
|
// the operand, so a file named `:(literal)mine` at the repo ROOT has its
|
|
// prefix PARSED -- the rm matched nothing, exited 0, and the entry survived
|
|
// a removal this call went on to report as done. A path under a directory
|
|
// never starts with `:`, so the fixture must be top-level to reach it.
|
|
const odd = ':(literal)mine';
|
|
fs.writeFileSync(path.join(tmpDir, odd), 'mine\n');
|
|
git(['add', '--', ':(literal)' + odd]);
|
|
git(['commit', '-q', '-m', 'seed a magic-named file']);
|
|
assert.strictEqual(git(['ls-files', '--', ':(literal)' + odd]), odd, 'fixture: the odd name is tracked');
|
|
fs.unlinkSync(path.join(tmpDir, odd));
|
|
|
|
// A peer that is MODIFIED but never declared: the commit's own pathspec is
|
|
// where an unliteralised name sweeps it in.
|
|
fs.writeFileSync(path.join(tmpDir, 'peer.md'), 'peer\n');
|
|
git(['add', 'peer.md']);
|
|
git(['commit', '-q', '-m', 'seed a peer']);
|
|
fs.writeFileSync(path.join(tmpDir, 'peer.md'), 'peer, modified\n');
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs: close a todo', '--files-removed', odd],
|
|
tmpDir,
|
|
);
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.committed, true, result.output);
|
|
assert.strictEqual(
|
|
git(['ls-files', '--', ':(literal)' + odd]), '',
|
|
'the entry the caller named must actually be gone from the index',
|
|
);
|
|
// The commit must contain the declared removal and NOTHING else -- an
|
|
// undeclared `M peer.md` is the sweep this flag exists to remove.
|
|
assert.strictEqual(
|
|
git(['diff', '--no-renames', 'HEAD~1', 'HEAD', '--name-status']), 'D\t' + odd,
|
|
'only the declared removal may be committed',
|
|
);
|
|
});
|
|
|
|
test('a glob-named removal commits only itself, never an undeclared peer edit',
|
|
{ skip: process.platform === 'win32' ? 'a filename containing `*` cannot exist on Windows (driven: IOException)' : false },
|
|
() => {
|
|
// Literalising the STAGING is not enough: `git commit -- <paths>` takes the
|
|
// same paths as a pathspec, so a tracked file named `*.md` swept a MODIFIED
|
|
// peer into the commit the caller never declared -- the sweep this flag
|
|
// exists to remove, arriving one step after staging.
|
|
fs.mkdirSync(path.join(tmpDir, PENDING), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, '*.md'), 'wildcard\n');
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'peer.md'), 'peer\n');
|
|
git(['add', '.planning/']);
|
|
git(['commit', '-q', '-m', 'seed a wildcard-named todo']);
|
|
fs.unlinkSync(path.join(tmpDir, PENDING, '*.md'));
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'peer.md'), 'peer, modified\n');
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs: close a todo', '--files-removed', '.planning/todos/pending/'],
|
|
tmpDir,
|
|
);
|
|
assert.strictEqual(JSON.parse(result.output).committed, true, result.output);
|
|
assert.strictEqual(
|
|
git(['diff', '--no-renames', 'HEAD~1', 'HEAD', '--name-status']),
|
|
'D\t' + path.join(PENDING, '*.md'),
|
|
'only the declared removal may be committed',
|
|
);
|
|
assert.match(porcelain(PENDING), /^ M \.planning\/todos\/pending\/peer\.md$/m, "the peer's edit stays uncommitted");
|
|
});
|
|
|
|
test('an intent-to-add entry with a glob name keeps its intent flag',
|
|
{ skip: process.platform === 'win32' ? 'a filename containing `*` cannot exist on Windows (driven: IOException)' : false },
|
|
() => {
|
|
// The intent-to-add probe is a `diff --cached` over the path, so an
|
|
// unliteralised glob name matched a STAGED PEER instead of itself, the
|
|
// entry was misclassified as ordinary content, removed, and then restored
|
|
// by --cacheinfo -- which cannot restore the intent flag. It came back as a
|
|
// real staged addition.
|
|
fs.mkdirSync(path.join(tmpDir, PENDING), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'peer.md'), 'peer\n');
|
|
git(['add', path.join(PENDING, 'peer.md')]); // a STAGED peer for the glob to find
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, '*.md'), 'wildcard\n');
|
|
git(['add', '-N', '--', ':(literal)' + path.join(PENDING, '*.md')]);
|
|
fs.unlinkSync(path.join(tmpDir, PENDING, '*.md'));
|
|
// OBSERVE THE FLAG, not the entry. `ls-files -v` renders an intent-to-add
|
|
// exactly like an ordinary cached entry, so comparing it cannot see the
|
|
// flag at all -- an earlier cut of this test did that and passed with the
|
|
// fix reverted. An intent-to-add is absent from `diff --cached`; losing the
|
|
// flag turns it into a real staged addition, which is what to assert on.
|
|
assert.doesNotMatch(
|
|
git(['diff', '--cached', '--name-status']), /^A\t.*\*\.md$/m,
|
|
'fixture: the intent-to-add entry is not a staged addition yet',
|
|
);
|
|
|
|
// A directory entry: the intent-to-add path must be SKIPPED, not removed.
|
|
const result = runGsdTools(
|
|
['commit', 'docs: close a todo', '--files-removed', '.planning/todos/pending/'],
|
|
tmpDir,
|
|
);
|
|
assert.ok(result.output, 'the tool produced output');
|
|
assert.doesNotMatch(
|
|
git(['diff', '--cached', '--name-status']), /^A\t.*\*\.md$/m,
|
|
'the intent-to-add entry must keep its flag -- a --cacheinfo restore turns it into a real staged addition',
|
|
);
|
|
assert.match(
|
|
git(['ls-files', '--', ':(literal)' + path.join(PENDING, '*.md')]), /\*\.md/,
|
|
'and it must still be in the index at all',
|
|
);
|
|
});
|
|
|
|
test("a nested project's rollback leaves the caller's own staged work alone", (t) => {
|
|
// `diff --cached` prints REPO-relative paths whatever the cwd, while
|
|
// stagedPaths holds the caller's cwd-relative names. In a project nested
|
|
// inside its repo the two name spaces never intersect, so `preStaged`
|
|
// matched NOTHING and the rollback unstaged everything -- including work
|
|
// the caller had staged themselves, which is precisely what preStaged
|
|
// exists to protect. Pre-existing: it governs the --files side too.
|
|
const repo = createTempGitProject();
|
|
t.after(() => cleanup(repo));
|
|
const proj = path.join(repo, 'sub');
|
|
const pending = path.join(proj, PENDING);
|
|
fs.mkdirSync(pending, { recursive: true });
|
|
fs.writeFileSync(path.join(pending, 'mine.md'), 'mine\n');
|
|
fs.writeFileSync(path.join(pending, 'peer.md'), 'peer\n');
|
|
fs.writeFileSync(path.join(pending, 'stays.md'), 'stays\n');
|
|
const g = (args) => gitOrThrow(args, { cwd: repo, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
g(['add', 'sub']);
|
|
g(['commit', '-q', '-m', 'seed a nested project']);
|
|
// The caller stages their OWN work: a deletion and a modification.
|
|
fs.unlinkSync(path.join(pending, 'mine.md'));
|
|
g(['add', '-A', '--', 'sub/.planning/todos/pending/mine.md']);
|
|
fs.writeFileSync(path.join(pending, 'peer.md'), 'peer, modified\n');
|
|
g(['add', 'sub/.planning/todos/pending/peer.md']);
|
|
const before = g(['diff', '--cached', '--name-status']);
|
|
|
|
// stays.md is present, so the declaration is contradictory and the call
|
|
// rolls back. The rollback must not touch what the caller staged.
|
|
const result = runGsdTools(
|
|
['commit', 'docs: bad declaration',
|
|
'--files-removed', '.planning/todos/pending/', '.planning/todos/pending/stays.md'],
|
|
proj,
|
|
);
|
|
assert.strictEqual(JSON.parse(result.output).reason, 'staging_failed', result.output);
|
|
assert.strictEqual(
|
|
g(['diff', '--cached', '--name-status']), before,
|
|
"the caller's own staged deletion and modification must survive the rollback",
|
|
);
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
test('a file that reappears between the absence check and the rm is refused, and the rollback restores its entry', () => {
|
|
// The window this PR's own headline scenario names: a concurrent session
|
|
// recreates the path after this call judged it absent. Driven
|
|
// deterministically with a post-index-change hook, which git fires the
|
|
// moment `rm --cached` writes the index -- the hook puts the file back
|
|
// exactly then, so the re-check after the mutation must catch it.
|
|
seedMove();
|
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(hooksDir, 'post-index-change'),
|
|
'#!/bin/sh\n'
|
|
+ 'git ls-files --error-unmatch -- .planning/todos/pending/mine.md >/dev/null 2>&1 '
|
|
+ '|| cp .planning/todos/completed/mine.md .planning/todos/pending/mine.md\n',
|
|
{ mode: 0o755 },
|
|
);
|
|
// Pin the hook location against a host core.hooksPath (#3901 shape).
|
|
const emptyConfig = path.join(tmpDir, 'empty.gitconfig');
|
|
fs.writeFileSync(emptyConfig, '');
|
|
const head = git(['rev-parse', 'HEAD']);
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs: close a todo', '--files', '.planning/todos/completed/mine.md', '--files-removed', '.planning/todos/pending/mine.md'],
|
|
tmpDir,
|
|
{ GIT_CONFIG_GLOBAL: emptyConfig, GIT_CONFIG_NOSYSTEM: '1' },
|
|
);
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.committed, false, result.output);
|
|
assert.strictEqual(parsed.reason, 'staging_failed');
|
|
assert.strictEqual(parsed.file, '.planning/todos/pending/mine.md');
|
|
assert.match(parsed.error, /reappeared on disk/);
|
|
assert.strictEqual(git(['rev-parse', 'HEAD']), head, 'nothing may be committed under a message that declared the path removed');
|
|
assert.strictEqual(git(['diff', '--cached', '--name-only']), '', 'the addition is unstaged and the removed entry is back');
|
|
assert.ok(fs.existsSync(path.join(tmpDir, PENDING, 'mine.md')), 'the hook did put the file back (the window was exercised)');
|
|
assert.match(git(['ls-files', '--', PENDING]), /mine\.md/, 'the index entry this call removed is restored');
|
|
});
|
|
|
|
test('the rollback restores a caller-pre-staged blob at a removed path exactly, not HEAD\'s version', () => {
|
|
seedMove();
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'present.md'), 'present\n');
|
|
git(['add', path.join(PENDING, 'present.md')]);
|
|
git(['commit', '-q', '-m', 'seed a present todo']);
|
|
// The caller staged an edit to mine.md at its OLD path (index only, HEAD
|
|
// still holds the seed blob), then moved the file and declared the old
|
|
// path removed; a `git reset` rollback would put HEAD's blob back,
|
|
// silently discarding the staged edit.
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'mine.md'), 'mine, edited and staged\n');
|
|
git(['add', path.join(PENDING, 'mine.md')]);
|
|
const staged = git(['ls-files', '-s', '--', path.join(PENDING, 'mine.md')]);
|
|
assert.notEqual(staged, git(['ls-tree', 'HEAD', '--', path.join(PENDING, 'mine.md')]).replace(/\t/, ' '), 'fixture: the staged blob must differ from HEAD');
|
|
fs.unlinkSync(path.join(tmpDir, PENDING, 'mine.md'));
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs: bad declaration', '--files-removed', '.planning/todos/pending/mine.md', '.planning/todos/pending/present.md'],
|
|
tmpDir,
|
|
);
|
|
assert.strictEqual(JSON.parse(result.output).reason, 'staging_failed', result.output);
|
|
assert.strictEqual(git(['ls-files', '-s', '--', path.join(PENDING, 'mine.md')]), staged, 'the pre-staged blob survives the rollback');
|
|
});
|
|
|
|
test('a symlink to a directory is one tracked path, not a directory entry', () => {
|
|
// Review of #4253 read the `lstatSync(...).isDirectory()` test as a defect
|
|
// because it does not follow symlinks. It is deliberate, and following the
|
|
// link would be the bug: git tracks a symlink as a single blob (mode
|
|
// 120000) and does NOT traverse it, so the tracked paths "under" it live
|
|
// at the REAL directory and were never named by the caller. Treating the
|
|
// link as a directory entry would stage those -- the directory sweep
|
|
// #4208 exists to remove -- while the entry the caller DID name still sat
|
|
// present on disk, contradicting its own declaration.
|
|
fs.mkdirSync(path.join(tmpDir, PENDING, 'real'), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpDir, PENDING, 'real', 'a.md'), 'a\n');
|
|
fs.symlinkSync('real', path.join(tmpDir, PENDING, 'link'));
|
|
git(['add', '.planning/']);
|
|
git(['commit', '-q', '-m', 'seed a symlinked dir']);
|
|
|
|
// The premise, driven rather than asserted: one path, and git does not
|
|
// traverse it.
|
|
assert.match(git(['ls-files', '-s', '--', path.join(PENDING, 'link')]), /^120000 /, 'git tracks the symlink itself');
|
|
assert.strictEqual(git(['ls-files', '--', path.join(PENDING, 'link') + '/']), '', 'git does not traverse the symlink');
|
|
|
|
const head = git(['rev-parse', 'HEAD']);
|
|
const result = runGsdTools(
|
|
['commit', 'docs: remove a symlinked dir', '--files-removed', '.planning/todos/pending/link'],
|
|
tmpDir,
|
|
);
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.reason, 'staging_failed', result.output);
|
|
assert.match(parsed.error, /still present on disk/);
|
|
assert.strictEqual(git(['rev-parse', 'HEAD']), head, 'nothing may be committed');
|
|
assert.match(git(['ls-files', '--', PENDING]), /real\/a\.md/, 'the path behind the link is untouched -- the caller never named it');
|
|
});
|
|
|
|
});
|
|
|
|
// RULESET.TESTS.property-based-testing: the two-list commit parser is a real
|
|
// parser (split argv into two lists by boundary flags, skip embedded boolean
|
|
// flags, merge repeated occurrences), and its edge cases were already the
|
|
// subject of a review round. The example cases above pin the shapes that broke;
|
|
// this pins the invariant they are instances of, over interleavings nobody
|
|
// enumerated.
|
|
describe('commit --files/--files-removed: the two-list parser upholds its partition invariant (#4208 review)', () => {
|
|
const LIST = [...COMMIT_LIST_FLAGS];
|
|
// The alphabet a real invocation draws from: positionals, both list flags,
|
|
// and the boolean flags that may sit inside a run without ending it.
|
|
const token = fc.oneof(
|
|
fc.constantFrom('a', 'b', 'c', 'd'),
|
|
fc.constantFrom(...LIST),
|
|
fc.constantFrom('--amend', '--no-verify', '--raw'),
|
|
);
|
|
const argv = fc.array(token, { minLength: 0, maxLength: 10 })
|
|
.map(rest => ['commit', ...rest]);
|
|
|
|
// The invariant, stated independently of the implementation: walking argv
|
|
// left to right, a list flag opens a run that every later positional joins
|
|
// until the next list flag; a boolean flag is transparent; a positional
|
|
// before any list flag belongs to the message, not to a list.
|
|
function partition(args) {
|
|
const out = Object.fromEntries(LIST.map(f => [f, []]));
|
|
let open = null;
|
|
for (const t of args.slice(1)) {
|
|
if (COMMIT_LIST_FLAGS.has(t)) { open = t; continue; }
|
|
if (t.startsWith('--')) continue;
|
|
if (open !== null) out[open].push(t);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
test('every positional lands in exactly the run that is open at it, whatever the flag order or count', () => {
|
|
fc.assert(fc.property(argv, (args) => {
|
|
const expected = partition(args);
|
|
for (const flag of LIST) {
|
|
assert.deepStrictEqual(collectListFlagValues(args, flag), expected[flag]);
|
|
}
|
|
return true;
|
|
}), { numRuns: 500 });
|
|
});
|
|
|
|
test('no positional after the first list flag is dropped, and none is claimed by both lists', () => {
|
|
fc.assert(fc.property(argv, (args) => {
|
|
const first = args.findIndex((a, i) => i > 0 && COMMIT_LIST_FLAGS.has(a));
|
|
if (first === -1) return true;
|
|
const afterFirst = args.slice(first + 1).filter(a => !a.startsWith('--'));
|
|
const collected = LIST.flatMap(f => collectListFlagValues(args, f));
|
|
// Multiset equality: every such positional is collected exactly once.
|
|
assert.deepStrictEqual([...collected].sort(), [...afterFirst].sort());
|
|
return true;
|
|
}), { numRuns: 500 });
|
|
});
|
|
|
|
test('with --files-removed absent the parse is the pre-#4208 slice-to-end parse', () => {
|
|
// The compatibility half: the only intended change to an invocation that
|
|
// never names the second list is that a second list flag now exists.
|
|
const legacy = fc.array(
|
|
fc.oneof(fc.constantFrom('a', 'b', 'c', 'd'), fc.constantFrom('--files'), fc.constantFrom('--amend', '--no-verify')),
|
|
{ minLength: 0, maxLength: 8 },
|
|
).map(rest => ['commit', ...rest]);
|
|
fc.assert(fc.property(legacy, (args) => {
|
|
const i = args.indexOf('--files');
|
|
const old = i === -1 ? [] : args.slice(i + 1).filter(a => !a.startsWith('--'));
|
|
assert.deepStrictEqual(collectListFlagValues(args, '--files'), old);
|
|
return true;
|
|
}), { numRuns: 500 });
|
|
});
|
|
});
|