- Added validation to prevent the use of reserved names ("user" and "team-lead") for team members, ensuring clearer error messages during member addition.
- Updated IPC handlers to improve logging functionality, enhancing observability of team-related actions.
- Implemented normalization of file paths across various services to ensure consistent handling on different platforms.
- Enhanced UI components to provide better feedback on team member statuses and actions.
Made-with: Cursor
159 lines
4.5 KiB
TypeScript
159 lines
4.5 KiB
TypeScript
import * as path from 'node:path';
|
|
|
|
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
|
|
|
|
function toRepoRelativePath(projectPath: string, filePath: string): string | null {
|
|
const normalizedProject = path.resolve(projectPath);
|
|
const normalizedFile = path.isAbsolute(filePath) ? path.resolve(filePath) : filePath;
|
|
|
|
// If we have an absolute file path, require it to be under projectPath.
|
|
if (path.isAbsolute(normalizedFile)) {
|
|
const rel = path.relative(normalizedProject, normalizedFile);
|
|
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
// Git pathspecs use forward slashes even on Windows.
|
|
const gitPath = rel.replace(/\\/g, '/');
|
|
if (gitPath.includes(':')) return null;
|
|
return gitPath;
|
|
}
|
|
|
|
// Relative path: normalize separators for git.
|
|
const gitPath = normalizedFile.replace(/\\/g, '/').replace(/^\.\/+/, '');
|
|
if (
|
|
!gitPath ||
|
|
gitPath.startsWith('/') ||
|
|
/^[a-zA-Z]:\//.test(gitPath) ||
|
|
gitPath.includes(':')
|
|
) {
|
|
return null;
|
|
}
|
|
return gitPath;
|
|
}
|
|
|
|
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 = toRepoRelativePath(projectPath, filePath);
|
|
if (!relativePath) return null;
|
|
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 = toRepoRelativePath(projectPath, filePath);
|
|
if (!relativePath) return null;
|
|
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 = toRepoRelativePath(projectPath, filePath);
|
|
if (!relativePath) return null;
|
|
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 = toRepoRelativePath(projectPath, filePath);
|
|
if (!relativePath) return [];
|
|
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;
|
|
}
|
|
}
|
|
}
|