fix: finish an interrupted move instead of holding it as version-reverted

The in-lock VERSION check accepts a VERSION already at the target's. Adds a
session-update case for the version-reverted hold, and backdates mtimes in
the new ff-update fixtures so the barrier's recapture is not racy.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-31 19:07:00 +00:00
1 parent 8d36c21c55
commit 93d7cd0313
3 files changed
+39 -2

No files matched your search

+4 -2
View File
@@ -90,11 +90,13 @@ if [ -n "$_PF" ]; then
refuse 6 tracked-by-project "$_top tracks $_n files of the gstack install at $_rel, so every checkout there puts old gstack files back (VERSION frozen, #1022); nothing was changed; fix: git -C $(ff_quote "$_top") --literal-pathspecs rm -r -q --cached -- $(ff_quote "$_rel"), add /$_ign/ to $(ff_quote "$_top/.gitignore") and commit both on every branch you check out (or vendor gstack as a git submodule), then git -C $(ff_quote "$DIR") stash push -m 'files reverted by the project' (keeps every edit) and re-run" 4000
fi
# A VERSION whose first line differs from HEAD's. Any unmerged file is left to
# the rescue below; the full update runs this inside the lock.
# the rescue below; the full update runs this inside the lock, where a VERSION
# already at the target's (an interrupted move) is finished, not held.
version_check() {
g cat-file -e HEAD:VERSION 2>/dev/null && [ -z "$(g ls-files -u 2>/dev/null)" ] || return 0
_have=$(head -1 VERSION 2>/dev/null | tr -d '\r') _want=$(g show HEAD:VERSION 2>/dev/null | head -1 | tr -d '\r')
[ "$_have" = "$_want" ] && return 0
[ -n "$1" ] && [ "$_have" = "$(g show "$1:VERSION" 2>/dev/null | head -1 | tr -d '\r')" ] && return 0
refuse 6 version-reverted "VERSION says ${_have:-nothing} but the checkout is at $_want: files changed outside git (an enclosing project's checkout puts old ones back, #1022, or a hand edit); nothing was changed; fix: git -C $(ff_quote "$DIR") stash push -m 'files reverted by the project' (keeps every edit), then re-run" 4000
}
[ "$PREFLIGHT" -eq 1 ] && { version_check; exit 0; }
@@ -169,7 +171,7 @@ case "$_beat" in ''|*[!0-9]*|0) _beat=300 ;; esac
HB_PID=$!
# ── Inside the lock ──
version_check
version_check "$TARGET"
HEAD_OID=$(g rev-parse -q --verify 'HEAD^{commit}' 2>/dev/null) || refuse 2 not-a-checkout "$DIR has no HEAD commit"
TMP=$(mktemp -d "${TMPDIR:-/tmp}/gstack-ff-XXXXXX" 2>/dev/null) || refuse 3 autostash-unrecoverable "cannot create a temp directory under ${TMPDIR:-/tmp}; nothing was changed"
g ls-files -u -z > "$TMP/unmerged.raw" 2>"$TMP/err" || refuse 5 index-unreadable "cannot read the index of $DIR ($(head -c 200 "$TMP/err")); nothing was changed"
+17
View File
@@ -74,6 +74,8 @@ function ff(fx: Fx, rev = 'origin/main', extra: Record<string, string> = {}, bin
}
const read = (fx: Fx, f: string) => fs.readFileSync(path.join(fx.install, f), 'utf8');
const write = (fx: Fx, f: string, body: string) => fs.writeFileSync(path.join(fx.install, f), body);
/** Moves mtimes 10 s back so no entry is racily clean against the index (a same-second edit can trip the barrier's recapture). */
const backdate = (fx: Fx, ...files: string[]) => { const s = Date.now() / 1000 - 10; for (const f of files) fs.utimesSync(path.join(fx.install, f), s, s); };
const rescueRef = (out: string) => out.match(/^rescued [0-9a-f]+ [0-9a-f]+ (refs\/gstack\/rescue\/[0-9a-f]{40}) unmerged=([01])$/m);
function makeUnmerged(fx: Fx, file: string) {
const blob = (body: string) => execFileSync('git', ['hash-object', '-w', '--stdin'], { cwd: fx.install, input: body, encoding: 'utf8', timeout: 30_000 }).trim();
@@ -344,6 +346,7 @@ describe.skipIf(process.platform === 'win32')('gstack-ff-update: moving the tree
const fx = makeFixture();
makeUnmerged(fx, 'notes.txt');
write(fx, 'VERSION', '0.9.0\n');
backdate(fx, 'VERSION', 'notes.txt');
const mixed = ff(fx, release(fx, { 'other.txt': 'x\n' }));
expect(mixed.status, mixed.stdout + mixed.stderr).toBe(0);
expect(mixed.stdout).toMatch(/^rescued .* unmerged=1/);
@@ -367,6 +370,20 @@ describe.skipIf(process.platform === 'win32')('gstack-ff-update: moving the tree
}
});
// Value: protects=a move interrupted after the tree was written but before HEAD moved still finishes on the next run, not held as
// version-reverted; fails_when=the in-lock check ignores the target's VERSION; why_new=review pass 2; seam=none
test('an interrupted move (tree at the target, HEAD old) finishes instead of holding', () => {
const fx = makeFixture();
const target = release(fx, { 'notes.txt': 'top UPSTREAM\nmiddle\nbottom\n' });
execFileSync('bash', ['-c', `git archive ${target} | tar -x`], { cwd: fx.install, timeout: 30_000 });
expect(read(fx, 'VERSION')).toBe('1.1.0\n');
backdate(fx, 'VERSION', 'notes.txt');
const r = ff(fx, target);
expect(r.status, r.stdout + r.stderr).toBe(0);
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
expect(spawnSync('bash', [FF, '--preflight', fx.install], { encoding: 'utf8', env: isolatedEnv(fx.base), timeout: 60_000 }).status).toBe(0);
});
test('refusals: not a fast-forward, an unresolvable target, and a missing checkout', () => {
const fx = makeFixture();
write(fx, 'VERSION', 'local\n');
+18
View File
@@ -200,6 +200,24 @@ describe.skipIf(process.platform === 'win32')('gstack-session-update: held-updat
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(target);
});
// Value: protects=a VERSION behind HEAD with no enclosing project holds (not rescued) and its preflight prints the stash fix;
// fails_when=session-update's exit-6 arm drops version-reverted; why_new=review pass 2; seam=none
test('version-reverted: the update holds with HEAD unchanged; the preflight prints the stash fix', async () => {
const fx = makeFixture();
const old = git(fx.install, 'rev-parse', 'HEAD');
release(fx, 'top UPSTREAM\nmiddle\nbottom\n');
fs.writeFileSync(path.join(fx.install, 'VERSION'), '0.0.1\n');
spawnSync('bash', [SCRIPT], { encoding: 'utf8', env: env(fx), timeout: 20_000 });
await waitFor(fx, /Z HELD version-reverted: /);
expect(git(fx.install, 'rev-parse', 'HEAD')).toBe(old);
expect(git(fx.install, 'for-each-ref', 'refs/gstack/rescue')).toBe('');
const line = banner(fx).trim();
expect(line).toStartWith('gstack auto-update: update held (version-reverted: VERSION says 0.0.1 but the checkout is at 1.0.0');
const r = run(fx, line.match(/The full fix: (bash .*?)\. https:/)![1]);
expect(r.status).toBe(6);
expect(r.stdout).toContain(`git -C ${quoted(fx.install)} stash push`);
});
test('every anchor the lines link to exists', () => {
const src = fs.readFileSync(SCRIPT, 'utf8');
const anchors = [...src.matchAll(/troubleshooting\.md#([a-z0-9-]+)/g)].map((m) => m[1]!);