From d9bc7cdd93fb9a4d8e68f676f71281fbf2c53093 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sat, 3 Oct 2026 11:42:54 +0200 Subject: [PATCH] (changes): run a local status() one git call at a time status(), git diff --numstat and git diff --cached --numstat ran in parallel. Each may refresh the index and rename a new .git/index over the old one; on Windows a concurrent git then fails with "index file open failed: Permission denied" and the panel showed an error. The remote transport keeps its parallel calls. Closes #421 --- .ai/contexts/changes-view.md | 14 ++++++++++++++ CHANGELOG.md | 2 ++ git-changes-runner.js | 17 ++++++++++++----- test/git-changes-runner.test.js | 16 ++++++++++++++++ 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/.ai/contexts/changes-view.md b/.ai/contexts/changes-view.md index c0d00888..f24a2c47 100644 --- a/.ai/contexts/changes-view.md +++ b/.ai/contexts/changes-view.md @@ -932,3 +932,17 @@ working directory busy for a moment after the call has returned, as above. `fs.rmSync`'s `maxRetries` does not cover this failure on Node 20 and 22: their recursive removal retries only after emptying a directory (`ENOTEMPTY`, `EPERM`); an `EBUSY` on the first `rmdir` of a directory is thrown at once. + +## Local reads run one at a time + +A local `status()` runs its `git status` and two `git diff` calls one after the +other. Run together, each may refresh the index and rename a new `.git/index` +over the old one while another git has it open; on Windows the loser fails +with `fatal: .git/index: index file open failed: Permission denied`, and the +panel showed that as an error (the flake behind `test/git-changes-runner-real-git.test.js`, +issue #421). `GIT_OPTIONAL_LOCKS=0` does not help: measured, `git diff` still +rewrites a stale index with it set. The remote transport keeps its parallel +calls: the host is not Windows, and a round trip per call is the cost there. +Measured 2026-10-03 on a scratch repo, four processes at once, a commit and an +edit before every round: 4 failing rounds in 400 with the three calls +parallel. diff --git a/CHANGELOG.md b/CHANGELOG.md index 44efe4f8..e17547f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ What changes for you in each release of Switchboard. How to write an entry: [doc - A single trigger is no longer typed into a dialog such as a permission prompt or a question: with `wait: "none"` (write now, the default) it holds while the CLI shows a dialog, and with `wait: "idle"` until the CLI is at its prompt, up to its `timeout_ms`; then it fails `not sent` with a `reason` that says a dialog is open instead of being written into it. `wait: "none"` still writes at once while the CLI is busy. Without a readable CLI descriptor it is written as before. Input you type yourself in the terminal is never held back. (#379) - Switchboard now checks once per host, at the first successful refresh and then every six hours (every 30 minutes while one is missing), whether `tmux` and `inotifywait` are installed. A host with `tmux` and no session running no longer shows attach as missing; a host without `tmux` no longer offers to attach to a session and opens its transcript, saying why in the tooltip; and the host's tooltip says when live updates are off because `inotifywait` is missing. On a remote host, the new-session button's tooltip now gives the reason, and Send a prompt… is disabled, with the reason, while no live session on the host reports a messaging socket. Stop is never disabled. (#218) - A trigger that gave up waiting for a session now says, in its result file's `reason`, when the session was blocked on a dialog such as a permission prompt or a question: for a single trigger, a chain's first wait, and a chain step whose turn never finished. Without a dialog the result is as before. (#379) +### Fixed +- The Changes panel no longer shows `fatal: .git/index: index file open failed: Permission denied` now and then on Windows: a session's local changes are read one git call at a time instead of three at once. (#421) ## v0.0.87 — 2026-10-02 diff --git a/git-changes-runner.js b/git-changes-runner.js index 7bd516f8..685ce2dd 100644 --- a/git-changes-runner.js +++ b/git-changes-runner.js @@ -585,11 +585,18 @@ function createGitChangesRunner({ kind, cwd, alias, exec, timeoutMs, fsOps, coun async function status() { let results; try { - results = await Promise.all([ - invoke(['status', '--porcelain=v2', '--branch', '-uall', '-z'], { maxStdoutBytes: STATUS_MAX_STDOUT_BYTES }), - invoke(['diff', '--numstat', '-z'], { maxStdoutBytes: STATUS_MAX_STDOUT_BYTES }), - invoke(['diff', '--cached', '--numstat', '-z'], { maxStdoutBytes: STATUS_MAX_STDOUT_BYTES }), - ]); + // see .ai/contexts/changes-view.md ("Local reads run one at a time") + const calls = [ + () => invoke(['status', '--porcelain=v2', '--branch', '-uall', '-z'], { maxStdoutBytes: STATUS_MAX_STDOUT_BYTES }), + () => invoke(['diff', '--numstat', '-z'], { maxStdoutBytes: STATUS_MAX_STDOUT_BYTES }), + () => invoke(['diff', '--cached', '--numstat', '-z'], { maxStdoutBytes: STATUS_MAX_STDOUT_BYTES }), + ]; + if (kind === 'local') { + results = []; + for (const call of calls) results.push(await call()); + } else { + results = await Promise.all(calls.map((call) => call())); + } } catch (err) { return { ok: false, error: err.message }; } diff --git a/test/git-changes-runner.test.js b/test/git-changes-runner.test.js index bde69151..05ac59a0 100644 --- a/test/git-changes-runner.test.js +++ b/test/git-changes-runner.test.js @@ -1681,3 +1681,19 @@ test('runner: a root lookup that prints something other than an absolute path is await createGitChangesRunner({ kind: 'remote', cwd: '/srv/app', alias: 'vps', exec }).diff('x.txt'); assert.equal(commands[1], "git -C '/srv/app' '--literal-pathspecs' 'diff' '--' 'x.txt'"); }); + +test('local runner .status(): never two git calls in flight at once — a refresh renames .git/index and a concurrent reader fails on Windows (mutation target: Promise.all)', async () => { + let inFlight = 0; + let peak = 0; + const exec = async () => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight -= 1; + return { code: 0, stdout: '', stderr: '' }; + }; + const runner = createGitChangesRunner({ kind: 'local', cwd: REPO, exec }); + const result = await runner.status(); + assert.equal(result.ok, true); + assert.equal(peak, 1); +});