Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .ai/contexts/changes-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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

Expand Down
17 changes: 12 additions & 5 deletions git-changes-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand Down
16 changes: 16 additions & 0 deletions test/git-changes-runner.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading