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
147 changes: 47 additions & 100 deletions .ai/contexts/changes-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,20 +306,20 @@ counts.

## Not a repository

A session's working directory need not be inside a git work tree, and when it
is not, the Changes affordance is not offered: `#changes-toggle-btn` is
`display: none` for that session. The panel never renders a refusal for this
case, because there is nothing to refuse — the button that would produce it is
not there.

**Withdrawing a control is only correct on positive evidence.** A missing
button says nothing and offers no way to ask why, so it is the wrong answer to
every failure except the one it describes. `isWorkTree()` in
`git-changes-runner.js` therefore reports `isRepo: false` only when something
actually established that there is no work tree, and returns `{ok: false,
error}` — *not an answer* — for everything else. The renderer's
`typeof result.isRepo !== 'boolean'` guard leaves the button alone on a
non-answer, and `status()` reports its bounded message into the tab.
A session's working directory need not be inside a git work tree. The Changes
control is offered for every session regardless, and the panel reports whichever
of four answers is true: the file list when there is a work tree with changes,
`No changes` when that tree is clean, `This directory is not a git repository.`
when there is no work tree, and git's own bounded message when there **is** a
repository and git refuses to open it.

**A control that vanishes explains nothing.** Its absence is indistinguishable
from a bug and offers no way to ask why, so the affordance is unconditional and
every answer is text in the panel it opens. That puts the whole weight on
telling the last two answers apart: "there is no repository here" and "git will
not open this repository" are indistinguishable from git's exit status, and they
must not read alike in the panel — the second is a repository the user has,
usually with a one-line fix in git's own message.

**No message is ever matched.** git translates every diagnostic (this project's
own host runs it in French), so the detection reads only `git rev-parse
Expand All @@ -343,15 +343,15 @@ another user, on a mounted or NFS filesystem), a `.git` whose permissions it
cannot read, an unsupported `core.repositoryformatversion`, a `.git` file whose
gitdir is gone, and a worktree whose main repository was deleted. Every one of
those is a repository the user has, usually with a one-line fix in git's own
message — exactly the case where silently removing the panel is worse than
printing the message. `test/git-changes-runner-real-git.test.js` builds those
message — exactly the case the plain "this is not a repository" line must never
be shown for. `test/git-changes-runner-real-git.test.js` builds those
fixtures against real git, asserts each really does exit 128, and pins that none
of them produces `reason: 'not-a-repo'`.

**A cwd that is gone is ruled out before the corroboration is trusted.** The walk
below answers "no `.git` anywhere" for a path that does not exist, so a deleted
worktree outside a repository would otherwise withdraw the panel. `execFile`
happens to fail to spawn for such a cwd — code `-1`, not 128 — but the local and
worktree outside a repository would otherwise be reported as "not a git
repository". `execFile` happens to fail to spawn for such a cwd — code `-1`, not 128 — but the local and
remote transports differ here (`git -C <gone>` exits 128), so the check is an
outcome of its own rather than something left to a code that happens not to
match. It also decides the wording: `spawn git ENOENT` reads as "git is not
Expand All @@ -367,8 +367,8 @@ The corroboration is `gitEntryAtOrAbove(cwd)`: an `fs.lstat` for a `.git` entry
at the cwd and at each ancestor up to the filesystem root. It needs no process
and no locale, and it answers the one question the exit code cannot — *is there
a repository here at all*. It returns three ways, and only `false` (a walk that
reached the root seeing nothing) withdraws the panel; an `EACCES` or any other
unexpected `lstat` error is `null`, undecidable, and reports. A `.git` that
reached the root seeing nothing) is "there is no repository here"; an `EACCES`
or any other unexpected `lstat` error is `null`, undecidable, and reports. A `.git` that
exists but is broken counts as `true`: the repository is there, it is just
unreadable.

Expand All @@ -378,85 +378,32 @@ corroboration is a local filesystem walk and there is no remote equivalent that
does not either re-read git's translated message or add ssh round-trips. So a
remote session that exits 128 is never corroborated and always reports. The
practical consequence: a remote working directory that is genuinely not a
repository keeps its Changes button and shows git's own bounded message when
clicked, instead of hiding the button. That is the pre-existing behaviour, and
it is the safe side of the trade — it also means a remote cwd that is merely
unmounted no longer loses the control.

**Who asks, and when.** Two paths reach the same conclusion, and `status()` is
the cheaper of them:

- `git-changes-available` runs the probe from `switchPanel()`. The answer is
cached on that session's `filePanelState` entry (`changesAvailable`) and
applied to the button before the round trip, so a known answer never flashes a
button that does not work.
- `status()` returns `{ok: false, reason: 'not-a-repo'}` when a command failed
**and** the probe then establishes there is no work tree. The renderer treats
that exactly like an `isRepo: false` availability answer. That covers the
window between a switch and the repository disappearing under a running
session, and it means a click landing before the availability answer arrives
is handled too.

**The probe is bounded on both axes.** It is a *diagnosis, not a precondition*:
a session in a repository pays three commands per refresh, the same three as
before, pinned by `calls.length === 3`. A `-uall` run that overruns the stdout
cap does not ask either — that is a volume problem with its own fallback, and
the large repositories that hit it are the ones an extra spawn costs most. And
on the switch path:

- a session already answered `true` is **never probed again**;
- a session git **could not answer for** is never probed again either. That
answer is memoised as its own state (`CHANGES_UNANSWERED`), because a
`{ok: false}` leaves the button visible and can never change it — asking again
buys nothing and costs an ssh with a 20 s kill timer. It is not a rare shape:
a remote cwd outside a repository, a local repository git refuses, and an
unreachable host all produce it, on every activation, forever;
- **a non-answer never displaces an answer.** `CHANGES_UNANSWERED` is written
only for a session nothing has been established for yet, so the two memo
writes cannot collide. "No repository" is the one answer deliberately
re-asked, which makes it the one a transient failure — an ssh blip, a sleeping
host — can land on; overwriting it would un-hide a button for a directory that
is definitely not a repository, and then never ask again. A session that keeps
its `false` stays re-askable, so the blip costs nothing beyond that one probe;
- a second probe for a session whose first is still in flight is **dropped**
(`changesAvailabilityInFlight`), so a burst of switches cannot put a burst of
ssh children on a remote host;
- an answer is **recorded against the session it is about**, then applied to the
button only while that session is still the one on screen — on every branch,
because "a stale reply never touches the DOM" is an invariant, not a
per-branch outcome. Discarding a
correct answer because the panel had moved on would cost that session another
probe on its next activation; painting from it would paint the wrong
session's state.

Only a session that answered "no repository" is re-asked on a later switch.
That is the one answer worth re-checking — a `git init` turns it into a
repository — and it is what makes the button come back without polling
anything. The reverse transition is not tracked: a session that answered "in a
repository" keeps its button even if the repository is deleted under it, and
clicking Changes then closes the tab straight away through the `status()` path.
Re-probing every activation to catch that is exactly the cost this memo exists
to remove.

**Withdrawal reuses the tab's own close control.** `noteChangesUnavailable`
calls `toggleChangesTab(sessionId)` rather than tearing the tab down itself, so
it takes the same path a user's click on the Changes toggle takes and inherits
`confirmDiscardChangesEdits` along with it. A repository that stops being one
under an open editor therefore asks before discarding the buffer, exactly as
the toggle does.

A Changes tab has a second way out: the panel's own X
(`changesCloseBtn` → `handleClose`), which clears `currentTab` and hides the
panel directly without passing through `toggleChangesTab`, and carries its own
call to the same guard. What keeps a tab from being torn down without asking is
that **each exit is guarded**, not that they funnel into one — a new exit has to
be guarded on its own terms, and reusing an existing one is how the withdrawal
avoids being such an exit.

If the close does not happen — the user refused the discard, so the tab is still
there afterwards — the button is left visible, because hiding the control while
its tab is still open would strand the edit it is holding with no way back to
it.
repository shows git's own bounded message rather than the plain-language line,
which is the safe side of the trade — the reported message is always true, and
only its wording is coarser than a local session's.

**Who asks.** `status()` is the only production caller: it returns
`{ok: false, reason: 'not-a-repo'}` when a command failed **and**
`isWorkTree()` then establishes there is no work tree, and every other failure
carries its bounded message and no reason. The renderer turns that one reason
into the plain-language line and everything else into git's own words, so the
distinction the probe establishes is the distinction the user reads. `tab.notARepo`
carries it to both surfaces that can show it: the list renders a neutral note
rather than an error, and the notice line above an open editor states the same
fact in the same neutral colour instead of reporting a refresh that failed —
a directory with no repository did not fail at anything. `isWorkTree()` stays on the
runner's interface for the tests that pin the table above.

**The probe is a diagnosis, not a precondition.** A session in a repository pays
three commands per refresh, pinned by `calls.length === 3`; the fourth runs only on a status that already failed. A
`-uall` run that overruns the stdout cap does not ask either — that is a volume
problem with its own fallback, and the large repositories that hit it are the
ones an extra spawn costs most.

A repository that disappears under an open tab is reported into that tab on the
next refresh, and one that appears under a session that had none is listed on
the next refresh too. Nothing polls and nothing is memoised, because the answer
arrives with the status the tab was going to fetch anyway.

## Bounded error messages

Expand All @@ -472,7 +419,7 @@ A genuine failure — a permission error, a corrupt repository, a transport
problem — is still reported, in git's own words and in whatever language git
chose. Only the volume is capped. That is what "Not a repository" above leans
on: every failure the probe cannot positively explain falls back to this
message rather than to a missing button.
message rather than to the "not a git repository" line.

## cwd resolution (`git-changes-target.js`)

Expand Down
3 changes: 1 addition & 2 deletions .ai/contexts/ipc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,7 @@ design (parser, runner, quoting, cwd resolution, refresh triggers, editing):

| IPC | Args | Returns | Notes |
|---|---|---|---|
| `git-changes-available` | `(sessionId)` | `{ok:true, isRepo} \| {ok:false, error}` | `git rev-parse --is-inside-work-tree`, one invocation. Answers by exit code and the `true`/`false` token, never by message — see `.ai/contexts/changes-view.md` ("Not a repository"). `isRepo:false` is what withdraws the Changes button and is returned only on positive evidence; exit 128 is git's generic fatal code and needs a corroborating filesystem walk (local only). Everything else — a refused repository, a cwd that is gone, ssh's own 255, a thrown exec — is `{ok:false, error}`, which leaves the button alone and is memoised so the session is not asked again. |
| `git-changes-status` | `(sessionId)` | `{ok, kind, branch, files, totals, untrackedCollapsed} \| {ok:false, reason?, error}` | `git status --porcelain=v2 --branch -uall` + `git diff --numstat` + `git diff --cached --numstat`, merged by `git-changes.js`'s `mergeChanges()`. A `-uall` run too large for the transport falls back to git's default untracked mode and reports `untrackedCollapsed: true`. `kind` is `'local'` or `'remote'` — the renderer decides from it whether the panel is editable. A cwd with no work tree comes back `reason: 'not-a-repo'`; every other failure carries a bounded message and no reason. |
| `git-changes-status` | `(sessionId)` | `{ok, kind, branch, files, totals, untrackedCollapsed} \| {ok:false, reason?, error}` | `git status --porcelain=v2 --branch -uall` + `git diff --numstat` + `git diff --cached --numstat`, merged by `git-changes.js`'s `mergeChanges()`. A `-uall` run too large for the transport falls back to git's default untracked mode and reports `untrackedCollapsed: true`. `kind` is `'local'` or `'remote'` — the renderer decides from it whether the panel is editable. A cwd with no work tree comes back `reason: 'not-a-repo'` — the panel's plain-language "not a git repository" line; every other failure carries a bounded message and no reason, including a repository git refuses to open. Telling the two apart is `git rev-parse --is-inside-work-tree` plus a filesystem walk, never a message match — see `.ai/contexts/changes-view.md` ("Not a repository"). |
| `git-changes-diff` | `(sessionId, filePath, staged, untracked)` | `{ok, content, truncated, added, deleted} \| {ok:false, error}` | `git diff [--cached] -- <filePath>`, or `git diff --no-index -- /dev/null <filePath>` when `untracked`; capped at 512 KB. `added`/`deleted` are filled for an untracked file only — see `.ai/contexts/changes-view.md` ("Untracked files"). |
| `git-changes-file` | `(sessionId, filePath, {staged})` | `{ok, original, current, version, binary, truncated} \| {ok:false, error, reason}` | The content pair behind the editable diff: `git cat-file blob :<path>` (or `HEAD:<path>` when `staged`) and the working-tree file, both LF-normalised and strictly UTF-8. `version` is an opaque token the renderer hands back on save. Local sessions only. `reason` is one of `invalid-path`, `repo`, `missing`, `outside`, `symlink`, `hardlink`, `git-dir`, `sensitive`, `not-a-file`, `binary`, `too-large`, `encoding`, `mixed-eol`, `git`, `remote`. |
| `git-changes-save` | `(sessionId, filePath, content, version)` | `{ok:true, version} \| {ok:false, error, reason}` | Writes the working-tree file the guard resolved, re-applying its line endings, and returns the token for the next save. Refused with `reason:'stale'` when the file changed since `version` was issued, and with `invalid-version` when no token is passed. Local sessions only; never creates a file. |
Expand Down
43 changes: 22 additions & 21 deletions docs/changes-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,24 @@

Click the **Changes** button in the terminal header, next to the stop button. Click it again to close.

The button is only there for a session whose working directory is inside a git
repository. A session started somewhere that is not one — a scratch directory, a
notes folder — has no Changes button at all. Run `git init` there and the button
appears the next time the panel follows that session.

The reverse is looser: a session that was in a repository keeps its button for
the rest of the run even if you delete the repository under it. Clicking Changes
then closes the view again straight away. Checking for that on every click of
every session would cost a git command each time, which is not worth it for a
case that ends the moment you restart.

A repository git *refuses to open* is a different case and keeps its button. If
git will not read the repository — it is owned by another user, its permissions
are wrong, or its format is one this git does not support — Changes shows you
git's own message, which usually names the fix. The button disappears only when
there is genuinely no repository there.
Every session has the button. What the panel shows when you open it is whichever
of these is true:

- the list of changed files, when there are any;
- **No changes**, when the working tree is clean;
- **This directory is not a git repository.**, when the session was started
somewhere that is not one — a scratch directory, a notes folder. Run
`git init` there and the files are listed from the next refresh;
- git's own message, when there *is* a repository and git will not open it: it
is owned by another user, its permissions are wrong, or its format is one this
git does not support. That message usually names the fix.

The last two are deliberately not worded alike: the first is a directory you
never meant to be a repository, the second is a repository you have and cannot
read yet.

Delete the repository under a running session and the panel says so on its next
refresh.

A file link in the terminal opens here too, when it points at one of this session's changed files: the panel opens on that row, ready to edit against its diff. A link to a file the session has not touched, or to one outside its repository, opens in the plain viewer as before.

Expand Down Expand Up @@ -113,10 +115,9 @@ The same parser and the same panel render both. Only the command runner differs:
- **Local**: `git status`/`git diff` run directly against the session's real working directory (its worktree, if it has one — the same directory a `claude --resume` targets).
- **Remote**: the same commands run over the existing ssh connection to the host, against the directory recorded in that session's descriptor. No attach, no tmux — this works even for a session you've never opened a terminal tab for.

A remote working directory that is not a git repository keeps its button and
shows git's message when you click, rather than hiding the button the way a
local one does. Telling "there is no repository here" apart from "git will not
open this repository" needs to look at the directory itself, which Switchboard
can only do on this machine.
A remote working directory that is not a git repository shows git's own message
rather than the plain "not a git repository" line. Telling those two cases apart
needs a look at the directory itself, which Switchboard can only do on this
machine.

Diffs are capped at 512 KB; a diff larger than that is truncated with a note at the bottom.
11 changes: 0 additions & 11 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
}

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles');

Check warning on line 73 in main.js

View workflow job for this annotation

GitHub Actions / lint

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 73 in main.js

View workflow job for this annotation

GitHub Actions / lint

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { scanMdFiles, acceptMdFile } = require('./scan-md-files');
Expand Down Expand Up @@ -461,8 +461,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 464 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 464 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker,

Check warning on line 465 in main.js

View workflow job for this annotation

GitHub Actions / lint

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
scanFoldersViaWorker, setRemoteRoots, resolveFolderDir } = sessionCache;
const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file');

Expand Down Expand Up @@ -1757,17 +1757,6 @@
: createGitChangesRunner({ kind: 'local', cwd: target.cwd });
}

// Decides whether the Changes affordance is offered at all — see .ai/contexts/changes-view.md ("Not a repository")
ipcMain.handle('git-changes-available', async (_event, sessionId) => {
const target = resolveGitChangesTarget(sessionId);
if (!target.ok) return target;
try {
return await gitChangesRunnerFor(target).isWorkTree();
} catch (err) {
return { ok: false, error: err.message };
}
});

ipcMain.handle('git-changes-status', async (_event, sessionId) => {
const target = resolveGitChangesTarget(sessionId);
if (!target.ok) return target;
Expand Down Expand Up @@ -2392,7 +2381,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 2384 in main.js

View workflow job for this annotation

GitHub Actions / lint

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down
Loading
Loading