kimi-code / packages /agent-core-v2 /src /app /git /gitService.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f0634fb verified
Raw
History Blame Contribute Delete
8.34 kB
import type { FsDiffResponse, FsGitStatusResponse, FsPullRequest } from './git';
import { LifecycleScope } from '#/app/scopes';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { ErrorCodes, Error2 } from '#/errors';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { IRuntimeResolver, IWorkspaceInstanceManager } from '#/workspace/workspaceInstance/workspaceInstanceManager';
import { IGitService } from './git';
import { parseNumstat, parsePorcelain, parsePullRequest } from './gitParsers';
import { findGitWorkTree, type GitWorkTree } from './workTree';
const DIFF_MAX_BYTES = 1_048_576;
const PR_SPAWN_TIMEOUT_MS = 5_000;
const PULL_REQUEST_TTL_MS = 60_000;
export class GitService implements IGitService {
declare readonly _serviceBrand: undefined;
private readonly pullRequestCache = new Map<
string,
{ value: FsPullRequest | null; fetchedAt: number }
>();
constructor(
@IRuntimeResolver private readonly resolver: IRuntimeResolver,
@IWorkspaceInstanceManager private readonly workspaces: IWorkspaceInstanceManager,
@IHostFileSystem private readonly fs: IHostFileSystem,
) {}
async status(cwd: string, pathFilter?: ReadonlySet<string>): Promise<FsGitStatusResponse> {
const inside = await this.runCommand('git', ['rev-parse', '--is-inside-work-tree'], cwd);
if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') {
throw this.gitUnavailable(cwd, inside.stderr.trim() || `git rev-parse exit ${inside.exitCode}`);
}
const porc = await this.runCommand('git', ['status', '--porcelain=v1', '--branch', '-z'], cwd);
if (porc.exitCode !== 0) {
throw this.gitUnavailable(cwd, porc.stderr.trim() || `git status exit ${porc.exitCode}`);
}
const result = parsePorcelain(porc.stdout, pathFilter);
const dirty = porc.stdout
.split('\0')
.some((record) => record.length > 0 && !record.startsWith('## '));
if (dirty) {
const head = await this.runCommand('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], cwd);
if (head.exitCode === 0) {
const numstat = await this.runCommand('git', ['diff', '--no-color', '--numstat', 'HEAD', '--'], cwd);
if (numstat.exitCode === 0) {
const stats = parseNumstat(numstat.stdout);
result.additions = stats.additions;
result.deletions = stats.deletions;
}
}
}
result.pullRequest = await this.readPullRequest(cwd);
return result;
}
async diff(cwd: string, relPath: string, absPath: string): Promise<FsDiffResponse> {
const inside = await this.runCommand('git', ['rev-parse', '--is-inside-work-tree'], cwd);
if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true') {
throw this.gitUnavailable(cwd, inside.stderr.trim() || `git rev-parse exit ${inside.exitCode}`);
}
const statusRes = await this.runCommand('git', ['status', '--porcelain=v1', '--', relPath], cwd);
if (statusRes.exitCode !== 0) {
throw this.gitUnavailable(cwd, statusRes.stderr.trim() || `git status exit ${statusRes.exitCode}`);
}
const untracked = statusRes.stdout.startsWith('??');
const headRes = await this.runCommand('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], cwd);
const hasHead = headRes.exitCode === 0;
let diffStdout: string;
if (untracked || !hasHead) {
const res = await this.runCommand(
'git',
['diff', '--no-color', '--no-index', '--', '/dev/null', relPath],
cwd,
);
if (res.exitCode !== 0 && res.exitCode !== 1) {
throw this.gitUnavailable(cwd, res.stderr.trim() || `git diff exit ${res.exitCode}`);
}
diffStdout = res.stdout;
} else {
const res = await this.runCommand('git', ['diff', '--no-color', 'HEAD', '--', relPath], cwd);
if (res.exitCode !== 0) {
throw this.gitUnavailable(cwd, res.stderr.trim() || `git diff exit ${res.exitCode}`);
}
if (res.stdout.length === 0 && statusRes.stdout.length === 0) {
const exists = await this.fs.lstat(absPath).then(
() => true,
() => false,
);
if (!exists) {
throw new Error2(ErrorCodes.FS_PATH_NOT_FOUND, `path not found: ${relPath}`, {
details: { path: relPath },
});
}
}
diffStdout = res.stdout;
}
const truncated = diffStdout.length > DIFF_MAX_BYTES;
return {
path: relPath,
diff: truncated ? diffStdout.slice(0, DIFF_MAX_BYTES) : diffStdout,
truncated,
};
}
findWorkTree(cwd: string): Promise<GitWorkTree | null> {
return findGitWorkTree(this.fs, cwd);
}
private async readPullRequest(cwd: string): Promise<FsPullRequest | null> {
const cached = this.pullRequestCache.get(cwd);
const now = Date.now();
if (cached !== undefined && now - cached.fetchedAt < PULL_REQUEST_TTL_MS) {
return cached.value;
}
const res = await this.runCommand(
'gh',
['pr', 'view', '--json', 'number,url,state'],
cwd,
{
env: { GH_NO_UPDATE_NOTIFIER: '1', GH_PROMPT_DISABLED: '1' },
timeoutMs: PR_SPAWN_TIMEOUT_MS,
},
);
const value = res.exitCode === 0 ? parsePullRequest(res.stdout) : null;
this.pullRequestCache.set(cwd, { value, fetchedAt: now });
return value;
}
private async runCommand(
cmd: string,
args: readonly string[],
cwd: string,
options: RunOptions = {},
): Promise<RunResult> {
const workspaceId = this.resolveWorkspaceId(cwd);
const lease = this.resolver.acquire({ workspaceId, runtimeId: 'local' }, ['process']);
const spawned = await lease.runtime.process!
.spawn(cmd, args, { cwd, env: options.env })
.then(
(proc) => ({ ok: true as const, proc }),
() => ({ ok: false as const }),
);
if (!spawned.ok) {
return { exitCode: -1, stdout: '', stderr: '' };
}
const { proc } = spawned;
const work = Promise.all([
collect(proc.stdout),
collect(proc.stderr),
proc.wait().catch(() => -1),
] as const);
work.catch(() => {});
let timer: ReturnType<typeof setTimeout> | undefined;
try {
if (options.timeoutMs === undefined) {
const [stdout, stderr, exitCode] = await work;
return { exitCode, stdout, stderr };
}
const timeout = new Promise<'timeout'>((resolve) => {
timer = setTimeout(() => resolve('timeout'), options.timeoutMs);
timer.unref?.();
});
const result = await Promise.race([
work.then(
([stdout, stderr, exitCode]) =>
({ kind: 'done' as const, stdout, stderr, exitCode }),
),
timeout.then((kind) => ({ kind })),
]);
if (result.kind === 'done') {
return { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
}
await proc.kill('SIGKILL').catch(() => {});
const [stdout, stderr] = await work
.then(([so, se]) => [so, se] as const)
.catch(() => ['', ''] as const);
return { exitCode: -1, stdout, stderr };
} finally {
if (timer !== undefined) clearTimeout(timer);
void proc.dispose();
lease.dispose();
}
}
private resolveWorkspaceId(cwd: string): string {
const workspace = this.workspaces.findByRoot(cwd);
if (workspace === undefined) {
throw new Error(`workspace for root ${cwd} is not materialized`);
}
return workspace.id;
}
private gitUnavailable(cwd: string, detail: string): Error2 {
return new Error2(ErrorCodes.FS_GIT_UNAVAILABLE, `git unavailable at ${cwd}: ${detail}`, {
details: { cwd, detail },
});
}
}
interface RunResult {
readonly exitCode: number;
readonly stdout: string;
readonly stderr: string;
}
interface RunOptions {
readonly timeoutMs?: number;
readonly env?: Record<string, string>;
}
async function collect(stream: AsyncIterable<Uint8Array | string>): Promise<string> {
const decoder = new TextDecoder();
let out = '';
for await (const chunk of stream) {
out += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
}
out += decoder.decode();
return out;
}
registerScopedService(LifecycleScope.App, IGitService, GitService, ScopeActivation.OnScopeCreated, 'git');