Skip to content
Open
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
57 changes: 16 additions & 41 deletions src/workspace/get-workspace-changes.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import { execFile } from "node:child_process";
import { readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";

import type {
RuntimeWorkspaceChangesResponse,
RuntimeWorkspaceFileChange,
RuntimeWorkspaceFileStatus,
} from "../core/api-contract.js";
import { createGitProcessEnv } from "../core/git-process-env.js";
import { getGitStdout } from "./git-utils.js";

const execFileAsync = promisify(execFile);
const GIT_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
const WORKSPACE_CHANGES_CACHE_MAX_ENTRIES = 128;

interface WorkspaceChangesCacheEntry {
Expand Down Expand Up @@ -105,27 +101,6 @@ function parseTrackedChanges(output: string): NameStatusEntry[] {
return entries;
}

async function runGit(args: string[], cwd: string): Promise<string> {
try {
const { stdout } = await execFileAsync("git", args, {
cwd,
encoding: "utf8",
maxBuffer: GIT_MAX_BUFFER_BYTES,
env: createGitProcessEnv(),
});
return String(stdout);
} catch (error) {
const message =
typeof error === "object" && error !== null && "stderr" in error
? String((error as { stderr?: unknown }).stderr ?? "").trim()
: "";
if (message) {
throw new Error(message);
}
throw error;
}
}

async function buildFileFingerprints(repoRoot: string, paths: string[]): Promise<FileFingerprint[]> {
if (paths.length === 0) {
return [];
Expand Down Expand Up @@ -193,15 +168,15 @@ function pruneWorkspaceChangesCache(): void {

async function readHeadFile(repoRoot: string, path: string): Promise<string | null> {
try {
return await runGit(["show", `HEAD:${path}`], repoRoot);
return await getGitStdout(["show", `HEAD:${path}`], repoRoot);
} catch {
return null;
}
}

async function readFileAtRef(repoRoot: string, ref: string, path: string): Promise<string | null> {
try {
return await runGit(["show", `${ref}:${path}`], repoRoot);
return await getGitStdout(["show", `${ref}:${path}`], repoRoot);
} catch {
return null;
}
Expand Down Expand Up @@ -236,7 +211,7 @@ function fallbackStats(oldText: string | null, newText: string | null): DiffStat

async function readDiffStat(repoRoot: string, path: string): Promise<DiffStat | null> {
try {
const output = await runGit(["diff", "--numstat", "HEAD", "--", path], repoRoot);
const output = await getGitStdout(["diff", "--numstat", "HEAD", "--", path], repoRoot);
const firstLine = output
.split("\n")
.map((line) => line.trim())
Expand All @@ -263,7 +238,7 @@ async function readDiffStatBetweenRefs(
path: string,
): Promise<DiffStat | null> {
try {
const output = await runGit(["diff", "--numstat", fromRef, toRef, "--", path], repoRoot);
const output = await getGitStdout(["diff", "--numstat", fromRef, toRef, "--", path], repoRoot);
const firstLine = output
.split("\n")
.map((line) => line.trim())
Expand All @@ -285,7 +260,7 @@ async function readDiffStatBetweenRefs(

async function readDiffStatFromRef(repoRoot: string, fromRef: string, path: string): Promise<DiffStat | null> {
try {
const output = await runGit(["diff", "--numstat", fromRef, "--", path], repoRoot);
const output = await getGitStdout(["diff", "--numstat", fromRef, "--", path], repoRoot);
const firstLine = output
.split("\n")
.map((line) => line.trim())
Expand Down Expand Up @@ -377,7 +352,7 @@ async function buildFileChangeFromRef(
}

export async function createEmptyWorkspaceChangesResponse(cwd: string): Promise<RuntimeWorkspaceChangesResponse> {
const repoRoot = (await runGit(["rev-parse", "--show-toplevel"], cwd)).trim();
const repoRoot = (await getGitStdout(["rev-parse", "--show-toplevel"], cwd)).trim();
if (!repoRoot) {
throw new Error("Could not resolve git repository root.");
}
Expand All @@ -389,15 +364,15 @@ export async function createEmptyWorkspaceChangesResponse(cwd: string): Promise<
}

export async function getWorkspaceChanges(cwd: string): Promise<RuntimeWorkspaceChangesResponse> {
const repoRoot = (await runGit(["rev-parse", "--show-toplevel"], cwd)).trim();
const repoRoot = (await getGitStdout(["rev-parse", "--show-toplevel"], cwd)).trim();
if (!repoRoot) {
throw new Error("Could not resolve git repository root.");
}

const [trackedChangesOutput, untrackedOutput, headCommitOutput] = await Promise.all([
runGit(["diff", "--name-status", "HEAD", "--"], repoRoot),
runGit(["ls-files", "--others", "--exclude-standard"], repoRoot),
runGit(["rev-parse", "--verify", "HEAD"], repoRoot).catch(() => ""),
getGitStdout(["diff", "--name-status", "HEAD", "--"], repoRoot),
getGitStdout(["ls-files", "--others", "--exclude-standard"], repoRoot),
getGitStdout(["rev-parse", "--verify", "HEAD"], repoRoot).catch(() => ""),
]);
const trackedChanges = parseTrackedChanges(trackedChangesOutput);
const untrackedPaths = untrackedOutput
Expand Down Expand Up @@ -449,12 +424,12 @@ export async function getWorkspaceChanges(cwd: string): Promise<RuntimeWorkspace
export async function getWorkspaceChangesBetweenRefs(
input: ChangesBetweenRefsInput,
): Promise<RuntimeWorkspaceChangesResponse> {
const repoRoot = (await runGit(["rev-parse", "--show-toplevel"], input.cwd)).trim();
const repoRoot = (await getGitStdout(["rev-parse", "--show-toplevel"], input.cwd)).trim();
if (!repoRoot) {
throw new Error("Could not resolve git repository root.");
}

const trackedChangesOutput = await runGit(
const trackedChangesOutput = await getGitStdout(
["diff", "--name-status", "--find-renames", input.fromRef, input.toRef, "--"],
repoRoot,
);
Expand All @@ -480,14 +455,14 @@ export async function getWorkspaceChangesBetweenRefs(
}

export async function getWorkspaceChangesFromRef(input: ChangesFromRefInput): Promise<RuntimeWorkspaceChangesResponse> {
const repoRoot = (await runGit(["rev-parse", "--show-toplevel"], input.cwd)).trim();
const repoRoot = (await getGitStdout(["rev-parse", "--show-toplevel"], input.cwd)).trim();
if (!repoRoot) {
throw new Error("Could not resolve git repository root.");
}

const [trackedChangesOutput, untrackedOutput] = await Promise.all([
runGit(["diff", "--name-status", "--find-renames", input.fromRef, "--"], repoRoot),
runGit(["ls-files", "--others", "--exclude-standard"], repoRoot),
getGitStdout(["diff", "--name-status", "--find-renames", input.fromRef, "--"], repoRoot),
getGitStdout(["ls-files", "--others", "--exclude-standard"], repoRoot),
]);
const trackedChanges = parseTrackedChanges(trackedChangesOutput);
const untrackedPaths = untrackedOutput
Expand Down
42 changes: 1 addition & 41 deletions src/workspace/git-history.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,19 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";

import type {
RuntimeGitCommit,
RuntimeGitCommitDiffResponse,
RuntimeGitLogResponse,
RuntimeGitRef,
RuntimeGitRefsResponse,
} from "../core/api-contract.js";
import { createGitProcessEnv } from "../core/git-process-env.js";

const execFileAsync = promisify(execFile);
const GIT_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
import { runGit } from "./git-utils.js";

const LOG_FIELD_SEPARATOR = "\x1f";
const LOG_RECORD_SEPARATOR = "\x1e";

const LOG_FORMAT = ["%H", "%h", "%an", "%ae", "%aI", "%s", "%P"].join(LOG_FIELD_SEPARATOR);

interface GitCommandResult {
ok: boolean;
stdout: string;
error: string | null;
}

type CommitRelation = NonNullable<RuntimeGitCommit["relation"]>;

async function runGit(
cwd: string,
args: string[],
options?: {
trimStdout?: boolean;
},
): Promise<GitCommandResult> {
try {
const { stdout } = await execFileAsync("git", args, {
cwd,
encoding: "utf8",
maxBuffer: GIT_MAX_BUFFER_BYTES,
env: createGitProcessEnv(),
});
const stdoutText = String(stdout ?? "");
return {
ok: true,
stdout: options?.trimStdout === false ? stdoutText : stdoutText.trim(),
error: null,
};
} catch (error) {
const candidate = error as { stderr?: unknown; message?: unknown };
const stderr = String(candidate.stderr ?? "").trim();
const message = String(candidate.message ?? "").trim();
return { ok: false, stdout: "", error: stderr || message || "Git command failed." };
}
}

function parseCommitRecord(record: string): RuntimeGitCommit | null {
const fields = record.split(LOG_FIELD_SEPARATOR);
if (fields.length < 7) {
Expand Down
70 changes: 12 additions & 58 deletions src/workspace/git-sync.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { execFile } from "node:child_process";
import { readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";

import type {
RuntimeGitCheckoutResponse,
Expand All @@ -10,18 +8,7 @@ import type {
RuntimeGitSyncResponse,
RuntimeGitSyncSummary,
} from "../core/api-contract.js";
import { createGitProcessEnv } from "../core/git-process-env.js";

const execFileAsync = promisify(execFile);
const GIT_MAX_BUFFER_BYTES = 10 * 1024 * 1024;

interface GitCommandResult {
ok: boolean;
stdout: string;
stderr: string;
output: string;
error: string | null;
}
import { runGit } from "./git-utils.js";

interface GitPathFingerprint {
path: string;
Expand Down Expand Up @@ -126,8 +113,8 @@ function parseStatusPath(line: string): string | null {
export async function probeGitWorkspaceState(cwd: string): Promise<GitWorkspaceProbe> {
const repoRoot = await resolveRepoRoot(cwd);
const [statusResult, headCommitResult] = await Promise.all([
runGitCommand(repoRoot, ["status", "--porcelain=v2", "--branch", "--untracked-files=all"]),
runGitCommand(repoRoot, ["rev-parse", "--verify", "HEAD"]),
runGit(repoRoot, ["status", "--porcelain=v2", "--branch", "--untracked-files=all"]),
runGit(repoRoot, ["rev-parse", "--verify", "HEAD"]),
]);

if (!statusResult.ok) {
Expand Down Expand Up @@ -212,41 +199,8 @@ export async function probeGitWorkspaceState(cwd: string): Promise<GitWorkspaceP
};
}

async function runGitCommand(cwd: string, args: string[]): Promise<GitCommandResult> {
try {
const { stdout, stderr } = await execFileAsync("git", args, {
cwd,
encoding: "utf8",
maxBuffer: GIT_MAX_BUFFER_BYTES,
env: createGitProcessEnv(),
});
const normalizedStdout = String(stdout ?? "").trim();
const normalizedStderr = String(stderr ?? "").trim();
return {
ok: true,
stdout: normalizedStdout,
stderr: normalizedStderr,
output: [normalizedStdout, normalizedStderr].filter(Boolean).join("\n"),
error: null,
};
} catch (error) {
const candidate = error as { stdout?: unknown; stderr?: unknown; message?: unknown };
const stdout = String(candidate.stdout ?? "").trim();
const stderr = String(candidate.stderr ?? "").trim();
const message = String(candidate.message ?? "").trim();
const resolvedError = stderr || message || "Git command failed.";
return {
ok: false,
stdout,
stderr,
output: [stdout, stderr].filter(Boolean).join("\n"),
error: resolvedError,
};
}
}

async function resolveRepoRoot(cwd: string): Promise<string> {
const result = await runGitCommand(cwd, ["rev-parse", "--show-toplevel"]);
const result = await runGit(cwd, ["rev-parse", "--show-toplevel"]);
if (!result.ok || !result.stdout) {
throw new Error("No git repository detected for this workspace.");
}
Expand All @@ -268,7 +222,7 @@ async function countUntrackedAdditions(repoRoot: string, untrackedPaths: string[
}

async function hasGitRef(repoRoot: string, ref: string): Promise<boolean> {
const result = await runGitCommand(repoRoot, ["show-ref", "--verify", "--quiet", ref]);
const result = await runGit(repoRoot, ["show-ref", "--verify", "--quiet", ref]);
return result.ok;
}

Expand All @@ -277,7 +231,7 @@ export async function getGitSyncSummary(
options?: { probe?: GitWorkspaceProbe },
): Promise<RuntimeGitSyncSummary> {
const probe = options?.probe ?? (await probeGitWorkspaceState(cwd));
const diffResult = await runGitCommand(probe.repoRoot, ["diff", "--numstat", "HEAD", "--"]);
const diffResult = await runGit(probe.repoRoot, ["diff", "--numstat", "HEAD", "--"]);
const trackedTotals = diffResult.ok ? parseNumstatTotals(diffResult.stdout) : { additions: 0, deletions: 0 };
const untrackedAdditions = await countUntrackedAdditions(probe.repoRoot, probe.untrackedPaths);

Expand Down Expand Up @@ -313,7 +267,7 @@ export async function runGitSyncAction(options: {
pull: ["pull", "--ff-only"],
push: ["push"],
};
const commandResult = await runGitCommand(options.cwd, argsByAction[options.action]);
const commandResult = await runGit(options.cwd, argsByAction[options.action]);
const nextSummary = await getGitSyncSummary(options.cwd);

if (!commandResult.ok) {
Expand Down Expand Up @@ -364,10 +318,10 @@ export async function runGitCheckoutAction(options: {

const hasLocalBranch = await hasGitRef(repoRoot, `refs/heads/${requestedBranch}`);
const commandResult = hasLocalBranch
? await runGitCommand(repoRoot, ["switch", requestedBranch])
? await runGit(repoRoot, ["switch", requestedBranch])
: (await hasGitRef(repoRoot, `refs/remotes/origin/${requestedBranch}`))
? await runGitCommand(repoRoot, ["switch", "--track", `origin/${requestedBranch}`])
: await runGitCommand(repoRoot, ["switch", requestedBranch]);
? await runGit(repoRoot, ["switch", "--track", `origin/${requestedBranch}`])
: await runGit(repoRoot, ["switch", requestedBranch]);
const nextSummary = await getGitSyncSummary(repoRoot);

if (!commandResult.ok) {
Expand Down Expand Up @@ -400,15 +354,15 @@ export async function discardGitChanges(options: { cwd: string }): Promise<Runti
};
}

const restoreResult = await runGitCommand(repoRoot, [
const restoreResult = await runGit(repoRoot, [
"restore",
"--source=HEAD",
"--staged",
"--worktree",
"--",
".",
]);
const cleanResult = restoreResult.ok ? await runGitCommand(repoRoot, ["clean", "-fd", "--", "."]) : null;
const cleanResult = restoreResult.ok ? await runGit(repoRoot, ["clean", "-fd", "--", "."]) : null;
const nextSummary = await getGitSyncSummary(repoRoot);
const output = [restoreResult.output, cleanResult?.output ?? ""].filter(Boolean).join("\n");

Expand Down
Loading
Loading