diff --git a/apps/x/packages/core/src/code-mode/git/service.ts b/apps/x/packages/core/src/code-mode/git/service.ts index 17b4bfb1..370d57ca 100644 --- a/apps/x/packages/core/src/code-mode/git/service.ts +++ b/apps/x/packages/core/src/code-mode/git/service.ts @@ -67,6 +67,17 @@ export async function repoInfo(cwd: string): Promise { return { isGitRepo: true, branch, hasCommits, dirtyCount }; } +// git status/diff report paths relative to the REPO ROOT, which is not the +// session cwd when the user opened a subdirectory of a repo as their project. +// Disk reads must resolve against the root, not cwd. +async function repoToplevel(cwd: string): Promise { + try { + return (await git(cwd, ['rev-parse', '--show-toplevel'])).trim() || cwd; + } catch { + return cwd; + } +} + function stateFromPorcelain(xy: string): GitFileState { if (xy === '??') return 'untracked'; if (xy.includes('R')) return 'renamed'; @@ -75,10 +86,14 @@ function stateFromPorcelain(xy: string): GitFileState { return 'modified'; } -// Working-tree changes vs HEAD with insertion/deletion counts. Untracked files -// get their line count from disk (capped) since numstat doesn't cover them. +// Working-tree changes vs HEAD with insertion/deletion counts, scoped to the +// session directory's subtree (`-- .`): a project opened inside a bigger repo +// only shows its own changes. Result paths are repo-root-relative (git's +// porcelain format). Untracked files get their line count from disk (capped) +// since numstat doesn't cover them. export async function status(cwd: string): Promise { - const out = await git(cwd, ['status', '--porcelain=v1', '-z']); + const root = await repoToplevel(cwd); + const out = await git(cwd, ['status', '--porcelain=v1', '-z', '--', '.']); const entries: Array<{ path: string; state: GitFileState }> = []; // -z format: "XY path\0" and for renames "XY newPath\0oldPath\0" const parts = out.split('\0'); @@ -94,7 +109,7 @@ export async function status(cwd: string): Promise { const counts = new Map(); try { - const numstat = await git(cwd, ['diff', 'HEAD', '--numstat', '-z']); + const numstat = await git(cwd, ['diff', 'HEAD', '--numstat', '-z', '--', '.']); // -z numstat rows: "ins\tdel\tpath\0" (renames: "ins\tdel\0old\0new\0") const rows = numstat.split('\0'); for (let i = 0; i < rows.length; i++) { @@ -126,7 +141,7 @@ export async function status(cwd: string): Promise { deletions = counted.deletions; } else if (entry.state === 'untracked') { try { - const full = path.join(cwd, entry.path); + const full = path.join(root, entry.path); const stat = await fs.stat(full); if (stat.isFile() && stat.size <= MAX_TEXT_BYTES) { const content = await fs.readFile(full, 'utf8'); @@ -154,16 +169,33 @@ export interface FileDiff { } export async function fileDiff(cwd: string, relPath: string): Promise { + // Paths from `status` are repo-root-relative; paths clicked in the chat + // timeline are cwd-relative. Resolve whichever interpretation points at a + // real file (deleted files fall back to the root interpretation, which is + // also what `git show` uses). + const root = await repoToplevel(cwd); + let gitPath = relPath; + let full = path.join(root, relPath); + const existsAt = async (p: string) => fs.stat(p).then((s) => s.isFile()).catch(() => false); + if (!await existsAt(full)) { + const cwdFull = path.join(cwd, relPath); + if (await existsAt(cwdFull)) { + full = cwdFull; + // Realpath both sides — git reports the real toplevel, while the + // session cwd may reach it through a symlink (e.g. /tmp on macOS). + const realFull = await fs.realpath(cwdFull).catch(() => cwdFull); + gitPath = path.relative(root, realFull).split(path.sep).join('/'); + } + } let oldText = ''; try { - oldText = await git(cwd, ['show', `HEAD:${relPath}`]); + oldText = await git(cwd, ['show', `HEAD:${gitPath}`]); } catch { // untracked / newly added / no commits — diff against empty oldText = ''; } let newText = ''; try { - const full = path.join(cwd, relPath); const stat = await fs.stat(full); if (stat.size > MAX_TEXT_BYTES) { return { oldText: '', newText: '', isBinary: false, tooLarge: true };