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
250 changes: 247 additions & 3 deletions .ai/contexts/changes-view.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions .ai/contexts/ipc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ refresh triggers): `.ai/contexts/changes-view.md`. User-facing: `docs/changes-vi

| IPC | Args | Returns | Notes |
|---|---|---|---|
| `git-changes-status` | `(sessionId)` | `{ok, branch, files, totals} \| {ok:false, error}` | `git status --porcelain=v2 --branch` + `git diff --numstat` + `git diff --cached --numstat`, merged by `git-changes.js`'s `mergeChanges()`. |
| `git-changes-diff` | `(sessionId, filePath, staged)` | `{ok, content, truncated} \| {ok:false, error}` | `git diff [--cached] -- <filePath>`, capped at 512 KB. |
| `git-changes-status` | `(sessionId)` | `{ok, branch, files, totals, untrackedCollapsed} \| {ok:false, 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`. |
| `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"). |

### Misc

Expand Down Expand Up @@ -157,7 +157,7 @@ Every handler that takes a renderer-supplied path or derives a spawn location fr
| `add-project` / `remap-project` | none on the probe (`fs.statSync`/`fs.existsSync`/`fs.lstatSync`); the actual write is confined through `encodeProjectPath` | existence/type oracle only — inherent to the feature (both accept an arbitrary disk location by design), not cheaply fixable without breaking it |
| `open-terminal` (`preLaunchCmd`) | `validatePreLaunchCmd` (`pre-launch-cmd-guard.js`) | not a path guard — a character allowlist on a raw-shell-by-design string (the documented prefix's character set plus its analogues: `env VAR=val`, `doas`, an absolute binary path); a denylist here proved incomplete (process substitution `<(...)`/`>(...)` needed none of the blocked characters), so this is closed by construction instead of by enumeration. Known cost: bare `$VAR` expansion and quoted arguments, both previously accepted, are now refused |
| `read-session-jsonl` / `read-subagent-jsonl` / `start-subagent-watch` / `create-schedule-session` | none directly — path is derived from a SQLite key or built via `encodeProjectPath`, not taken verbatim from the renderer | out of scope for a path guard; flag if a renderer-controlled string is ever found reaching the derivation unencoded |
| `git-changes-diff` | `isSafeGitPath` (`git-changes-runner.js`) | not a filesystem path — a git pathspec relative to an arbitrary (possibly remote) cwd; see `.ai/contexts/changes-view.md` ("Quoting rule") for why this is a denylist, not an allowlist |
| `git-changes-diff` | `isSafeGitPath`, or `isSafeNoIndexPath` + containment when `untracked` (`git-changes-runner.js`) | a git pathspec relative to an arbitrary (possibly remote) cwd; see `.ai/contexts/changes-view.md` ("Quoting rule") for why this is a denylist, not an allowlist. The untracked variant is a real filesystem operand of `git diff --no-index`, which has no repository-boundary check of its own: on top of the syntactic guard it is resolved with `realpath`/`stat` against the resolved cwd (local) or checked against `git ls-files --others` (remote), git receives the guard's operand rather than the caller's, and the returned diff must name that same path in its `diff --git` line — see "Untracked files" in the same doc |

### Non-obvious behaviors

Expand Down
26 changes: 25 additions & 1 deletion docs/changes-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,33 @@ Click the **Changes** button in the terminal header, next to the stop button. Cl

- A header line: `N files changed +A −B`, plus the current branch and how far it is ahead/behind its upstream.
- One row per changed file: a state letter (`M` modified, `A` added, `D` deleted, `R`/`C` renamed/copied, `?` untracked), its path, and its own `+added −deleted` line counts.
- Clicking a row opens a read-only diff for that file. Untracked files show a note instead of a diff — `git diff` never reports them.
- Clicking a row opens a read-only diff for that file, including an untracked one — a brand-new file shows up as an all-additions diff. A binary file shows a one-line note instead of its bytes.
- A brand-new directory is listed file by file, not as a single folder row.
- A **Refresh** button for a manual pull.

### Very large working trees

The list shows at most 500 rows, then a `+N more files not shown` line; the
header keeps counting every changed file. Changed tracked files come first, so
what the cap drops is untracked files.

A working tree with tens of thousands of untracked files — an unignored
`node_modules`, a vendored or build directory — can be more than the panel can
fetch file by file, especially over ssh. Changes then falls back to listing
untracked entries by directory, the way `git status` does by default, and says
so under the header. Your tracked changes are unaffected.

### Counts for new files

Git reports line counts for tracked files only, so an untracked file's row
starts without any, and the header's `+A −B` does not include it yet. Click the
row once: its diff is fetched, the row gets its `+added −0`, and the header
total grows by the same amount. This is deliberate — counting every new file up
front would mean running one extra git command per untracked file on every
refresh (and one ssh round-trip each, for a remote session), which a repo with a
large untracked tree would feel. Refreshing resets them, since the files may
have changed since.

## What it doesn't do

- No staging, committing, or reverting from the UI — this is a viewer, not a git client.
Expand Down
157 changes: 146 additions & 11 deletions git-changes-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
'use strict';

const { execFile } = require('child_process');
const fs = require('fs');
const path = require('path');
const { defaultRunRemoteCommand } = require('./remote-attach');
const { parseStatusPorcelainV2, parseNumstat, mergeChanges } = require('./git-changes');
const { parseStatusPorcelainV2, parseNumstat, mergeChanges, countNewFileDiffAdditions, diffHeaderNamesPath } = require('./git-changes');

const DEFAULT_LOCAL_TIMEOUT_MS = 10_000;
const DEFAULT_REMOTE_TIMEOUT_MS = 20_000;
Expand All @@ -24,14 +26,81 @@ function isSafeCwd(cwd) {
return isSafeShellArg(cwd);
}

function hasDotDotSegment(p) {
return p.split(/[/\\]/).includes('..');
}

// Denylist plus a leading-':' shape check — see .ai/contexts/changes-view.md ("Quoting rule").
function isSafeGitPath(p) {
if (!isSafeShellArg(p)) return false;
if (p.includes('..')) return false;
if (hasDotDotSegment(p)) return false;
if (p[0] === ':') return false;
return true;
}

// `git diff --no-index` operands are filesystem paths, not pathspecs — see .ai/contexts/changes-view.md ("Untracked files")
const NO_INDEX_EMPTY_SIDE = '/dev/null';

function isSafeNoIndexPath(p) {
if (!isSafeGitPath(p)) return false;
if (p[0] === '-') return false;
if (p[0] === '/' || p[0] === '\\') return false;
if (/^[A-Za-z]:/.test(p)) return false;
return true;
}

// fs seam — injected in tests, real fs in production (same pattern as remote-attach.js's spawnFn)
const DEFAULT_FS_OPS = {
realpath: (p) => fs.realpathSync.native(p),
lstat: (p) => fs.lstatSync(p),
stat: (p) => fs.statSync(p),
};

function isInsideRoot(root, candidate, pathOps) {
if (candidate === root) return true;
const rel = pathOps.relative(root, candidate);
if (rel === '') return true;
return rel !== '..' && !rel.startsWith('..' + pathOps.sep) && !pathOps.isAbsolute(rel);
}

// git spells every path with forward slashes — see .ai/contexts/changes-view.md ("Untracked files")
function toGitPath(p, pathOps) {
return pathOps.sep === '/' ? p : p.split(pathOps.sep).join('/');
}

// git follows a symlink to a directory — see .ai/contexts/changes-view.md ("Untracked files")
function leafSymlinkIsDiffable(resolved, fsOps) {
let target;
try {
target = fsOps.stat(resolved);
} catch {
return true;
}
return target.isFile();
}

// Containment for a --no-index operand — see .ai/contexts/changes-view.md ("Untracked files")
function resolveLocalNoIndexOperand(cwd, filePath, fsOps = DEFAULT_FS_OPS, pathOps = path) {
if (!isSafeNoIndexPath(filePath)) return null;

try {
const root = fsOps.realpath(cwd);
const absolute = pathOps.resolve(root, filePath);
const parent = fsOps.realpath(pathOps.dirname(absolute));
if (!root || !parent || !isInsideRoot(root, parent, pathOps)) return null;

const resolved = pathOps.join(parent, pathOps.basename(absolute));
const stat = fsOps.lstat(resolved);
if (!stat.isFile() && !stat.isSymbolicLink()) return null;
if (stat.isSymbolicLink() && !leafSymlinkIsDiffable(resolved, fsOps)) return null;

const operand = toGitPath(pathOps.relative(root, resolved), pathOps);
return isSafeNoIndexPath(operand) ? operand : null;
} catch {
return null;
}
}

// --literal-pathspecs on every invocation — see .ai/contexts/changes-view.md ("Quoting rule").
function buildGitArgs(args) {
return ['--literal-pathspecs', ...args];
Expand Down Expand Up @@ -88,8 +157,14 @@ function firstError(result) {
return (result.stderr || '').trim() || `git exited with code ${result.code}`;
}

// {kind, cwd, alias, exec, timeoutMs} — see .ai/contexts/changes-view.md ("Runner interface")
function createGitChangesRunner({ kind, cwd, alias, exec, timeoutMs } = {}) {
// The two stdout-cap overruns: the remote transport's own, and execFile's maxBuffer — see .ai/contexts/changes-view.md ("Untracked files")
function isStdoutCapFailure(result) {
const stderr = (result && result.stderr) || '';
return /stdout exceeded \d+ bytes/.test(stderr) || /maxBuffer length exceeded/i.test(stderr);
}

// {kind, cwd, alias, exec, timeoutMs, fsOps} — see .ai/contexts/changes-view.md ("Runner interface")
function createGitChangesRunner({ kind, cwd, alias, exec, timeoutMs, fsOps } = {}) {
if (kind !== 'local' && kind !== 'remote') {
throw new Error('createGitChangesRunner requires kind "local" or "remote"');
}
Expand Down Expand Up @@ -119,28 +194,86 @@ function createGitChangesRunner({ kind, cwd, alias, exec, timeoutMs } = {}) {
let results;
try {
results = await Promise.all([
invoke(['status', '--porcelain=v2', '--branch', '-z'], { maxStdoutBytes: STATUS_MAX_STDOUT_BYTES }),
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 }),
]);
} catch (err) {
return { ok: false, error: err.message };
}
const [st, unstagedNum, stagedNum] = results;
if (st.code !== 0) return { ok: false, error: firstError(st) };
let [st] = results;
const [, unstagedNum, stagedNum] = results;
if (unstagedNum.code !== 0) return { ok: false, error: firstError(unstagedNum) };
if (stagedNum.code !== 0) return { ok: false, error: firstError(stagedNum) };

// A repo too large for -uall falls back to git's collapsed listing — see .ai/contexts/changes-view.md ("Untracked files")
let untrackedCollapsed = false;
if (st.code !== 0) {
if (!isStdoutCapFailure(st)) return { ok: false, error: firstError(st) };
let fallback;
try {
fallback = await invoke(['status', '--porcelain=v2', '--branch', '-z'], { maxStdoutBytes: STATUS_MAX_STDOUT_BYTES });
} catch {
return { ok: false, error: firstError(st) };
}
if (fallback.code !== 0) return { ok: false, error: firstError(st) };
st = fallback;
untrackedCollapsed = true;
}

const parsedStatus = parseStatusPorcelainV2(st.stdout);
const numstatUnstaged = parseNumstat(unstagedNum.stdout);
const numstatStaged = parseNumstat(stagedNum.stdout);
return { ok: true, ...mergeChanges(parsedStatus, numstatStaged, numstatUnstaged) };
return { ok: true, ...mergeChanges(parsedStatus, numstatStaged, numstatUnstaged), untrackedCollapsed };
}

// The operand git receives is the guard's own, never the caller's — see .ai/contexts/changes-view.md ("Untracked files")
async function resolveUntrackedOperand(filePath) {
if (!isSafeNoIndexPath(filePath)) return { ok: false, error: 'invalid path' };

if (kind === 'local') {
const operand = resolveLocalNoIndexOperand(cwd, filePath, fsOps || DEFAULT_FS_OPS);
return operand ? { ok: true, operand } : { ok: false, error: 'invalid path' };
}

let listed;
try {
listed = await invoke(['ls-files', '--others', '--exclude-standard', '-z', '--', filePath], { maxStdoutBytes: STATUS_MAX_STDOUT_BYTES });
} catch (err) {
return { ok: false, error: err.message };
}
if (listed.code !== 0) return { ok: false, error: firstError(listed) };
const operand = String(listed.stdout || '').split('\0')[0];
return operand === filePath ? { ok: true, operand } : { ok: false, error: 'invalid path' };
}

// `--no-index` exits 1 on a difference — see .ai/contexts/changes-view.md ("Untracked files")
async function untrackedDiff(filePath) {
const contained = await resolveUntrackedOperand(filePath);
if (!contained.ok) return { ok: false, error: contained.error };

let result;
try {
result = await invoke(['-c', 'core.quotepath=false', 'diff', '--no-index', '--', NO_INDEX_EMPTY_SIDE, contained.operand],
{ maxStdoutBytes: DIFF_MAX_STDOUT_BYTES });
} catch (err) {
return { ok: false, error: err.message };
}
if (result.code !== 0 && result.code !== 1) return { ok: false, error: firstError(result) };
const stdout = result.stdout || '';
if (!stdout && (result.stderr || '').trim()) return { ok: false, error: firstError(result) };
if (!diffHeaderNamesPath(stdout, contained.operand)) return { ok: false, error: 'invalid path' };

const { content, truncated } = truncateDiffContent(stdout, MAX_DIFF_BYTES);
const added = truncated ? null : countNewFileDiffAdditions(content);
return { ok: true, content, truncated, added, deleted: added === null ? null : 0 };
}

async function diff(path, opts = {}) {
if (!isSafeGitPath(path)) return { ok: false, error: 'invalid path' };
async function diff(filePath, opts = {}) {
if (opts.untracked) return untrackedDiff(filePath);
if (!isSafeGitPath(filePath)) return { ok: false, error: 'invalid path' };
const staged = !!opts.staged;
const args = staged ? ['diff', '--cached', '--', path] : ['diff', '--', path];
const args = staged ? ['diff', '--cached', '--', filePath] : ['diff', '--', filePath];

let result;
try {
Expand All @@ -165,6 +298,8 @@ module.exports = {
shQuote,
isSafeCwd,
isSafeGitPath,
isSafeNoIndexPath,
resolveLocalNoIndexOperand,
MAX_DIFF_BYTES,
STATUS_MAX_STDOUT_BYTES,
DIFF_MAX_STDOUT_BYTES,
Expand Down
42 changes: 41 additions & 1 deletion git-changes.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,46 @@ function parseNumstat(text) {
return result;
}

// Count additions in a new-file unified diff — see .ai/contexts/changes-view.md ("Untracked files")
function countNewFileDiffAdditions(text) {
const content = String(text || '');
if (/^Binary files /m.test(content)) return null;
let inHunk = false;
let added = 0;
for (const line of content.split('\n')) {
if (!inHunk) {
if (line.startsWith('@@')) inHunk = true;
continue;
}
if (line.startsWith('+')) added += 1;
}
return added;
}

const C_QUOTE_ESCAPES = { 7: 'a', 8: 'b', 9: 't', 10: 'n', 11: 'v', 12: 'f', 13: 'r', 34: '"', 92: '\\' };

// git's C-style path quoting, as emitted under core.quotepath=false — see .ai/contexts/changes-view.md ("Untracked files")
function gitQuotePath(p) {
let out = '"';
for (const ch of String(p)) {
const code = ch.codePointAt(0);
if (Object.prototype.hasOwnProperty.call(C_QUOTE_ESCAPES, code)) out += '\\' + C_QUOTE_ESCAPES[code];
else if (code < 0x20 || code === 0x7f) out += '\\' + code.toString(8).padStart(3, '0');
else out += ch;
}
return out + '"';
}

// A diff's first line names its file twice — see .ai/contexts/changes-view.md ("Untracked files")
function diffHeaderNamesPath(content, filePath) {
if (typeof filePath !== 'string' || !filePath) return false;
const first = String(content || '').split('\n', 1)[0];
if (!first.startsWith('diff --git ')) return false;
const operands = first.slice('diff --git '.length);
return operands === `a/${filePath} b/${filePath}`
|| operands === `${gitQuotePath('a/' + filePath)} ${gitQuotePath('b/' + filePath)}`;
}

function combineCounts(a, b) {
if ((a && a.added === null) || (b && b.added === null)) return { added: null, deleted: null };
const added = (a ? a.added || 0 : 0) + (b ? b.added || 0 : 0);
Expand Down Expand Up @@ -143,4 +183,4 @@ function mergeChanges(status, numstatStaged, numstatUnstaged) {
};
}

module.exports = { parseStatusPorcelainV2, parseNumstat, mergeChanges };
module.exports = { parseStatusPorcelainV2, parseNumstat, mergeChanges, countNewFileDiffAdditions, diffHeaderNamesPath };
6 changes: 3 additions & 3 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 @@ -457,8 +457,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 460 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 460 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 461 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 @@ -1759,13 +1759,13 @@
}
});

// filePath is a git pathspec, not a filesystem path — see .ai/contexts/changes-view.md
ipcMain.handle('git-changes-diff', async (_event, sessionId, filePath, staged) => {
// filePath is a git pathspec, or an untracked file's --no-index operand — see .ai/contexts/changes-view.md
ipcMain.handle('git-changes-diff', async (_event, sessionId, filePath, staged, untracked) => {
if (typeof filePath !== 'string' || !filePath) return { ok: false, error: 'invalid path' };
const target = resolveGitChangesTarget(sessionId);
if (!target.ok) return target;
try {
return await gitChangesRunnerFor(target).diff(filePath, { staged: !!staged });
return await gitChangesRunnerFor(target).diff(filePath, { staged: !!staged, untracked: !!untracked });
} catch (err) {
return { ok: false, error: err.message };
}
Expand Down Expand Up @@ -2283,7 +2283,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 2286 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
2 changes: 1 addition & 1 deletion preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ contextBridge.exposeInMainWorld('api', {
stopSubagentWatch: (watchId) => ipcRenderer.invoke('stop-subagent-watch', watchId),
// see .ai/contexts/changes-view.md
gitChangesStatus: (sessionId) => ipcRenderer.invoke('git-changes-status', sessionId),
gitChangesDiff: (sessionId, filePath, staged) => ipcRenderer.invoke('git-changes-diff', sessionId, filePath, staged),
gitChangesDiff: (sessionId, filePath, staged, untracked) => ipcRenderer.invoke('git-changes-diff', sessionId, filePath, staged, untracked),

// Settings
getSetting: (key) => ipcRenderer.invoke('get-setting', key),
Expand Down
Loading
Loading