Skip to content

Commit 841d412

Browse files
dorlugasigalCopilot
andcommitted
fix(git): resolve diff/blame paths from repo root and scope CodeViewer state per session
git status returns repo-root-relative paths, but diff/blame/log commands were running from the session CWD (which may be a subdirectory). This caused empty diffs when the session was opened in a subdirectory of the repo. Added getGitRoot() helper to resolve the repo root and use it as CWD for git diff, blame, and log --follow commands. Also fixed CodeViewer git state (gitStatus, gitDiff, etc.) being shared across sessions in a single global Zustand store. Added bindSession() to reset all session-scoped state when switching between sessions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a5e416e commit 841d412

4 files changed

Lines changed: 77 additions & 14 deletions

File tree

package-lock.json

Lines changed: 10 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/frontend/src/components/CodeViewer/CodeViewer.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,12 @@ export default function CodeViewer({ sessionId, onClose, initialView }: CodeView
4848
setGitBlame,
4949
blameEnabled,
5050
toggleBlame,
51+
bindSession,
5152
} = useCodeViewerStore();
5253

54+
// Bind store to this session — resets all state if session changed
55+
bindSession(sessionId);
56+
5357
const [treeLoading, setTreeLoading] = useState(true);
5458
const [treeError, setTreeError] = useState<string | null>(null);
5559
const [fileLoading, setFileLoading] = useState(false);

src/frontend/src/stores/codeViewerStore.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ export interface FileTreeNode {
1818
}
1919

2020
interface CodeViewerState {
21+
// Tracks which session this state belongs to
22+
boundSessionId: string | null;
23+
2124
// State
2225
openFiles: Map<string, OpenFile>;
2326
activeFilePath: string | null;
@@ -52,9 +55,13 @@ interface CodeViewerState {
5255
setGitLog: (log: GitLog | null) => void;
5356
setDiffFile: (file: string | null) => void;
5457
toggleBlame: () => void;
58+
59+
// Bind store to a session, resetting state if the session changed
60+
bindSession: (sessionId: string) => void;
5561
}
5662

57-
export const useCodeViewerStore = create<CodeViewerState>((set) => ({
63+
export const useCodeViewerStore = create<CodeViewerState>((set, get) => ({
64+
boundSessionId: null,
5865
openFiles: new Map(),
5966
activeFilePath: null,
6067
expandedDirs: new Set(),
@@ -128,4 +135,21 @@ export const useCodeViewerStore = create<CodeViewerState>((set) => ({
128135
setGitLog: (log) => set({ gitLog: log }),
129136
setDiffFile: (file) => set({ diffFile: file }),
130137
toggleBlame: () => set((state) => ({ blameEnabled: !state.blameEnabled })),
138+
139+
bindSession: (sessionId) => {
140+
if (get().boundSessionId === sessionId) return;
141+
set({
142+
boundSessionId: sessionId,
143+
openFiles: new Map(),
144+
activeFilePath: null,
145+
expandedDirs: new Set(),
146+
fileTree: null,
147+
gitStatus: null,
148+
gitDiff: null,
149+
gitBlame: null,
150+
gitLog: null,
151+
diffFile: null,
152+
blameEnabled: false,
153+
});
154+
},
131155
}));

src/utils/git.js

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,32 @@ const MAX_DIFF_BUFFER = 1024 * 1024; // 1 MB
132132
const MAX_BLAME_BUFFER = 2 * 1024 * 1024; // 2 MB
133133
const MAX_LOG_BUFFER = 1024 * 1024; // 1 MB
134134

135+
// git status --porcelain returns paths relative to the repo root, so
136+
// commands that accept those paths (diff, blame, log --follow) must also
137+
// run from the repo root. Cache per-cwd to avoid repeated rev-parse calls.
138+
const gitRootCache = new Map();
139+
140+
async function getGitRoot(cwd) {
141+
if (gitRootCache.has(cwd)) return gitRootCache.get(cwd);
142+
try {
143+
const root = await new Promise((resolve, reject) => {
144+
require('child_process').execFile(
145+
'git',
146+
['rev-parse', '--show-toplevel'],
147+
{ cwd, timeout: GIT_TIMEOUT },
148+
(err, stdout) => {
149+
if (err) return reject(err);
150+
resolve(stdout.trim());
151+
},
152+
);
153+
});
154+
gitRootCache.set(cwd, root);
155+
return root;
156+
} catch {
157+
return cwd; // fallback to session cwd
158+
}
159+
}
160+
135161
async function gitAsync(args, cwd, options = {}) {
136162
return new Promise((resolve, reject) => {
137163
require('child_process').execFile(
@@ -302,6 +328,8 @@ async function parseDiffOutput(raw, filePath) {
302328

303329
async function getFileDiff(cwd, filePath, options = {}) {
304330
const { staged = false, untracked = false, context = 3 } = options;
331+
// git status returns paths relative to the repo root, so run diff from there
332+
const root = await getGitRoot(cwd);
305333

306334
try {
307335
// Untracked files: use --no-index to diff against the null device
@@ -312,7 +340,7 @@ async function getFileDiff(cwd, filePath, options = {}) {
312340
'git',
313341
['diff', '--no-index', '--no-color', `--unified=${context}`, '--', nullDevice, filePath],
314342
{
315-
cwd,
343+
cwd: root,
316344
timeout: GIT_TIMEOUT,
317345
maxBuffer: MAX_DIFF_BUFFER,
318346
},
@@ -330,7 +358,7 @@ async function getFileDiff(cwd, filePath, options = {}) {
330358
if (staged) args.push('--cached');
331359
args.push('--', filePath);
332360

333-
const raw = await gitAsync(args, cwd, { maxBuffer: MAX_DIFF_BUFFER });
361+
const raw = await gitAsync(args, root, { maxBuffer: MAX_DIFF_BUFFER });
334362
return parseDiffOutput(raw, filePath);
335363
} catch (err) {
336364
// Empty diff or git error
@@ -350,9 +378,11 @@ async function getFileDiff(cwd, filePath, options = {}) {
350378

351379
async function getFileBlame(cwd, filePath) {
352380
const result = { file: filePath, lines: [] };
381+
// git status returns paths relative to the repo root, so run blame from there
382+
const root = await getGitRoot(cwd);
353383

354384
try {
355-
const raw = await gitAsync(['blame', '--porcelain', '--', filePath], cwd, {
385+
const raw = await gitAsync(['blame', '--porcelain', '--', filePath], root, {
356386
maxBuffer: MAX_BLAME_BUFFER,
357387
});
358388

@@ -427,11 +457,14 @@ async function getGitLog(cwd, options = {}) {
427457

428458
try {
429459
const args = ['log', `--format=${LOG_SEPARATOR}${LOG_FORMAT}`, `-n`, String(limit)];
460+
// When filtering by file, run from repo root since paths are repo-root-relative
461+
let runCwd = cwd;
430462
if (options.file) {
431463
args.push('--follow', '--', options.file);
464+
runCwd = await getGitRoot(cwd);
432465
}
433466

434-
const raw = await gitAsync(args, cwd, { maxBuffer: MAX_LOG_BUFFER });
467+
const raw = await gitAsync(args, runCwd, { maxBuffer: MAX_LOG_BUFFER });
435468

436469
const entries = raw.split(LOG_SEPARATOR).filter((e) => e.trim());
437470
for (const entry of entries) {
@@ -462,4 +495,5 @@ module.exports = {
462495
getFileDiff,
463496
getFileBlame,
464497
getGitLog,
498+
getGitRoot,
465499
};

0 commit comments

Comments
 (0)