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)
134 lines
3.6 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|