Skip to content
Draft
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
9 changes: 6 additions & 3 deletions src/main/ipc/gitIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -747,19 +747,22 @@ export function registerGitIpc() {
// Git: Status (moved from Codex IPC)
ipcMain.handle(
'git:get-status',
async (_, arg: string | { taskPath: string; taskId?: string }) => {
async (_, arg: string | { taskPath: string; taskId?: string; includeUntracked?: boolean }) => {
const taskPath = typeof arg === 'string' ? arg : arg.taskPath;
const taskId = typeof arg === 'string' ? undefined : arg.taskId;
const includeUntracked = typeof arg === 'string' ? true : (arg.includeUntracked ?? true);
try {
const remote = await resolveRemoteContext(taskPath, taskId);
if (remote) {
const changes = await remoteGitService.getStatusDetailed(
remote.connectionId,
remote.remotePath
remote.remotePath,
{ includeUntracked }
);
return { success: true, changes };
}
const changes = await gitGetStatus(taskPath);

const changes = await gitGetStatus(taskPath, { includeUntracked });
return { success: true, changes };
} catch (error) {
log.error('git:get-status error', error);
Expand Down
2 changes: 1 addition & 1 deletion src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
fetchProjectBaseRef: (args: { projectId: string; projectPath: string }) =>
ipcRenderer.invoke('projectSettings:fetchBaseRef', args),
getGitInfo: (projectPath: string) => ipcRenderer.invoke('git:getInfo', projectPath),
getGitStatus: (arg: string | { taskPath: string; taskId?: string }) =>
getGitStatus: (arg: string | { taskPath: string; taskId?: string; includeUntracked?: boolean }) =>
ipcRenderer.invoke('git:get-status', arg),
getDeleteRisks: (args: {
targets: Array<{ id: string; taskPath: string }>;
Expand Down
21 changes: 17 additions & 4 deletions src/main/services/GitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,18 @@ async function resolveReviewBaseRef(taskPath: string, baseRef: string): Promise<
return baseRef;
}

export async function getStatus(taskPath: string): Promise<GitChange[]> {
/**
* Returns the list of changed files in the worktree.
*
* @param taskPath Absolute path to the worktree.
* @param [options.includeUntracked=true] Whether to include untracked files.
* Disabling this avoids a full directory walk which can be slow in large repos.
*/
export async function getStatus(
taskPath: string,
options?: { includeUntracked?: boolean }
): Promise<GitChange[]> {
const includeUntracked = options?.includeUntracked ?? true;
try {
try {
await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], {
Expand All @@ -157,6 +168,9 @@ export async function getStatus(taskPath: string): Promise<GitChange[]> {
// Run git commands in parallel with flags tuned for performance:
// --no-optional-locks: avoid blocking on concurrent git processes
// --no-ahead-behind: skip commit-graph walk for tracking info
// --untracked-files: 'no' for fast pass (~50ms), 'all' for full pass (~8s+)
// The untracked directory walk is the main bottleneck in large monorepos.
const untrackedMode = includeUntracked ? 'all' : 'no';
const statusPromise = (async () => {
try {
const { stdout } = await execFileAsync(
Expand All @@ -167,7 +181,7 @@ export async function getStatus(taskPath: string): Promise<GitChange[]> {
'--porcelain=v2',
'-z',
'--no-ahead-behind',
'--untracked-files=all',
`--untracked-files=${untrackedMode}`,
],
{
cwd: taskPath,
Expand All @@ -176,10 +190,9 @@ export async function getStatus(taskPath: string): Promise<GitChange[]> {
);
return stdout;
} catch {
// Fallback for older git versions that do not support porcelain v2.
const { stdout } = await execFileAsync(
'git',
['--no-optional-locks', 'status', '--porcelain', '--untracked-files=all'],
['--no-optional-locks', 'status', '--porcelain', `--untracked-files=${untrackedMode}`],
{
cwd: taskPath,
maxBuffer: MAX_DIFF_OUTPUT_BYTES,
Expand Down
13 changes: 9 additions & 4 deletions src/main/services/RemoteGitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,13 @@ export class RemoteGitService {
* Detailed git status matching the shape returned by local GitService.getStatus().
* Parses porcelain output, numstat diffs, and untracked file line counts.
*/
async getStatusDetailed(connectionId: string, worktreePath: string): Promise<GitChange[]> {
async getStatusDetailed(
connectionId: string,
worktreePath: string,
options?: { includeUntracked?: boolean }
): Promise<GitChange[]> {
const includeUntracked = options?.includeUntracked ?? true;
const untrackedMode = includeUntracked ? 'all' : 'no';
const cwd = this.normalizeRemotePath(worktreePath);
// Verify git repo
const verifyResult = await this.sshService.executeCommand(
Expand All @@ -355,16 +361,15 @@ export class RemoteGitService {
let statusOutput = '';
const statusV2Result = await this.sshService.executeCommand(
connectionId,
'git status --porcelain=v2 -z --untracked-files=all',
`git status --porcelain=v2 -z --untracked-files=${untrackedMode}`,
cwd
);
if (statusV2Result.exitCode === 0) {
statusOutput = statusV2Result.stdout || '';
} else {
// Fallback for older remote git versions.
const statusV1Result = await this.sshService.executeCommand(
connectionId,
'git status --porcelain --untracked-files=all',
`git status --porcelain --untracked-files=${untrackedMode}`,
cwd
);
if (statusV1Result.exitCode !== 0) {
Expand Down
13 changes: 13 additions & 0 deletions src/main/services/__tests__/RemoteGitService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,19 @@ describe('RemoteGitService', () => {
expect(result[1].additions).toBe(100);
});

it('should use --untracked-files=no when includeUntracked is false', async () => {
mockExecuteCommand
.mockResolvedValueOnce({ stdout: 'true', stderr: '', exitCode: 0 } as ExecResult) // rev-parse
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as ExecResult); // status

await service.getStatusDetailed('conn-1', '/home/user/project', {
includeUntracked: false,
});

const statusCall = mockExecuteCommand.mock.calls[1];
expect(statusCall[1]).toContain('--untracked-files=no');
});

it('should handle renamed files', async () => {
mockExecuteCommand
.mockResolvedValueOnce({ stdout: 'true', stderr: '', exitCode: 0 } as ExecResult)
Expand Down
5 changes: 4 additions & 1 deletion src/renderer/components/FileChangesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ const FileChangesPanelComponent: React.FC<FileChangesPanelProps> = ({
}
};

const { fileChanges, isLoading, refreshChanges } = useFileChanges(safeTaskPath, {
const { fileChanges, isLoading, isRevalidating, refreshChanges } = useFileChanges(safeTaskPath, {
taskId: resolvedTaskId,
});
const { toast } = useToast();
Expand Down Expand Up @@ -664,6 +664,9 @@ const FileChangesPanelComponent: React.FC<FileChangesPanelProps> = ({
<span className="font-medium text-red-600 dark:text-red-400">
-{formatDiffCount(totalChanges.deletions)}
</span>
{isRevalidating && (
<Loader2 className="ml-1 h-3 w-3 animate-spin text-muted-foreground" />
)}
</div>
{hasStagedChanges && (
<span className="shrink-0 rounded bg-muted-foreground/10 px-2 py-0.5 text-xs font-medium text-muted-foreground">
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/FileExplorer/CodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export default function CodeEditor({
} = useFileManager({ taskId, taskPath, connectionId, remotePath });

// Get file changes status from git
const { fileChanges } = useFileChanges(taskPath);
const { fileChanges } = useFileChanges(taskPath, { taskId });

// UI state
const [explorerWidth, setExplorerWidth] = useState(EXPLORER_WIDTH.DEFAULT);
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/components/diff-viewer/DiffViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
const isPrReview = Boolean(prNumber && (taskPath || scopedTaskPath));

const [activeTab, setActiveTab] = useState<Tab>('changes');
const { fileChanges: localFileChanges, refreshChanges } = useFileChanges(taskPath);
const { fileChanges: localFileChanges, refreshChanges } = useFileChanges(taskPath, { taskId });

// PR review mode: fetch PR diff changes and base branch
const [prFileChanges, setPrFileChanges] = useState<FileChange[]>([]);
Expand Down
128 changes: 103 additions & 25 deletions src/renderer/hooks/useFileChanges.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { subscribeToFileChanges } from '@/lib/fileChangeEvents';
import { getCachedGitStatus } from '@/lib/gitStatusCache';
import {
buildCacheKey,
getCachedGitStatus,
getCachedResult,
onCacheRevalidated,
} from '@/lib/gitStatusCache';
import type { GitStatusChange } from '@/lib/gitStatusCache';
import { filterVisibleGitStatusChanges } from '@/lib/gitStatusFilters';
import { useGitStatusAutoRefresh } from '@/hooks/internal/useGitStatusAutoRefresh';

Expand All @@ -13,6 +19,17 @@ export interface FileChange {
diff?: string;
}

function toFileChange(change: GitStatusChange): FileChange {
return {
path: change.path,
status: change.status as 'added' | 'modified' | 'deleted' | 'renamed',
additions: change.additions ?? null,
deletions: change.deletions ?? null,
isStaged: change.isStaged || false,
diff: change.diff,
};
}

interface UseFileChangesOptions {
isActive?: boolean;
idleIntervalMs?: number;
Expand All @@ -30,6 +47,7 @@ export function shouldRefreshFileChanges(
export function useFileChanges(taskPath?: string, options: UseFileChangesOptions = {}) {
const [fileChanges, setFileChanges] = useState<FileChange[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isRevalidating, setIsRevalidating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isDocumentVisible, setIsDocumentVisible] = useState(() => {
if (typeof document === 'undefined') return true;
Expand All @@ -38,6 +56,7 @@ export function useFileChanges(taskPath?: string, options: UseFileChangesOptions

const { isActive = true, idleIntervalMs = 60000, taskId } = options;
const taskPathRef = useRef(taskPath);
const taskIdRef = useRef(taskId);
const inFlightRef = useRef(false);
const hasLoadedRef = useRef(false);
const mountedRef = useRef(true);
Expand All @@ -52,13 +71,36 @@ export function useFileChanges(taskPath?: string, options: UseFileChangesOptions
}, []);

useEffect(() => {
setFileChanges([]); // Clear stale state immediately
if (taskPath) {
setIsLoading(true);
}
taskPathRef.current = taskPath;
taskIdRef.current = taskId;
hasLoadedRef.current = false;
}, [taskPath]);

setIsRevalidating(false);

if (!taskPath) {
setFileChanges([]);
return;
}

const cached = getCachedResult(taskPath, taskId);
if (cached) {
// Show cached data immediately (even if stale)
if (cached.result?.success && cached.result.changes && cached.result.changes.length > 0) {
setFileChanges(filterVisibleGitStatusChanges(cached.result.changes).map(toFileChange));
} else {
setFileChanges([]);
}
setIsRevalidating(cached.isStale);
} else {
setFileChanges([]);
setIsLoading(true);
}

// Kick off a fetch immediately so same-path/taskId changes don't wait
// for the idle poll or a watcher event to trigger a load.
void fetchFileChanges(true);
// eslint-disable-next-line react-hooks/exhaustive-deps -- fetchFileChanges is stable (deps: [queueRefresh]) and reads taskPath/taskId from refs
}, [taskPath, taskId]);

useEffect(() => {
if (typeof document === 'undefined' || typeof window === 'undefined') return;
Expand All @@ -78,7 +120,7 @@ export function useFileChanges(taskPath?: string, options: UseFileChangesOptions
pendingRefreshRef.current = true;
if (shouldSetLoading) {
pendingInitialLoadRef.current = true;
if (mountedRef.current) {
if (mountedRef.current && !getCachedResult(taskPathRef.current ?? '', taskIdRef.current)) {
setIsLoading(true);
setError(null);
}
Expand All @@ -98,48 +140,54 @@ export function useFileChanges(taskPath?: string, options: UseFileChangesOptions
}

inFlightRef.current = true;
if (isInitialLoad && mountedRef.current) {
if (isInitialLoad && mountedRef.current && !getCachedResult(currentPath, taskIdRef.current)) {
setIsLoading(true);
setError(null);
}

const requestPath = currentPath;
const requestTaskId = taskIdRef.current;

const isStale = () =>
requestPath !== taskPathRef.current || requestTaskId !== taskIdRef.current;

try {
const result = await getCachedGitStatus(requestPath, { force: options?.force, taskId });
const result = await getCachedGitStatus(requestPath, {
force: options?.force,
taskId: requestTaskId,
});

if (!mountedRef.current) return;

if (requestPath !== taskPathRef.current) {
if (isStale()) {
queueRefresh(true);
return;
}

if (result?.success && result.changes && result.changes.length > 0) {
const visibleChanges = filterVisibleGitStatusChanges(result.changes);
const changes: FileChange[] = visibleChanges.map((change) => ({
path: change.path,
status: change.status as 'added' | 'modified' | 'deleted' | 'renamed',
additions: change.additions ?? null,
deletions: change.deletions ?? null,
isStaged: change.isStaged || false,
diff: change.diff,
}));
setFileChanges(changes);
if (result?.success) {
setFileChanges(
result.changes?.length
? filterVisibleGitStatusChanges(result.changes).map(toFileChange)
: []
);
setError(null);
} else if (hasLoadedRef.current) {
setError(result?.error || 'Failed to refresh file changes');
} else {
setFileChanges([]);
setError(result?.error || 'Failed to load file changes');
}
} catch (err) {
if (!mountedRef.current) return;
if (requestPath !== taskPathRef.current) {
if (isStale()) {
queueRefresh(true);
return;
}
console.error('Failed to fetch file changes:', err);
if (isInitialLoad) {
setError('Failed to load file changes');
setError('Failed to load file changes');
if (!hasLoadedRef.current) {
setFileChanges([]);
}
setFileChanges([]);
} finally {
const isCurrentPath = requestPath === taskPathRef.current;
if (mountedRef.current && isInitialLoad && !pendingInitialLoadRef.current) {
Expand Down Expand Up @@ -171,6 +219,35 @@ export function useFileChanges(taskPath?: string, options: UseFileChangesOptions
fetchChanges: fetchFileChanges,
});

useEffect(() => {
if (!taskPath) return undefined;
const expectedKey = buildCacheKey(taskPath, taskId);

const unsubRevalidate = onCacheRevalidated((key, result) => {
if (!mountedRef.current || key !== expectedKey) return;

setIsLoading(false);
setIsRevalidating(false);

if (result.success) {
setError(null);
setFileChanges(
result.changes?.length
? filterVisibleGitStatusChanges(result.changes).map(toFileChange)
: []
);
return;
}

// Preserve existing fileChanges on background refresh failure
setError(result.error || 'Failed to refresh file changes');
});

return () => {
unsubRevalidate();
};
}, [taskPath, taskId]);

useEffect(() => {
if (!taskPath) return undefined;

Expand All @@ -193,6 +270,7 @@ export function useFileChanges(taskPath?: string, options: UseFileChangesOptions
return {
fileChanges,
isLoading,
isRevalidating,
error,
refreshChanges,
};
Expand Down
Loading
Loading