agent-ecosystem/src/main/services/team/GitDiffFallback.ts
iliya 190cafdb8e feat: implement diff view with 4 phases — review, accept/reject, task scoping, enhanced UX
Phase 1: Core diff extraction and display
- ChangeExtractorService: JSONL streaming parser with snippet extraction
- FileContentResolver: 3-level content resolution (file-history → snippets → disk)
- ReviewApplierService: hunk-level accept/reject with conflict detection
- CodeMirrorDiffView: unified merge view with syntax highlighting
- ReviewFileTree: file browser with status indicators
- changeReviewSlice: Zustand state for review workflow

Phase 2: Interactive review with accept/reject
- Per-hunk and per-file accept/reject decisions
- Conflict checking before apply
- ReviewToolbar with bulk actions
- DiffErrorBoundary for graceful degradation

Phase 3: Per-task change scoping
- TaskBoundaryParser: detects task boundaries in JSONL (Tier 1-4 confidence)
- TaskChangeSetV2 with scope + warnings
- ConfidenceBadge and ScopeWarningBanner components

Phase 4: Enhanced features
- Keyboard navigation (j/k/n/p/a/x shortcuts via useDiffNavigation)
- Viewed file tracking (localStorage + useViewedFiles hook)
- File edit timeline (chronological events per file)
- Git fallback (GitDiffFallback service for incomplete JSONL data)
- Auto-viewed detection (IntersectionObserver sentinel)
2026-02-24 23:39:41 +02:00

134 lines
3.6 KiB
TypeScript

import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
const GIT_TIMEOUT = 10_000; // 10s timeout for all git operations
const GIT_MAX_BUFFER = 10 * 1024 * 1024; // 10MB
export class GitDiffFallback {
private gitRepoCache = new Map<string, boolean>();
/**
* Get file contents at a specific commit.
* Used when file-history-snapshot is unavailable.
*/
async getFileAtCommit(
projectPath: string,
filePath: string,
commitHash: string
): Promise<string | null> {
try {
const relativePath = filePath.startsWith(projectPath + '/')
? filePath.slice(projectPath.length + 1)
: filePath;
const { stdout } = await execFileAsync('git', ['show', `${commitHash}:${relativePath}`], {
cwd: projectPath,
maxBuffer: GIT_MAX_BUFFER,
timeout: GIT_TIMEOUT,
});
return stdout;
} catch {
return null;
}
}
/**
* Find the commit closest to (but before) a given timestamp for a file.
*/
async findCommitNearTimestamp(
projectPath: string,
filePath: string,
timestamp: string
): Promise<string | null> {
try {
const relativePath = filePath.startsWith(projectPath + '/')
? filePath.slice(projectPath.length + 1)
: filePath;
const { stdout } = await execFileAsync(
'git',
['log', '--format=%H', '--before', timestamp, '-1', '--', relativePath],
{ cwd: projectPath, timeout: GIT_TIMEOUT }
);
return stdout.trim() || null;
} catch {
return null;
}
}
/**
* Get git diff for a file between two refs.
*/
async getGitDiff(
projectPath: string,
filePath: string,
fromCommit: string,
toCommit: string = 'HEAD'
): Promise<string | null> {
try {
const relativePath = filePath.startsWith(projectPath + '/')
? filePath.slice(projectPath.length + 1)
: filePath;
const { stdout } = await execFileAsync(
'git',
['diff', fromCommit, toCommit, '--', relativePath],
{ cwd: projectPath, timeout: GIT_TIMEOUT }
);
return stdout || null;
} catch {
return null;
}
}
/**
* Get file change log (for timeline enrichment).
*/
async getFileLog(
projectPath: string,
filePath: string,
maxCount: number = 20
): Promise<{ hash: string; timestamp: string; message: string }[]> {
try {
const relativePath = filePath.startsWith(projectPath + '/')
? filePath.slice(projectPath.length + 1)
: filePath;
const { stdout } = await execFileAsync(
'git',
['log', `--max-count=${maxCount}`, '--format=%H|%aI|%s', '--', relativePath],
{ cwd: projectPath, timeout: GIT_TIMEOUT }
);
return stdout
.trim()
.split('\n')
.filter((line) => line.includes('|'))
.map((line) => {
const [hash, timestamp, ...msgParts] = line.split('|');
return { hash, timestamp, message: msgParts.join('|') };
});
} catch {
return [];
}
}
/**
* Check if a path is inside a git repository.
* Result is cached per projectPath for the session lifetime.
*/
async isGitRepo(projectPath: string): Promise<boolean> {
const cached = this.gitRepoCache.get(projectPath);
if (cached !== undefined) return cached;
try {
await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], {
cwd: projectPath,
timeout: GIT_TIMEOUT,
});
this.gitRepoCache.set(projectPath, true);
return true;
} catch {
this.gitRepoCache.set(projectPath, false);
return false;
}
}
}