import { execFile } from "node:child_process";import { existsSync } from "node:fs";import { readFile } from "node:fs/promises";import os from "node:os";import path from "node:path";import { promisify } from "node:util";import type { GitAction, GitBranch, GitRef, GitState, GitStatusEntry, GitWorktree,} from "@/features/agent/contracts"; const execFileAsync = promisify(execFile); export function configuredGitRoots(): string[] { const raw = process.env.LOCAL_STUDIO_GIT_DIFF_ROOTS; return (raw ? raw.split(path.delimiter) : [os.homedir()]) .map((entry) => entry.trim()) .filter(Boolean) .map((entry) => path.resolve(entry));} export function resolveGitCwd(input: string, roots = configuredGitRoots()): string | null { if (!path.isAbsolute(input)) return null; const candidate = path.resolve(input); return roots.some((root) => { const relative = path.relative(root, candidate); return ( relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)) ); }) ? candidate : null;} export function assertGitCwd( input: string | null | undefined,): { cwd: string; error?: never } | { cwd?: never; error: Response } { const requested = input?.trim(); if (!requested) return { error: Response.json({ error: "cwd is required" }, { status: 400 }) }; const cwd = resolveGitCwd(requested); if (!cwd) return { error: Response.json({ error: "cwd must be absolute" }, { status: 400 }) }; if (!existsSync(cwd)) return { error: Response.json({ error: "cwd not found" }, { status: 404 }) }; return { cwd };} async function git(cwd: string, args: string[]): Promise<string> { const { stdout } = await execFileAsync("git", args, { cwd, env: cleanGitEnv(), maxBuffer: 12 * 1024 * 1024, }); return stdout;} function cleanGitEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.GIT_DIR; delete env.GIT_WORK_TREE; delete env.GIT_INDEX_FILE; delete env.GIT_PREFIX; return env;} export async function loadGitState(cwd: string): Promise<GitState> { const inside = await git(cwd, ["rev-parse", "--is-inside-work-tree"]).catch(() => ""); if (inside.trim() !== "true") return emptyGitState(false); const hasHead = Boolean( (await git(cwd, ["rev-parse", "--verify", "HEAD"]).catch(() => "")).trim(), ); const diffArgs = hasHead ? ["diff", "--no-ext-diff", "HEAD", "--src-prefix=a/", "--dst-prefix=b/"] : ["diff", "--no-ext-diff", "--cached", "--src-prefix=a/", "--dst-prefix=b/"]; const numstatArgs = hasHead ? ["diff", "--numstat", "HEAD", "--"] : ["diff", "--numstat", "--cached", "--"]; const [branch, statusRaw, diff, numstat, untrackedRaw, refsRaw, upstream, remoteUrl] = await Promise.all([ git(cwd, ["branch", "--show-current"]).catch(() => ""), git(cwd, ["status", "--short"]), git(cwd, diffArgs), git(cwd, numstatArgs).catch(() => ""), git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]).catch(() => ""), git(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes"]).catch( () => "", ), git(cwd, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).catch(() => ""), git(cwd, ["remote", "get-url", "origin"]).catch(() => ""), ]); const current = branch.trim() || null; const trackedStats = numstatStats(numstat); const untracked = await untrackedFileDiffs(cwd, untrackedRaw); const additions = trackedStats.additions + untracked.additions; const deletions = trackedStats.deletions + untracked.deletions; return { isRepo: true, branch: current, status: statusLines(statusRaw), entries: statusEntries(statusRaw), // `git diff HEAD` omits untracked (new) files, so a "build me a site" // session that creates dozens of new files shows an empty diff while the // counter says +N. Append synthesized new-file diffs so the panel reviews // them like GitHub does. diff: untracked.diff ? `${diff}${diff.endsWith("\n") || !diff ? "" : "\n"}${untracked.diff}` : diff, additions, deletions, refs: parseRefs(refsRaw, current), hasUpstream: Boolean(upstream.trim()), remoteUrl: remoteUrl.trim() || null, prUrl: pullRequestUrl(remoteUrl.trim(), current), };} // git treats a leading-dash argument as an option, so a ref/branch like// `--upload-pack=…` would be interpreted as a flag even through execFile (which// only stops shell injection, not argument injection). Reject them.function assertNotOption(value: string, label: string): string { if (value.startsWith("-")) throw new Error(`Invalid ${label}: must not start with "-"`); return value;} export async function runGitAction(cwd: string, action: GitAction): Promise<GitState> { if (action.action === "init") await git(cwd, ["init"]); if (action.action === "checkout") await git(cwd, ["switch", "--", assertNotOption(action.ref, "ref")]); if (action.action === "switch_branch") await git(cwd, ["switch", assertNotOption(action.branch, "branch")]); if (action.action === "create_branch") await git(cwd, ["switch", "-c", assertNotOption(action.branch, "branch")]); if (action.action === "add_worktree") { const branch = assertNotOption(action.branch, "branch"); const worktreePath = assertWorktreePath(action.path); const hasBranch = Boolean( ( await git(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]).catch(() => "") ).trim(), ); await git( cwd, hasBranch ? ["worktree", "add", worktreePath, branch] : ["worktree", "add", "-b", branch, worktreePath], ); } if (action.action === "remove_worktree") { const worktreePath = assertWorktreePath(action.path); await git(cwd, ["worktree", "remove", "--force", worktreePath]); } if (action.action === "commit") { await git(cwd, ["add", "--", ...(action.paths.length ? action.paths : ["."])]); await git(cwd, ["commit", "-m", action.message]); } if (action.action === "push") { const state = await loadGitState(cwd); const branch = state.branch; await git(cwd, state.hasUpstream || !branch ? ["push"] : ["push", "-u", "origin", branch]); } return loadGitState(cwd);} /** Local branches plus the remote branches of the repo in `cwd`, each marked * with whether it is the checked-out one and whether it lives on a remote. */export async function listBranches(cwd: string): Promise<GitBranch[]> { const [branchRaw, localRaw] = await Promise.all([ git(cwd, ["branch", "--show-current"]).catch(() => ""), git(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]).catch(() => ""), ]); const current = branchRaw.trim() || null; const branches: GitBranch[] = []; const seen = new Set<string>(); for (const name of localRaw.split("\n")) { const trimmed = name.trim(); if (!trimmed) continue; seen.add(trimmed); branches.push({ name: trimmed, current: trimmed === current, remote: false }); } const remoteRaw = await git(cwd, [ "for-each-ref", "--format=%(refname:short)", "refs/remotes", ]).catch(() => ""); for (const name of remoteRaw.split("\n")) { const trimmed = name.trim(); if (!trimmed || seen.has(trimmed) || trimmed.endsWith("/HEAD")) continue; branches.push({ name: trimmed, current: false, remote: true }); } return branches;} /** The linked worktrees of the repo in `cwd`, parsed from porcelain output. */export async function listWorktrees(cwd: string): Promise<GitWorktree[]> { const raw = await git(cwd, ["worktree", "list", "--porcelain"]).catch(() => ""); const worktrees: GitWorktree[] = []; let entry: { path: string; branch: string | null } | null = null; for (const line of raw.split("\n")) { if (line.startsWith("worktree ")) { if (entry) worktrees.push({ ...entry, current: false }); entry = { path: line.slice("worktree ".length).trim(), branch: null }; } else if (entry && line.startsWith("branch ")) { entry.branch = line .slice("branch ".length) .trim() .replace(/^refs\/heads\//, ""); } } if (entry) worktrees.push({ ...entry, current: false }); const resolvedCwd = await path.resolve(cwd); return worktrees.map((worktree) => ({ ...worktree, current: path.resolve(worktree.path) === resolvedCwd, }));} /** A worktree lives outside a project root (it is a sibling leaf of the repo), * but it must still resolve inside the configured git roots so a request can't * point git at an arbitrary filesystem path. */function assertWorktreePath(input: string): string { const clean = input.trim(); if (!clean || !path.isAbsolute(clean)) throw new Error("Invalid worktree path: must be absolute"); const roots = configuredGitRoots(); const candidate = path.resolve(clean); const within = roots.some((root) => { const relative = path.relative(root, candidate); return ( relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)) ); }); if (!within) throw new Error("Invalid worktree path: outside allowed roots"); return candidate;} function emptyGitState(isRepo: boolean): GitState { return { isRepo, branch: null, status: [], entries: [], diff: "", additions: 0, deletions: 0, refs: [], hasUpstream: false, remoteUrl: null, prUrl: null, };} function statusLines(raw: string): string[] { return raw .split("\n") .map((line) => line.trimEnd()) .filter(Boolean);} function statusEntries(raw: string): GitStatusEntry[] { return statusLines(raw).map((line) => ({ code: line.slice(0, 2).trim() || "?", path: line.slice(3), }));} function parseRefs(raw: string, current: string | null): GitRef[] { const seen = new Set<string>(); return raw .split("\n") .map((line) => line.trim()) .filter(Boolean) .filter((name) => { if (name.endsWith("/HEAD") || seen.has(name)) return false; seen.add(name); return true; }) .map((name) => ({ name, current: name === current, remote: name.includes("/") }));} export function numstatStats(numstat: string): { additions: number; deletions: number } { let additions = 0; let deletions = 0; for (const line of numstat.split("\n")) { const [added, deleted] = line.split("\t"); const addedCount = Number.parseInt(added ?? "", 10); const deletedCount = Number.parseInt(deleted ?? "", 10); if (Number.isFinite(addedCount)) additions += addedCount; if (Number.isFinite(deletedCount)) deletions += deletedCount; } return { additions, deletions };} // Per-file and total caps so a session that generates large bundles (e.g. an// 800KB data.js) can't blow up the diff payload; GitHub collapses huge files// too. Beyond the cap the file's content is truncated with a marker.const MAX_UNTRACKED_LINES_PER_FILE = 1000;const MAX_UNTRACKED_DIFF_BYTES = 1_500_000; /** * Synthesize a unified-diff block for one untracked file so it renders as a * GitHub-style "new file" (all additions). Binary files emit a marker instead * of their bytes; long files are truncated to MAX_UNTRACKED_LINES_PER_FILE. * `additions` is the file's true line count (matching git's working-tree count), * not the possibly-truncated number of rendered `+` rows. */export function buildUntrackedFileDiffBlock( file: string, contents: string,): { block: string; additions: number } { const header = `diff --git a/${file} b/${file}\nnew file mode 100644`; if (contents.includes("\0")) { return { block: `${header}\nBinary files /dev/null and b/${file} differ\n`, additions: 0 }; } const lines = contents.split("\n"); if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); const shown = lines.slice(0, MAX_UNTRACKED_LINES_PER_FILE); const body = shown.map((line) => `+${line}`).join("\n"); const truncated = lines.length > shown.length ? `\n+… (${lines.length - shown.length} more lines not shown)` : ""; const block = `${header}\n--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n${body}${truncated}\n`; return { block, additions: lines.length };} async function untrackedFileDiffs( cwd: string, raw: string,): Promise<{ additions: number; deletions: number; diff: string }> { const files = raw.split("\0").filter(Boolean); let additions = 0; let bytes = 0; let omitted = 0; const blocks: string[] = []; for (const file of files) { const absolutePath = path.resolve(cwd, file); const relative = path.relative(cwd, absolutePath); if (relative.startsWith("..") || path.isAbsolute(relative)) continue; if (bytes >= MAX_UNTRACKED_DIFF_BYTES) { omitted += 1; continue; } let contents: string; try { contents = await readFile(absolutePath, "utf8"); } catch { continue; } const { block, additions: fileAdditions } = buildUntrackedFileDiffBlock(file, contents); additions += fileAdditions; bytes += block.length; blocks.push(block); } if (omitted > 0) { blocks.push( `diff --git a/(${omitted} more untracked files) b/(${omitted} more untracked files)\n` + `@@ -0,0 +1,1 @@\n+… ${omitted} more untracked file(s) not shown (diff size cap reached)\n`, ); } return { additions, deletions: 0, diff: blocks.join("") };} function pullRequestUrl(remoteUrl: string, branch: string | null): string | null { if (!remoteUrl || !branch) return null; const normalized = remoteUrl .replace(/^git@github.com:/, "https://github.com/") .replace(/\.git$/, ""); return normalized.startsWith("https://github.com/") ? `${normalized}/compare/${encodeURIComponent(branch)}?expand=1` : null;}
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import type {
GitAction,
GitBranch,
GitRef,
GitState,
GitStatusEntry,
GitWorktree,
} from "@/features/agent/contracts";
const execFileAsync = promisify(execFile);
export function configuredGitRoots(): string[] {
const raw = process.env.LOCAL_STUDIO_GIT_DIFF_ROOTS;
return (raw ? raw.split(path.delimiter) : [os.homedir()])
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => path.resolve(entry));
}
export function resolveGitCwd(input: string, roots = configuredGitRoots()): string | null {
if (!path.isAbsolute(input)) return null;
const candidate = path.resolve(input);
return roots.some((root) => {
const relative = path.relative(root, candidate);
return (
relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
);
})
? candidate
: null;
}
export function assertGitCwd(
input: string | null | undefined,
): { cwd: string; error?: never } | { cwd?: never; error: Response } {
const requested = input?.trim();
if (!requested) return { error: Response.json({ error: "cwd is required" }, { status: 400 }) };
const cwd = resolveGitCwd(requested);
if (!cwd) return { error: Response.json({ error: "cwd must be absolute" }, { status: 400 }) };
if (!existsSync(cwd))
return { error: Response.json({ error: "cwd not found" }, { status: 404 }) };
return { cwd };
}
async function git(cwd: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", args, {
cwd,
env: cleanGitEnv(),
maxBuffer: 12 * 1024 * 1024,
});
return stdout;
}
function cleanGitEnv(): NodeJS.ProcessEnv {
const env = { ...process.env };
delete env.GIT_DIR;
delete env.GIT_WORK_TREE;
delete env.GIT_INDEX_FILE;
delete env.GIT_PREFIX;
return env;
}
export async function loadGitState(cwd: string): Promise<GitState> {
const inside = await git(cwd, ["rev-parse", "--is-inside-work-tree"]).catch(() => "");
if (inside.trim() !== "true") return emptyGitState(false);
const hasHead = Boolean(
(await git(cwd, ["rev-parse", "--verify", "HEAD"]).catch(() => "")).trim(),
);
const diffArgs = hasHead
? ["diff", "--no-ext-diff", "HEAD", "--src-prefix=a/", "--dst-prefix=b/"]
: ["diff", "--no-ext-diff", "--cached", "--src-prefix=a/", "--dst-prefix=b/"];
const numstatArgs = hasHead
? ["diff", "--numstat", "HEAD", "--"]
: ["diff", "--numstat", "--cached", "--"];
const [branch, statusRaw, diff, numstat, untrackedRaw, refsRaw, upstream, remoteUrl] =
await Promise.all([
git(cwd, ["branch", "--show-current"]).catch(() => ""),
git(cwd, ["status", "--short"]),
git(cwd, diffArgs),
git(cwd, numstatArgs).catch(() => ""),
git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]).catch(() => ""),
git(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes"]).catch(
() => "",
),
git(cwd, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).catch(() => ""),
git(cwd, ["remote", "get-url", "origin"]).catch(() => ""),
]);
const current = branch.trim() || null;
const trackedStats = numstatStats(numstat);
const untracked = await untrackedFileDiffs(cwd, untrackedRaw);
const additions = trackedStats.additions + untracked.additions;
const deletions = trackedStats.deletions + untracked.deletions;
return {
isRepo: true,
branch: current,
status: statusLines(statusRaw),
entries: statusEntries(statusRaw),
// `git diff HEAD` omits untracked (new) files, so a "build me a site"
// session that creates dozens of new files shows an empty diff while the
// counter says +N. Append synthesized new-file diffs so the panel reviews
// them like GitHub does.
diff: untracked.diff
? `${diff}${diff.endsWith("\n") || !diff ? "" : "\n"}${untracked.diff}`
: diff,
additions,
deletions,
refs: parseRefs(refsRaw, current),
hasUpstream: Boolean(upstream.trim()),
remoteUrl: remoteUrl.trim() || null,
prUrl: pullRequestUrl(remoteUrl.trim(), current),
};
}
// git treats a leading-dash argument as an option, so a ref/branch like
// `--upload-pack=…` would be interpreted as a flag even through execFile (which
// only stops shell injection, not argument injection). Reject them.
function assertNotOption(value: string, label: string): string {
if (value.startsWith("-")) throw new Error(`Invalid ${label}: must not start with "-"`);
return value;
}
export async function runGitAction(cwd: string, action: GitAction): Promise<GitState> {
if (action.action === "init") await git(cwd, ["init"]);
if (action.action === "checkout")
await git(cwd, ["switch", "--", assertNotOption(action.ref, "ref")]);
if (action.action === "switch_branch")
await git(cwd, ["switch", assertNotOption(action.branch, "branch")]);
if (action.action === "create_branch")
await git(cwd, ["switch", "-c", assertNotOption(action.branch, "branch")]);
if (action.action === "add_worktree") {
const branch = assertNotOption(action.branch, "branch");
const worktreePath = assertWorktreePath(action.path);
const hasBranch = Boolean(
(
await git(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]).catch(() => "")
).trim(),
);
await git(
cwd,
hasBranch
? ["worktree", "add", worktreePath, branch]
: ["worktree", "add", "-b", branch, worktreePath],
);
}
if (action.action === "remove_worktree") {
const worktreePath = assertWorktreePath(action.path);
await git(cwd, ["worktree", "remove", "--force", worktreePath]);
}
if (action.action === "commit") {
await git(cwd, ["add", "--", ...(action.paths.length ? action.paths : ["."])]);
await git(cwd, ["commit", "-m", action.message]);
}
if (action.action === "push") {
const state = await loadGitState(cwd);
const branch = state.branch;
await git(cwd, state.hasUpstream || !branch ? ["push"] : ["push", "-u", "origin", branch]);
}
return loadGitState(cwd);
}
/** Local branches plus the remote branches of the repo in `cwd`, each marked
* with whether it is the checked-out one and whether it lives on a remote. */
export async function listBranches(cwd: string): Promise<GitBranch[]> {
const [branchRaw, localRaw] = await Promise.all([
git(cwd, ["branch", "--show-current"]).catch(() => ""),
git(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]).catch(() => ""),
]);
const current = branchRaw.trim() || null;
const branches: GitBranch[] = [];
const seen = new Set<string>();
for (const name of localRaw.split("\n")) {
const trimmed = name.trim();
if (!trimmed) continue;
seen.add(trimmed);
branches.push({ name: trimmed, current: trimmed === current, remote: false });
}
const remoteRaw = await git(cwd, [
"for-each-ref",
"--format=%(refname:short)",
"refs/remotes",
]).catch(() => "");
for (const name of remoteRaw.split("\n")) {
const trimmed = name.trim();
if (!trimmed || seen.has(trimmed) || trimmed.endsWith("/HEAD")) continue;
branches.push({ name: trimmed, current: false, remote: true });
}
return branches;
}
/** The linked worktrees of the repo in `cwd`, parsed from porcelain output. */
export async function listWorktrees(cwd: string): Promise<GitWorktree[]> {
const raw = await git(cwd, ["worktree", "list", "--porcelain"]).catch(() => "");
const worktrees: GitWorktree[] = [];
let entry: { path: string; branch: string | null } | null = null;
for (const line of raw.split("\n")) {
if (line.startsWith("worktree ")) {
if (entry) worktrees.push({ ...entry, current: false });
entry = { path: line.slice("worktree ".length).trim(), branch: null };
} else if (entry && line.startsWith("branch ")) {
entry.branch = line
.slice("branch ".length)
.trim()
.replace(/^refs\/heads\//, "");
}
}
if (entry) worktrees.push({ ...entry, current: false });
const resolvedCwd = await path.resolve(cwd);
return worktrees.map((worktree) => ({
...worktree,
current: path.resolve(worktree.path) === resolvedCwd,
}));
}
/** A worktree lives outside a project root (it is a sibling leaf of the repo),
* but it must still resolve inside the configured git roots so a request can't
* point git at an arbitrary filesystem path. */
function assertWorktreePath(input: string): string {
const clean = input.trim();
if (!clean || !path.isAbsolute(clean)) throw new Error("Invalid worktree path: must be absolute");
const roots = configuredGitRoots();
const candidate = path.resolve(clean);
const within = roots.some((root) => {
const relative = path.relative(root, candidate);
return (
relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative))
);
});
if (!within) throw new Error("Invalid worktree path: outside allowed roots");
return candidate;
}
function emptyGitState(isRepo: boolean): GitState {
return {
isRepo,
branch: null,
status: [],
entries: [],
diff: "",
additions: 0,
deletions: 0,
refs: [],
hasUpstream: false,
remoteUrl: null,
prUrl: null,
};
}
function statusLines(raw: string): string[] {
return raw
.split("\n")
.map((line) => line.trimEnd())
.filter(Boolean);
}
function statusEntries(raw: string): GitStatusEntry[] {
return statusLines(raw).map((line) => ({
code: line.slice(0, 2).trim() || "?",
path: line.slice(3),
}));
}
function parseRefs(raw: string, current: string | null): GitRef[] {
const seen = new Set<string>();
return raw
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.filter((name) => {
if (name.endsWith("/HEAD") || seen.has(name)) return false;
seen.add(name);
return true;
})
.map((name) => ({ name, current: name === current, remote: name.includes("/") }));
}
export function numstatStats(numstat: string): { additions: number; deletions: number } {
let additions = 0;
let deletions = 0;
for (const line of numstat.split("\n")) {
const [added, deleted] = line.split("\t");
const addedCount = Number.parseInt(added ?? "", 10);
const deletedCount = Number.parseInt(deleted ?? "", 10);
if (Number.isFinite(addedCount)) additions += addedCount;
if (Number.isFinite(deletedCount)) deletions += deletedCount;
}
return { additions, deletions };
}
// Per-file and total caps so a session that generates large bundles (e.g. an
// 800KB data.js) can't blow up the diff payload; GitHub collapses huge files
// too. Beyond the cap the file's content is truncated with a marker.
const MAX_UNTRACKED_LINES_PER_FILE = 1000;
const MAX_UNTRACKED_DIFF_BYTES = 1_500_000;
/**
* Synthesize a unified-diff block for one untracked file so it renders as a
* GitHub-style "new file" (all additions). Binary files emit a marker instead
* of their bytes; long files are truncated to MAX_UNTRACKED_LINES_PER_FILE.
* `additions` is the file's true line count (matching git's working-tree count),
* not the possibly-truncated number of rendered `+` rows.
*/
export function buildUntrackedFileDiffBlock(
file: string,
contents: string,
): { block: string; additions: number } {
const header = `diff --git a/${file} b/${file}\nnew file mode 100644`;
if (contents.includes("\0")) {
return { block: `${header}\nBinary files /dev/null and b/${file} differ\n`, additions: 0 };
}
const lines = contents.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
const shown = lines.slice(0, MAX_UNTRACKED_LINES_PER_FILE);
const body = shown.map((line) => `+${line}`).join("\n");
const truncated =
lines.length > shown.length ? `\n+… (${lines.length - shown.length} more lines not shown)` : "";
const block = `${header}\n--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n${body}${truncated}\n`;
return { block, additions: lines.length };
}
async function untrackedFileDiffs(
cwd: string,
raw: string,
): Promise<{ additions: number; deletions: number; diff: string }> {
const files = raw.split("\0").filter(Boolean);
let additions = 0;
let bytes = 0;
let omitted = 0;
const blocks: string[] = [];
for (const file of files) {
const absolutePath = path.resolve(cwd, file);
const relative = path.relative(cwd, absolutePath);
if (relative.startsWith("..") || path.isAbsolute(relative)) continue;
if (bytes >= MAX_UNTRACKED_DIFF_BYTES) {
omitted += 1;
continue;
}
let contents: string;
try {
contents = await readFile(absolutePath, "utf8");
} catch {
continue;
}
const { block, additions: fileAdditions } = buildUntrackedFileDiffBlock(file, contents);
additions += fileAdditions;
bytes += block.length;
blocks.push(block);
}
if (omitted > 0) {
blocks.push(
`diff --git a/(${omitted} more untracked files) b/(${omitted} more untracked files)\n` +
`@@ -0,0 +1,1 @@\n+… ${omitted} more untracked file(s) not shown (diff size cap reached)\n`,
);
}
return { additions, deletions: 0, diff: blocks.join("") };
}
function pullRequestUrl(remoteUrl: string, branch: string | null): string | null {
if (!remoteUrl || !branch) return null;
const normalized = remoteUrl
.replace(/^git@github.com:/, "https://github.com/")
.replace(/\.git$/, "");
return normalized.startsWith("https://github.com/")
? `${normalized}/compare/${encodeURIComponent(branch)}?expand=1`
: null;
}