Merge origin/main into dev (second round)
Resolve 21 conflicts from latest main (provider profiles, Windows fixes, 0.1.55-rc.1). Fix all type errors and syntax issues from merge artifacts.
This commit is contained in:
@@ -20,7 +20,9 @@ vi.mock("child_process", async () => {
|
||||
normalizedArgs[0] === "diff" &&
|
||||
normalizedArgs.includes("HEAD") &&
|
||||
!normalizedArgs.includes("--numstat") &&
|
||||
!normalizedArgs.includes("--no-index");
|
||||
!normalizedArgs.includes("--no-index") &&
|
||||
!normalizedArgs.includes("--shortstat") &&
|
||||
!normalizedArgs.includes("--name-status");
|
||||
if (isTrackedTextDiff) {
|
||||
spawnCounters.trackedTextDiffCalls += 1;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { exec, execFile } from "child_process";
|
||||
import { spawnProcess } from "./spawn.js";
|
||||
import { promisify } from "util";
|
||||
import { resolve, dirname, basename } from "path";
|
||||
import { existsSync, realpathSync } from "fs";
|
||||
import { open as openFile, stat as statFile } from "fs/promises";
|
||||
@@ -9,17 +6,15 @@ import { z } from "zod";
|
||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
||||
import { findExecutable } from "./executable.js";
|
||||
import { runGitCommand } from "./run-git-command.js";
|
||||
import { execCommand } from "./spawn.js";
|
||||
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
|
||||
import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
GIT_OPTIONAL_LOCKS: "0",
|
||||
};
|
||||
|
||||
const SMALL_OUTPUT_MAX_BUFFER = 20 * 1024 * 1024; // 20MB
|
||||
const DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS = 30_000;
|
||||
const PULL_REQUEST_STATUS_CACHE_MAX = 1_000;
|
||||
const DEFAULT_SHORTSTAT_CACHE_TTL_MS = 15_000;
|
||||
@@ -97,91 +92,6 @@ export function __setCheckoutShortstatCacheTtlForTests(ttlMs: number): void {
|
||||
shortstatInFlight.clear();
|
||||
}
|
||||
|
||||
async function execGit(
|
||||
command: string,
|
||||
options: { cwd: string; env?: NodeJS.ProcessEnv },
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return execAsync(command, { ...options, maxBuffer: SMALL_OUTPUT_MAX_BUFFER });
|
||||
}
|
||||
|
||||
type LimitedTextResult = {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
exitCode: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
};
|
||||
|
||||
async function spawnLimitedText(params: {
|
||||
cmd: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
maxBytes: number;
|
||||
acceptExitCodes?: number[];
|
||||
}): Promise<LimitedTextResult> {
|
||||
const accept = new Set(params.acceptExitCodes ?? [0]);
|
||||
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const child = spawnProcess(params.cmd, params.args, {
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
let stdoutBytes = 0;
|
||||
let truncated = false;
|
||||
|
||||
const stop = () => {
|
||||
if (child.killed) return;
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout!.on("data", (chunk: Buffer) => {
|
||||
if (truncated) return;
|
||||
stdoutBytes += chunk.length;
|
||||
if (stdoutBytes > params.maxBytes) {
|
||||
truncated = true;
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
stdoutChunks.push(chunk);
|
||||
});
|
||||
|
||||
// We don't buffer stderr (it can be large too). Keep it minimal for debugging.
|
||||
let stderrPreview = "";
|
||||
child.stderr!.on("data", (chunk: Buffer) => {
|
||||
if (stderrPreview.length > 2048) return;
|
||||
stderrPreview += chunk.toString("utf8");
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
rejectPromise(error);
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
if (code !== null && !accept.has(code) && !truncated) {
|
||||
rejectPromise(
|
||||
new Error(
|
||||
`Command failed: ${params.cmd} ${params.args.join(" ")} (code ${code})\n${stderrPreview}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolvePromise({
|
||||
text: Buffer.concat(stdoutChunks).toString("utf8"),
|
||||
truncated,
|
||||
exitCode: code,
|
||||
signal,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
type CheckoutFileChange = {
|
||||
path: string;
|
||||
oldPath?: string;
|
||||
@@ -221,8 +131,13 @@ interface GitRef {
|
||||
}
|
||||
|
||||
async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
|
||||
const { stdout } = await execGit(
|
||||
`git for-each-ref --sort=-committerdate --format="%(refname)%09%(committerdate:unix)" ${refPrefix}`,
|
||||
const { stdout } = await runGitCommand(
|
||||
[
|
||||
"for-each-ref",
|
||||
"--sort=-committerdate",
|
||||
"--format=%(refname)%09%(committerdate:unix)",
|
||||
refPrefix,
|
||||
],
|
||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
return stdout
|
||||
@@ -324,17 +239,12 @@ async function listCheckoutFileChanges(
|
||||
): Promise<CheckoutFileChange[]> {
|
||||
const changes: CheckoutFileChange[] = [];
|
||||
|
||||
const { stdout: nameStatusOut } = await execFileAsync(
|
||||
"git",
|
||||
const { stdout: nameStatusOut } = await runGitCommand(
|
||||
buildGitDiffArgs({
|
||||
ignoreWhitespace,
|
||||
extra: ["--name-status", ref],
|
||||
}),
|
||||
{
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBuffer: SMALL_OUTPUT_MAX_BUFFER,
|
||||
},
|
||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
for (const line of nameStatusOut
|
||||
.split("\n")
|
||||
@@ -371,10 +281,13 @@ async function listCheckoutFileChanges(
|
||||
});
|
||||
}
|
||||
|
||||
const { stdout: untrackedOut } = await execGit("git ls-files --others --exclude-standard", {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const { stdout: untrackedOut } = await runGitCommand(
|
||||
["ls-files", "--others", "--exclude-standard"],
|
||||
{
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
for (const file of untrackedOut
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
@@ -409,10 +322,9 @@ async function readGitFileContentAtRef(
|
||||
path: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("git", ["show", `${ref}:${path}`], {
|
||||
const { stdout } = await runGitCommand(["show", `${ref}:${path}`], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBuffer: SMALL_OUTPUT_MAX_BUFFER,
|
||||
});
|
||||
return stdout;
|
||||
} catch {
|
||||
@@ -422,7 +334,7 @@ async function readGitFileContentAtRef(
|
||||
|
||||
async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execGit(`git merge-base ${baseRef} HEAD`, {
|
||||
const { stdout } = await runGitCommand(["merge-base", baseRef, "HEAD"], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -462,20 +374,21 @@ async function getTrackedNumstatByPath(
|
||||
ref: string,
|
||||
ignoreWhitespace = false,
|
||||
): Promise<Map<string, FileStat>> {
|
||||
const result = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: buildGitDiffArgs({
|
||||
const result = await runGitCommand(
|
||||
buildGitDiffArgs({
|
||||
ignoreWhitespace,
|
||||
extra: ["--numstat", ref],
|
||||
}),
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
|
||||
acceptExitCodes: [0],
|
||||
});
|
||||
{
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxOutputBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
|
||||
acceptExitCodes: [0],
|
||||
},
|
||||
);
|
||||
|
||||
const stats = new Map<string, FileStat>();
|
||||
const lines = result.text
|
||||
const lines = result.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
@@ -671,14 +584,14 @@ function isGitError(error: unknown): boolean {
|
||||
|
||||
async function requireGitRepo(cwd: string): Promise<void> {
|
||||
try {
|
||||
await execAsync("git rev-parse --git-dir", { cwd, env: READ_ONLY_GIT_ENV });
|
||||
await runGitCommand(["rev-parse", "--git-dir"], { cwd, env: READ_ONLY_GIT_ENV });
|
||||
} catch (error) {
|
||||
throw new NotGitRepoError(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentBranch(cwd: string): Promise<string | null> {
|
||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -688,10 +601,13 @@ export async function getCurrentBranch(cwd: string): Promise<string | null> {
|
||||
|
||||
async function getWorktreeRoot(cwd: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const { stdout } = await runGitCommand(
|
||||
["rev-parse", "--path-format=absolute", "--show-toplevel"],
|
||||
{
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
const root = stdout.trim();
|
||||
return root.length > 0 ? root : null;
|
||||
} catch {
|
||||
@@ -700,8 +616,8 @@ async function getWorktreeRoot(cwd: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
export async function getMainRepoRoot(cwd: string): Promise<string> {
|
||||
const { stdout: commonDirOut } = await execAsync(
|
||||
"git rev-parse --path-format=absolute --git-common-dir",
|
||||
const { stdout: commonDirOut } = await runGitCommand(
|
||||
["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
const commonDir = commonDirOut.trim();
|
||||
@@ -711,7 +627,7 @@ export async function getMainRepoRoot(cwd: string): Promise<string> {
|
||||
return dirname(normalized);
|
||||
}
|
||||
|
||||
const { stdout: worktreeOut } = await execAsync("git worktree list --porcelain", {
|
||||
const { stdout: worktreeOut } = await runGitCommand(["worktree", "list", "--porcelain"], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -777,7 +693,7 @@ export function parseWorktreeList(output: string): GitWorktreeEntry[] {
|
||||
|
||||
async function getWorktreePathForBranch(cwd: string, branchName: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -800,8 +716,9 @@ export async function renameCurrentBranch(
|
||||
throw new Error("Cannot rename branch in detached HEAD state");
|
||||
}
|
||||
|
||||
await execAsync(`git branch -m "${newName}"`, {
|
||||
await runGitCommand(["branch", "-m", newName], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
@@ -834,7 +751,7 @@ async function getConfiguredBaseRefForCwd(
|
||||
}
|
||||
|
||||
async function isWorkingTreeDirty(cwd: string): Promise<boolean> {
|
||||
const { stdout } = await execAsync("git status --porcelain", {
|
||||
const { stdout } = await runGitCommand(["status", "--porcelain"], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -843,7 +760,7 @@ async function isWorkingTreeDirty(cwd: string): Promise<boolean> {
|
||||
|
||||
export async function getOriginRemoteUrl(cwd: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git config --get remote.origin.url", {
|
||||
const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -861,7 +778,7 @@ export async function hasOriginRemote(cwd: string): Promise<boolean> {
|
||||
|
||||
export async function resolveAbsoluteGitDir(cwd: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse --absolute-git-dir", {
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--absolute-git-dir"], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -884,7 +801,7 @@ async function abortGitPullConflictState(cwd: string): Promise<void> {
|
||||
|
||||
if (existsSync(mergeHeadPath)) {
|
||||
try {
|
||||
await execAsync("git merge --abort", { cwd });
|
||||
await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -892,7 +809,7 @@ async function abortGitPullConflictState(cwd: string): Promise<void> {
|
||||
|
||||
if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) {
|
||||
try {
|
||||
await execAsync("git rebase --abort", { cwd });
|
||||
await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -901,10 +818,13 @@ async function abortGitPullConflictState(cwd: string): Promise<void> {
|
||||
|
||||
export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", {
|
||||
cwd: repoRoot,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const { stdout } = await runGitCommand(
|
||||
["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
const ref = stdout.trim();
|
||||
if (ref) {
|
||||
// Prefer a local branch name (e.g. "main") over the remote-tracking ref (e.g. "origin/main")
|
||||
@@ -914,7 +834,7 @@ export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<
|
||||
? remoteShort.slice("origin/".length)
|
||||
: remoteShort;
|
||||
try {
|
||||
await execAsync(`git show-ref --verify --quiet refs/heads/${localName}`, {
|
||||
await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${localName}`], {
|
||||
cwd: repoRoot,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -927,7 +847,7 @@ export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<
|
||||
// ignore
|
||||
}
|
||||
|
||||
const { stdout } = await execAsync("git branch --format='%(refname:short)'", {
|
||||
const { stdout } = await runGitCommand(["branch", "--format=%(refname:short)"], {
|
||||
cwd: repoRoot,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -956,7 +876,7 @@ function normalizeLocalBranchRefName(input: string): string {
|
||||
|
||||
async function doesGitRefExist(cwd: string, fullRef: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync(`git show-ref --verify --quiet ${fullRef}`, {
|
||||
await runGitCommand(["show-ref", "--verify", "--quiet", fullRef], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -987,8 +907,8 @@ async function resolveBestComparisonBaseRef(
|
||||
|
||||
// Both exist: choose the ref with more unique commits compared to the other.
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git rev-list --left-right --count ${normalizedBaseRef}...origin/${normalizedBaseRef}`,
|
||||
const { stdout } = await runGitCommand(
|
||||
["rev-list", "--left-right", "--count", `${normalizedBaseRef}...origin/${normalizedBaseRef}`],
|
||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
const [localOnlyRaw, originOnlyRaw] = stdout.trim().split(/\s+/);
|
||||
@@ -1014,8 +934,8 @@ async function getAheadBehind(
|
||||
return null;
|
||||
}
|
||||
const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, normalizedBaseRef);
|
||||
const { stdout } = await execAsync(
|
||||
`git rev-list --left-right --count ${comparisonBaseRef}...${currentBranch}`,
|
||||
const { stdout } = await runGitCommand(
|
||||
["rev-list", "--left-right", "--count", `${comparisonBaseRef}...${currentBranch}`],
|
||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/);
|
||||
@@ -1032,15 +952,15 @@ async function getAheadOfOrigin(cwd: string, currentBranch: string): Promise<num
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git rev-list --count origin/${currentBranch}..${currentBranch}`,
|
||||
const { stdout } = await runGitCommand(
|
||||
["rev-list", "--count", `origin/${currentBranch}..${currentBranch}`],
|
||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
const count = Number.parseInt(stdout.trim(), 10);
|
||||
return Number.isNaN(count) ? null : count;
|
||||
} catch {
|
||||
try {
|
||||
const { stdout } = await execAsync(`git rev-list --count ${currentBranch}`, {
|
||||
const { stdout } = await runGitCommand(["rev-list", "--count", currentBranch], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -1057,8 +977,8 @@ async function getBehindOfOrigin(cwd: string, currentBranch: string): Promise<nu
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git rev-list --count ${currentBranch}..origin/${currentBranch}`,
|
||||
const { stdout } = await runGitCommand(
|
||||
["rev-list", "--count", `${currentBranch}..origin/${currentBranch}`],
|
||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
const count = Number.parseInt(stdout.trim(), 10);
|
||||
@@ -1196,19 +1116,20 @@ async function getUntrackedDiffText(
|
||||
// Fall through to git diff path if metadata probing fails.
|
||||
}
|
||||
|
||||
const result = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: buildGitDiffArgs({
|
||||
const result = await runGitCommand(
|
||||
buildGitDiffArgs({
|
||||
ignoreWhitespace,
|
||||
extra: ["--no-index", "/dev/null", "--", change.path],
|
||||
}),
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
||||
acceptExitCodes: [0, 1],
|
||||
});
|
||||
{
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxOutputBytes: PER_FILE_DIFF_MAX_BYTES,
|
||||
acceptExitCodes: [0, 1],
|
||||
},
|
||||
);
|
||||
return {
|
||||
text: result.text,
|
||||
text: result.stdout,
|
||||
truncated: result.truncated,
|
||||
stat: { additions: 0, deletions: 0, isBinary: false },
|
||||
};
|
||||
@@ -1335,7 +1256,7 @@ async function getCheckoutShortstatUncached(
|
||||
);
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(`git merge-base HEAD ${comparisonBaseRef}`, {
|
||||
const { stdout } = await runGitCommand(["merge-base", "HEAD", comparisonBaseRef], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -1360,7 +1281,7 @@ async function getCheckoutShortstatUncached(
|
||||
|
||||
try {
|
||||
// Omit HEAD so the diff includes uncommitted (staged + unstaged) changes
|
||||
const { stdout } = await execAsync(`git diff --shortstat ${diffTarget}`, {
|
||||
const { stdout } = await runGitCommand(["diff", "--shortstat", diffTarget], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -1530,17 +1451,18 @@ export async function getCheckoutDiff(
|
||||
let trackedDiffText = "";
|
||||
let trackedDiffTruncated = false;
|
||||
if (trackedDiffPaths.length > 0) {
|
||||
const trackedDiffResult = await spawnLimitedText({
|
||||
cmd: "git",
|
||||
args: buildGitDiffArgs({
|
||||
const trackedDiffResult = await runGitCommand(
|
||||
buildGitDiffArgs({
|
||||
ignoreWhitespace,
|
||||
extra: [refForDiff, "--", ...trackedDiffPaths],
|
||||
}),
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxBytes: TOTAL_DIFF_MAX_BYTES,
|
||||
});
|
||||
trackedDiffText = trackedDiffResult.text;
|
||||
{
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
maxOutputBytes: TOTAL_DIFF_MAX_BYTES,
|
||||
},
|
||||
);
|
||||
trackedDiffText = trackedDiffResult.stdout;
|
||||
trackedDiffTruncated = trackedDiffResult.truncated;
|
||||
appendDiff(trackedDiffText);
|
||||
if (trackedDiffTruncated) {
|
||||
@@ -1695,10 +1617,11 @@ export async function commitChanges(
|
||||
): Promise<void> {
|
||||
await requireGitRepo(cwd);
|
||||
if (options.addAll ?? true) {
|
||||
await execFileAsync("git", ["add", "-A"], { cwd });
|
||||
await runGitCommand(["add", "-A"], { cwd, timeout: 120_000 });
|
||||
}
|
||||
await execFileAsync("git", ["-c", "commit.gpgsign=false", "commit", "-m", options.message], {
|
||||
await runGitCommand(["-c", "commit.gpgsign=false", "commit", "-m", options.message], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1737,16 +1660,23 @@ export async function mergeToBase(
|
||||
const originalBranch = await getCurrentBranch(operationCwd);
|
||||
const mode = options.mode ?? "merge";
|
||||
try {
|
||||
await execAsync(`git checkout ${normalizedBaseRef}`, { cwd: operationCwd });
|
||||
await runGitCommand(["checkout", normalizedBaseRef], {
|
||||
cwd: operationCwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
if (mode === "squash") {
|
||||
await execAsync(`git merge --squash ${currentBranch}`, { cwd: operationCwd });
|
||||
await runGitCommand(["merge", "--squash", currentBranch], {
|
||||
cwd: operationCwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
const message =
|
||||
options.commitMessage ?? `Squash merge ${currentBranch} into ${normalizedBaseRef}`;
|
||||
await execFileAsync("git", ["-c", "commit.gpgsign=false", "commit", "-m", message], {
|
||||
await runGitCommand(["-c", "commit.gpgsign=false", "commit", "-m", message], {
|
||||
cwd: operationCwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
} else {
|
||||
await execAsync(`git merge ${currentBranch}`, { cwd: operationCwd });
|
||||
await runGitCommand(["merge", currentBranch], { cwd: operationCwd, timeout: 120_000 });
|
||||
}
|
||||
} catch (error) {
|
||||
const errorDetails =
|
||||
@@ -1755,9 +1685,9 @@ export async function mergeToBase(
|
||||
: String(error);
|
||||
try {
|
||||
const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([
|
||||
execAsync("git diff --name-only --diff-filter=U", { cwd: operationCwd }),
|
||||
execAsync("git ls-files -u", { cwd: operationCwd }),
|
||||
execAsync("git status --porcelain", { cwd: operationCwd }),
|
||||
runGitCommand(["diff", "--name-only", "--diff-filter=U"], { cwd: operationCwd }),
|
||||
runGitCommand(["ls-files", "-u"], { cwd: operationCwd }),
|
||||
runGitCommand(["status", "--porcelain"], { cwd: operationCwd }),
|
||||
]);
|
||||
const statusConflicts = statusOutput.stdout
|
||||
.split("\n")
|
||||
@@ -1781,7 +1711,7 @@ export async function mergeToBase(
|
||||
conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails);
|
||||
if (conflictDetected) {
|
||||
try {
|
||||
await execAsync("git merge --abort", { cwd: operationCwd });
|
||||
await runGitCommand(["merge", "--abort"], { cwd: operationCwd, timeout: 120_000 });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -1802,7 +1732,10 @@ export async function mergeToBase(
|
||||
} finally {
|
||||
if (isSameCheckout && originalBranch && originalBranch !== normalizedBaseRef) {
|
||||
try {
|
||||
await execAsync(`git checkout ${originalBranch}`, { cwd: operationCwd });
|
||||
await runGitCommand(["checkout", originalBranch], {
|
||||
cwd: operationCwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -1832,7 +1765,7 @@ export async function mergeFromBase(
|
||||
|
||||
const requireCleanTarget = options.requireCleanTarget ?? true;
|
||||
if (requireCleanTarget) {
|
||||
const { stdout } = await execAsync("git status --porcelain", {
|
||||
const { stdout } = await runGitCommand(["status", "--porcelain"], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -1848,7 +1781,7 @@ export async function mergeFromBase(
|
||||
}
|
||||
|
||||
try {
|
||||
await execAsync(`git merge ${bestBaseRef}`, { cwd });
|
||||
await runGitCommand(["merge", bestBaseRef], { cwd, timeout: 120_000 });
|
||||
} catch (error) {
|
||||
const errorDetails =
|
||||
error instanceof Error
|
||||
@@ -1856,9 +1789,9 @@ export async function mergeFromBase(
|
||||
: String(error);
|
||||
try {
|
||||
const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([
|
||||
execAsync("git diff --name-only --diff-filter=U", { cwd }),
|
||||
execAsync("git ls-files -u", { cwd }),
|
||||
execAsync("git status --porcelain", { cwd }),
|
||||
runGitCommand(["diff", "--name-only", "--diff-filter=U"], { cwd }),
|
||||
runGitCommand(["ls-files", "-u"], { cwd }),
|
||||
runGitCommand(["status", "--porcelain"], { cwd }),
|
||||
]);
|
||||
const statusConflicts = statusOutput.stdout
|
||||
.split("\n")
|
||||
@@ -1882,7 +1815,7 @@ export async function mergeFromBase(
|
||||
conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails);
|
||||
if (conflictDetected) {
|
||||
try {
|
||||
await execAsync("git merge --abort", { cwd });
|
||||
await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -1914,7 +1847,7 @@ export async function pullCurrentBranch(cwd: string): Promise<void> {
|
||||
throw new Error("Remote 'origin' is not configured.");
|
||||
}
|
||||
try {
|
||||
await execAsync("git pull", { cwd });
|
||||
await runGitCommand(["pull"], { cwd, timeout: 120_000 });
|
||||
} catch (error) {
|
||||
await abortGitPullConflictState(cwd);
|
||||
throw error;
|
||||
@@ -1931,7 +1864,7 @@ export async function pushCurrentBranch(cwd: string): Promise<void> {
|
||||
if (!hasRemote) {
|
||||
throw new Error("Remote 'origin' is not configured.");
|
||||
}
|
||||
await execAsync(`git push -u origin ${currentBranch}`, { cwd });
|
||||
await runGitCommand(["push", "-u", "origin", currentBranch], { cwd, timeout: 120_000 });
|
||||
}
|
||||
|
||||
export interface CreatePullRequestOptions {
|
||||
@@ -2016,7 +1949,6 @@ const ReviewDecisionSchema = z
|
||||
type CheckRunNode = z.infer<typeof CheckRunNodeSchema>;
|
||||
type StatusContextNode = z.infer<typeof StatusContextNodeSchema>;
|
||||
|
||||
|
||||
export async function resolveGhPath(): Promise<string> {
|
||||
if (cachedGhPath === undefined) {
|
||||
cachedGhPath = await findExecutable("gh");
|
||||
@@ -2048,10 +1980,7 @@ function isGhAuthError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function mapCheckRunStatus(
|
||||
status: unknown,
|
||||
conclusion: unknown,
|
||||
): PullRequestCheck["status"] {
|
||||
function mapCheckRunStatus(status: unknown, conclusion: unknown): PullRequestCheck["status"] {
|
||||
if (status !== "COMPLETED") {
|
||||
return "pending";
|
||||
}
|
||||
@@ -2203,7 +2132,7 @@ function mapReviewDecision(value: unknown): ReviewDecision {
|
||||
|
||||
async function resolveGitHubRepo(cwd: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git config --get remote.origin.url", {
|
||||
const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -2265,7 +2194,7 @@ export async function createPullRequest(
|
||||
throw new Error(`Base ref mismatch: expected ${base}, got ${options.base}`);
|
||||
}
|
||||
|
||||
await execAsync(`git push -u origin ${head}`, { cwd });
|
||||
await runGitCommand(["push", "-u", "origin", head], { cwd, timeout: 120_000 });
|
||||
|
||||
const ghEnv: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||
const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`];
|
||||
@@ -2274,7 +2203,7 @@ export async function createPullRequest(
|
||||
if (options.body) {
|
||||
args.push("-f", `body=${options.body}`);
|
||||
}
|
||||
const { stdout } = await execFileAsync(ghPath, args, { cwd, env: ghEnv });
|
||||
const { stdout } = await execCommand(ghPath, args, { cwd, env: ghEnv });
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
if (!parsed?.url || !parsed?.number) {
|
||||
throw new Error("GitHub CLI did not return PR url/number");
|
||||
@@ -2326,7 +2255,7 @@ async function getPullRequestStatusUncached(cwd: string): Promise<PullRequestSta
|
||||
};
|
||||
}
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
const { stdout } = await execCommand(
|
||||
ghPath,
|
||||
[
|
||||
"pr",
|
||||
@@ -2407,7 +2336,7 @@ export async function searchGitHubIssuesAndPrs(
|
||||
|
||||
try {
|
||||
const [issuesResult, prsResult] = await Promise.allSettled([
|
||||
execFileAsync(
|
||||
execCommand(
|
||||
ghPath,
|
||||
[
|
||||
"issue",
|
||||
@@ -2421,7 +2350,7 @@ export async function searchGitHubIssuesAndPrs(
|
||||
],
|
||||
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
|
||||
),
|
||||
execFileAsync(
|
||||
execCommand(
|
||||
ghPath,
|
||||
[
|
||||
"pr",
|
||||
|
||||
@@ -4,6 +4,8 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { searchHomeDirectories, searchWorkspaceEntries } from "./directory-suggestions.js";
|
||||
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
describe("searchHomeDirectories", () => {
|
||||
let tempRoot: string;
|
||||
let homeDir: string;
|
||||
@@ -101,7 +103,7 @@ describe("searchHomeDirectories", () => {
|
||||
expect(earlierIndex).toBeLessThan(laterIndex);
|
||||
});
|
||||
|
||||
it("returns home-root suggestions when query is '~'", async () => {
|
||||
it.skipIf(isWindows)("returns home-root suggestions when query is '~'", async () => {
|
||||
const results = await searchHomeDirectories({
|
||||
homeDir,
|
||||
query: "~",
|
||||
|
||||
@@ -1,78 +1,213 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { findExecutableSync, quoteWindowsArgument, quoteWindowsCommand } from "./executable.js";
|
||||
type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void;
|
||||
|
||||
type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutableSync>[1]>;
|
||||
async function loadExecutableModule(params?: {
|
||||
execFileImpl?: (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: unknown,
|
||||
callback: ExecFileCallback,
|
||||
) => void;
|
||||
}) {
|
||||
vi.resetModules();
|
||||
|
||||
function createFindExecutableDependencies(): FindExecutableDependencies {
|
||||
const execFileMock = vi.fn(
|
||||
params?.execFileImpl ??
|
||||
((_command: string, _args: string[], _options: unknown, callback: ExecFileCallback) => {
|
||||
callback(new Error("execFile not mocked"), "", "");
|
||||
}),
|
||||
);
|
||||
Object.assign(execFileMock, {
|
||||
[promisify.custom]: (command: string, args: string[], options: unknown) =>
|
||||
new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
||||
execFileMock(
|
||||
command,
|
||||
args,
|
||||
options,
|
||||
(error: Error | null, stdout: string, stderr: string) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
},
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
vi.doMock("node:child_process", () => ({
|
||||
execFile: execFileMock,
|
||||
}));
|
||||
const module = await import("./executable.js");
|
||||
return {
|
||||
execFileSync: vi.fn(),
|
||||
existsSync: vi.fn(),
|
||||
platform: vi.fn(() => "darwin"),
|
||||
...module,
|
||||
execFileMock,
|
||||
};
|
||||
}
|
||||
|
||||
let findExecutableDependencies: FindExecutableDependencies;
|
||||
describe("findExecutable", () => {
|
||||
const originalPlatform = process.platform;
|
||||
const missingBinaryName = "nonexistent-binary-xyz-12345";
|
||||
|
||||
beforeEach(() => {
|
||||
findExecutableDependencies = createFindExecutableDependencies();
|
||||
});
|
||||
function setPlatform(value: string) {
|
||||
Object.defineProperty(process, "platform", { value, writable: true });
|
||||
}
|
||||
|
||||
describe("findExecutableSync", () => {
|
||||
test("on Windows, resolves executables using where.exe with inherited PATH", () => {
|
||||
findExecutableDependencies.platform = vi.fn(() => "win32");
|
||||
findExecutableDependencies.execFileSync.mockReturnValue(
|
||||
"C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n",
|
||||
);
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
expect(findExecutableSync("claude", findExecutableDependencies)).toBe(
|
||||
test("on Windows, resolves executables using where.exe with inherited PATH", async () => {
|
||||
setPlatform("win32");
|
||||
const { execFileMock, findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "C:\\Users\\boudr\\.local\\bin\\claude.exe\r\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable("claude")).resolves.toBe(
|
||||
"C:\\Users\\boudr\\.local\\bin\\claude.exe",
|
||||
);
|
||||
expect(findExecutableDependencies.execFileSync).toHaveBeenCalledOnce();
|
||||
const call = findExecutableDependencies.execFileSync.mock.calls[0];
|
||||
expect(execFileMock).toHaveBeenCalledOnce();
|
||||
const call = execFileMock.mock.calls[0];
|
||||
expect(call?.[0]).toBe("where.exe");
|
||||
expect(call?.[1]).toEqual(["claude"]);
|
||||
expect(call?.[2]?.encoding).toBe("utf8");
|
||||
expect(call?.[2]?.windowsHide).toBe(true);
|
||||
});
|
||||
|
||||
test("on Windows, preserves the first where.exe match", () => {
|
||||
findExecutableDependencies.platform = vi.fn(() => "win32");
|
||||
findExecutableDependencies.execFileSync.mockReturnValue(
|
||||
"C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n",
|
||||
);
|
||||
|
||||
expect(findExecutableSync("codex", findExecutableDependencies)).toBe(
|
||||
"C:\\nvm4w\\nodejs\\codex",
|
||||
);
|
||||
});
|
||||
|
||||
test("on Unix, uses the last line from which output", () => {
|
||||
findExecutableDependencies.execFileSync.mockReturnValue("/usr/local/bin/codex\n");
|
||||
|
||||
expect(findExecutableSync("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex");
|
||||
expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith("which", ["codex"], {
|
||||
expect(call?.[2]).toMatchObject({
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("warns and returns null when the final which line is not an absolute path", () => {
|
||||
findExecutableDependencies.execFileSync.mockReturnValue("codex\n");
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
test("on Windows, prefers an executable match from where.exe output", async () => {
|
||||
setPlatform("win32");
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "C:\\nvm4w\\nodejs\\codex\r\nC:\\nvm4w\\nodejs\\codex.cmd\r\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
expect(findExecutableSync("codex", findExecutableDependencies)).toBeNull();
|
||||
expect(warnSpy).toHaveBeenCalledOnce();
|
||||
|
||||
warnSpy.mockRestore();
|
||||
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex.cmd");
|
||||
});
|
||||
|
||||
test("returns direct paths when they exist", () => {
|
||||
findExecutableDependencies.existsSync.mockReturnValue(true);
|
||||
test("on Windows, prefers .exe over .cmd, .ps1, and extensionless candidates", async () => {
|
||||
setPlatform("win32");
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(
|
||||
null,
|
||||
[
|
||||
"C:\\nvm4w\\nodejs\\codex",
|
||||
"C:\\nvm4w\\nodejs\\codex.ps1",
|
||||
"C:\\nvm4w\\nodejs\\codex.cmd",
|
||||
"C:\\nvm4w\\nodejs\\codex.exe",
|
||||
].join("\r\n"),
|
||||
"",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
expect(findExecutableSync("/usr/local/bin/codex", findExecutableDependencies)).toBe(
|
||||
"/usr/local/bin/codex",
|
||||
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex.exe");
|
||||
});
|
||||
|
||||
test("on Windows, returns null when where.exe output is empty", async () => {
|
||||
setPlatform("win32");
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "\r\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable(missingBinaryName)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
test("on Windows, falls back to the first extensionless candidate when needed", async () => {
|
||||
setPlatform("win32");
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "C:\\nvm4w\\nodejs\\codex\r\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable("codex")).resolves.toBe("C:\\nvm4w\\nodejs\\codex");
|
||||
});
|
||||
|
||||
test("on Unix, uses the last line from which output", async () => {
|
||||
const { execFileMock, findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "/usr/local/bin/codex\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable("codex")).resolves.toBe("/usr/local/bin/codex");
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
process.platform === "win32" ? "where.exe" : "which",
|
||||
["codex"],
|
||||
process.platform === "win32" ? { encoding: "utf8", windowsHide: true } : { encoding: "utf8" },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith("/usr/local/bin/codex");
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"warns and returns null when the final which line is not an absolute path",
|
||||
async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(null, "codex\n", "");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable("codex")).resolves.toBeNull();
|
||||
expect(warnSpy).toHaveBeenCalledOnce();
|
||||
|
||||
warnSpy.mockRestore();
|
||||
},
|
||||
);
|
||||
|
||||
test("returns null when which lookup fails", async () => {
|
||||
const { findExecutable } = await loadExecutableModule({
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
callback(new Error("which failed"), "", "");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(findExecutable(missingBinaryName)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("executableExists", () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
function setPlatform(value: string) {
|
||||
Object.defineProperty(process, "platform", { value, writable: true });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
test("returns the path when it already exists", async () => {
|
||||
const { executableExists } = await loadExecutableModule();
|
||||
const exists = vi.fn((candidate: string) => candidate === "/usr/local/bin/codex");
|
||||
|
||||
expect(executableExists("/usr/local/bin/codex", exists)).toBe("/usr/local/bin/codex");
|
||||
});
|
||||
|
||||
test("on Windows, falls back to .exe, .cmd, then .ps1 for extensionless paths", async () => {
|
||||
setPlatform("win32");
|
||||
const { executableExists } = await loadExecutableModule();
|
||||
const exists = vi.fn((candidate: string) => candidate === "C:\\tools\\codex.cmd");
|
||||
|
||||
expect(executableExists("C:\\tools\\codex", exists)).toBe("C:\\tools\\codex.cmd");
|
||||
});
|
||||
|
||||
test("returns null when no matching path exists", async () => {
|
||||
const { executableExists } = await loadExecutableModule();
|
||||
const exists = vi.fn(() => false);
|
||||
|
||||
expect(executableExists("/missing/codex", exists)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,27 +222,71 @@ describe("quoteWindowsCommand", () => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
test("quotes a Windows path with spaces", () => {
|
||||
test("quotes a Windows path with spaces", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand("C:\\Program Files\\Anthropic\\claude.exe")).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||
);
|
||||
});
|
||||
|
||||
test("does not double-quote an already-quoted path", () => {
|
||||
test("does not double-quote an already-quoted path", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand('"C:\\Program Files\\Anthropic\\claude.exe"')).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\claude.exe"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the command unchanged when there are no spaces", () => {
|
||||
test("returns the command unchanged when there are no spaces", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand("C:\\nvm4w\\nodejs\\codex")).toBe("C:\\nvm4w\\nodejs\\codex");
|
||||
});
|
||||
|
||||
test("returns the command unchanged on non-Windows platforms", () => {
|
||||
test("escapes ampersands", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand("feature&bugfix")).toBe("feature^&bugfix");
|
||||
});
|
||||
|
||||
test("escapes pipes", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand("feature|bugfix")).toBe("feature^|bugfix");
|
||||
});
|
||||
|
||||
test("doubles percent signs", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand("100%")).toBe("100%%");
|
||||
});
|
||||
|
||||
test("escapes carets", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand("feature^bugfix")).toBe("feature^^bugfix");
|
||||
});
|
||||
|
||||
test("escapes multiple metacharacters", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand("build&(test|deploy)!<output>")).toBe(
|
||||
"build^&^(test^|deploy^)^!^<output^>",
|
||||
);
|
||||
});
|
||||
|
||||
test("quotes commands with spaces after escaping metacharacters", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand("C:\\Program Files\\My Tool&Stuff\\run 100%.cmd")).toBe(
|
||||
'"C:\\Program Files\\My Tool^&Stuff\\run 100%%.cmd"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the command unchanged on non-Windows platforms", async () => {
|
||||
setPlatform("darwin");
|
||||
const { quoteWindowsCommand } = await loadExecutableModule();
|
||||
expect(quoteWindowsCommand("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
||||
});
|
||||
});
|
||||
@@ -123,27 +302,31 @@ describe("quoteWindowsArgument", () => {
|
||||
setPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
test("quotes a Windows argument with spaces", () => {
|
||||
test("quotes a Windows argument with spaces", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsArgument } = await loadExecutableModule();
|
||||
expect(quoteWindowsArgument("C:\\Program Files\\Anthropic\\cli.js")).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\cli.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test("does not double-quote an already-quoted argument", () => {
|
||||
test("does not double-quote an already-quoted argument", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsArgument } = await loadExecutableModule();
|
||||
expect(quoteWindowsArgument('"C:\\Program Files\\Anthropic\\cli.js"')).toBe(
|
||||
'"C:\\Program Files\\Anthropic\\cli.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the argument unchanged when there are no spaces", () => {
|
||||
test("returns the argument unchanged when there are no spaces", async () => {
|
||||
setPlatform("win32");
|
||||
const { quoteWindowsArgument } = await loadExecutableModule();
|
||||
expect(quoteWindowsArgument("--version")).toBe("--version");
|
||||
});
|
||||
|
||||
test("returns the argument unchanged on non-Windows platforms", () => {
|
||||
test("returns the argument unchanged on non-Windows platforms", async () => {
|
||||
setPlatform("darwin");
|
||||
const { quoteWindowsArgument } = await loadExecutableModule();
|
||||
expect(quoteWindowsArgument("/usr/local/bin/claude code")).toBe("/usr/local/bin/claude code");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { platform } from "node:os";
|
||||
import path from "node:path";
|
||||
import path, { extname } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface FindExecutableDependencies {
|
||||
execFileSync: typeof execFileSync;
|
||||
existsSync: typeof existsSync;
|
||||
platform: typeof platform;
|
||||
function pickBestWindowsCandidate(lines: string[]): string | null {
|
||||
const candidates = lines.filter((line) => line.length > 0);
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const extPriority = [".exe", ".cmd", ".ps1"];
|
||||
for (const ext of extPriority) {
|
||||
const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext));
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
function resolveExecutableFromWhichOutput(
|
||||
@@ -44,58 +50,18 @@ function resolveExecutableFromWhichOutput(
|
||||
* enriches it at startup via inheritLoginShellEnv(); on Windows, Electron
|
||||
* inherits the full user environment from Explorer.
|
||||
*/
|
||||
export function findExecutableSync(
|
||||
name: string,
|
||||
dependencies?: FindExecutableDependencies,
|
||||
export function executableExists(
|
||||
executablePath: string,
|
||||
exists: typeof existsSync = existsSync,
|
||||
): string | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deps: FindExecutableDependencies = {
|
||||
execFileSync,
|
||||
existsSync,
|
||||
platform,
|
||||
...dependencies,
|
||||
};
|
||||
|
||||
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
||||
return deps.existsSync(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
if (deps.platform() === "win32") {
|
||||
try {
|
||||
const out = deps
|
||||
.execFileSync("where.exe", [trimmed], {
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
})
|
||||
.trim();
|
||||
return (
|
||||
out
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0) ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
if (exists(executablePath)) return executablePath;
|
||||
if (process.platform === "win32" && !extname(executablePath)) {
|
||||
for (const ext of [".exe", ".cmd", ".ps1"]) {
|
||||
const candidate = executablePath + ext;
|
||||
if (exists(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return resolveExecutableFromWhichOutput(
|
||||
trimmed,
|
||||
deps.execFileSync("which", [trimmed], { encoding: "utf8" }).trim(),
|
||||
"which",
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isCommandAvailableSync(command: string): boolean {
|
||||
return findExecutableSync(command) !== null;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findExecutable(name: string): Promise<string | null> {
|
||||
@@ -105,21 +71,22 @@ export async function findExecutable(name: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
||||
return existsSync(trimmed) ? trimmed : null;
|
||||
return executableExists(trimmed);
|
||||
}
|
||||
|
||||
if (platform() === "win32") {
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("where.exe", [trimmed], {
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
});
|
||||
return (
|
||||
stdout
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0) ?? null
|
||||
pickBestWindowsCandidate(
|
||||
stdout
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim()),
|
||||
) ?? null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
@@ -138,6 +105,20 @@ export async function isCommandAvailable(command: string): Promise<boolean> {
|
||||
return (await findExecutable(command)) !== null;
|
||||
}
|
||||
|
||||
function escapeWindowsCmdValue(value: string): string {
|
||||
if (process.platform !== "win32") return value;
|
||||
|
||||
const isQuoted = value.startsWith('"') && value.endsWith('"');
|
||||
const unquoted = isQuoted ? value.slice(1, -1) : value;
|
||||
const escaped = unquoted.replace(/%/g, "%%").replace(/([&|^<>()!])/g, "^$1");
|
||||
|
||||
if (isQuoted || escaped.includes(" ")) {
|
||||
return `"${escaped}"`;
|
||||
}
|
||||
|
||||
return escaped;
|
||||
}
|
||||
|
||||
/**
|
||||
* When spawning with `shell: true` on Windows, the command is passed to
|
||||
* `cmd.exe /d /s /c "command args"`. The `/s` strips outer quotes, so a
|
||||
@@ -145,10 +126,7 @@ export async function isCommandAvailable(command: string): Promise<boolean> {
|
||||
* space. Wrapping it in quotes produces the correct `"C:\Program Files\..." args`.
|
||||
*/
|
||||
export function quoteWindowsCommand(command: string): string {
|
||||
if (process.platform !== "win32") return command;
|
||||
if (!command.includes(" ")) return command;
|
||||
if (command.startsWith('"') && command.endsWith('"')) return command;
|
||||
return `"${command}"`;
|
||||
return escapeWindowsCmdValue(command);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,8 +135,5 @@ export function quoteWindowsCommand(command: string): string {
|
||||
* child process sees it.
|
||||
*/
|
||||
export function quoteWindowsArgument(argument: string): string {
|
||||
if (process.platform !== "win32") return argument;
|
||||
if (!argument.includes(" ")) return argument;
|
||||
if (argument.startsWith('"') && argument.endsWith('"')) return argument;
|
||||
return `"${argument}"`;
|
||||
return escapeWindowsCmdValue(argument);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface FakeSpawnBehavior {
|
||||
delayMs?: number;
|
||||
emitError?: Error;
|
||||
exitCode?: number | null;
|
||||
stderrData?: Buffer | string;
|
||||
stdoutData?: Buffer | string;
|
||||
}
|
||||
|
||||
interface FakeSpawnController {
|
||||
activeCount: number;
|
||||
nextPid: number;
|
||||
peakActiveCount: number;
|
||||
processes: FakeChildProcess[];
|
||||
queue: FakeSpawnBehavior[];
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const fakeSpawnController = vi.hoisted<FakeSpawnController>(() => ({
|
||||
activeCount: 0,
|
||||
nextPid: 1000,
|
||||
peakActiveCount: 0,
|
||||
processes: [],
|
||||
queue: [],
|
||||
reset() {
|
||||
for (const process of this.processes) {
|
||||
process.dispose();
|
||||
}
|
||||
|
||||
this.activeCount = 0;
|
||||
this.nextPid = 1000;
|
||||
this.peakActiveCount = 0;
|
||||
this.processes = [];
|
||||
this.queue = [];
|
||||
},
|
||||
}));
|
||||
|
||||
class FakeChildProcess extends EventEmitter {
|
||||
public readonly pid: number;
|
||||
public readonly stderr = new EventEmitter();
|
||||
public readonly stdout = new EventEmitter();
|
||||
public killed = false;
|
||||
public killSignals: NodeJS.Signals[] = [];
|
||||
|
||||
private readonly behavior: FakeSpawnBehavior;
|
||||
private readonly timers: NodeJS.Timeout[] = [];
|
||||
private closed = false;
|
||||
|
||||
public constructor(behavior: FakeSpawnBehavior) {
|
||||
super();
|
||||
this.behavior = behavior;
|
||||
this.pid = fakeSpawnController.nextPid;
|
||||
fakeSpawnController.nextPid += 1;
|
||||
|
||||
fakeSpawnController.activeCount += 1;
|
||||
fakeSpawnController.peakActiveCount = Math.max(
|
||||
fakeSpawnController.peakActiveCount,
|
||||
fakeSpawnController.activeCount,
|
||||
);
|
||||
|
||||
this.scheduleLifecycle();
|
||||
}
|
||||
|
||||
public kill(signal: NodeJS.Signals = "SIGTERM"): boolean {
|
||||
if (this.closed) return false;
|
||||
|
||||
this.killed = true;
|
||||
this.killSignals.push(signal);
|
||||
this.clearTimers();
|
||||
this.schedule(() => {
|
||||
this.finishClose({
|
||||
exitCode: null,
|
||||
signal,
|
||||
});
|
||||
}, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.clearTimers();
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
private scheduleLifecycle(): void {
|
||||
const stdoutData = this.behavior.stdoutData;
|
||||
if (stdoutData !== undefined) {
|
||||
this.schedule(() => {
|
||||
if (this.closed) return;
|
||||
this.stdout.emit("data", stdoutData);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const stderrData = this.behavior.stderrData;
|
||||
if (stderrData !== undefined) {
|
||||
this.schedule(() => {
|
||||
if (this.closed) return;
|
||||
this.stderr.emit("data", stderrData);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (this.behavior.emitError) {
|
||||
this.schedule(() => {
|
||||
if (this.closed) return;
|
||||
this.finishError(this.behavior.emitError);
|
||||
}, this.behavior.delayMs ?? 0);
|
||||
return;
|
||||
}
|
||||
|
||||
this.schedule(() => {
|
||||
this.finishClose({
|
||||
exitCode: this.behavior.exitCode ?? 0,
|
||||
signal: null,
|
||||
});
|
||||
}, this.behavior.delayMs ?? 0);
|
||||
}
|
||||
|
||||
private finishClose({
|
||||
exitCode,
|
||||
signal,
|
||||
}: {
|
||||
exitCode: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
}): void {
|
||||
if (this.closed) return;
|
||||
|
||||
this.closed = true;
|
||||
this.clearTimers();
|
||||
fakeSpawnController.activeCount -= 1;
|
||||
this.emit("close", exitCode, signal);
|
||||
}
|
||||
|
||||
private finishError(error: Error): void {
|
||||
if (this.closed) return;
|
||||
|
||||
this.closed = true;
|
||||
this.clearTimers();
|
||||
fakeSpawnController.activeCount -= 1;
|
||||
this.emit("error", error);
|
||||
}
|
||||
|
||||
private schedule(callback: () => void, delayMs: number): void {
|
||||
const timer = setTimeout(callback, delayMs);
|
||||
this.timers.push(timer);
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
for (const timer of this.timers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.timers.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
|
||||
return {
|
||||
...actual,
|
||||
spawn: vi.fn(() => {
|
||||
const behavior = fakeSpawnController.queue.shift() ?? {};
|
||||
const child = new FakeChildProcess(behavior);
|
||||
fakeSpawnController.processes.push(child);
|
||||
return child as unknown as ReturnType<typeof actual.spawn>;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
function enqueueSpawnBehaviors(...behaviors: FakeSpawnBehavior[]): void {
|
||||
fakeSpawnController.queue.push(...behaviors);
|
||||
}
|
||||
|
||||
async function loadRunGitCommand(concurrency: number) {
|
||||
vi.resetModules();
|
||||
vi.stubEnv("PASEO_GIT_CONCURRENCY", String(concurrency));
|
||||
return import("./run-git-command.js");
|
||||
}
|
||||
|
||||
describe("runGitCommand", () => {
|
||||
beforeEach(() => {
|
||||
fakeSpawnController.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fakeSpawnController.reset();
|
||||
vi.resetModules();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("throttles concurrent git commands to the configured limit", async () => {
|
||||
const { runGitCommand } = await loadRunGitCommand(2);
|
||||
|
||||
enqueueSpawnBehaviors(...Array.from({ length: 16 }, () => ({ delayMs: 25 })));
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: 16 }, () =>
|
||||
runGitCommand(["rev-parse", "--show-toplevel"], {
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(fakeSpawnController.peakActiveCount).toBe(2);
|
||||
expect(fakeSpawnController.activeCount).toBe(0);
|
||||
});
|
||||
|
||||
it("kills timed out processes and releases the limiter slot", async () => {
|
||||
const { runGitCommand } = await loadRunGitCommand(1);
|
||||
|
||||
enqueueSpawnBehaviors({ delayMs: 5_000 }, { delayMs: 0 });
|
||||
|
||||
await expect(
|
||||
runGitCommand(["status"], {
|
||||
cwd: process.cwd(),
|
||||
timeout: 100,
|
||||
}),
|
||||
).rejects.toThrow("Git command timed out after 100ms: git status");
|
||||
|
||||
expect(fakeSpawnController.processes[0]?.killed).toBe(true);
|
||||
expect(fakeSpawnController.processes[0]?.killSignals).toEqual(["SIGKILL"]);
|
||||
|
||||
await expect(
|
||||
runGitCommand(["rev-parse", "--show-toplevel"], {
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
exitCode: 0,
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves truncated stdout, caps output, and kills the child process", async () => {
|
||||
const { runGitCommand } = await loadRunGitCommand(1);
|
||||
|
||||
enqueueSpawnBehaviors({
|
||||
delayMs: 5_000,
|
||||
stdoutData: "x".repeat(1_000),
|
||||
});
|
||||
|
||||
const result = await runGitCommand(["log", "--all", "--oneline"], {
|
||||
cwd: process.cwd(),
|
||||
maxOutputBytes: 100,
|
||||
});
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.stdout.length).toBeLessThanOrEqual(100);
|
||||
expect(result.stderr).toBe("");
|
||||
expect(fakeSpawnController.processes[0]?.killed).toBe(true);
|
||||
expect(fakeSpawnController.processes[0]?.killSignals).toEqual(["SIGKILL"]);
|
||||
});
|
||||
|
||||
it("rejects process errors and frees the limiter for the next command", async () => {
|
||||
const { runGitCommand } = await loadRunGitCommand(1);
|
||||
|
||||
enqueueSpawnBehaviors(
|
||||
{ emitError: new Error("spawn exploded") },
|
||||
{ delayMs: 0, stdoutData: "ok" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
runGitCommand(["status"], {
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).rejects.toThrow("spawn exploded");
|
||||
|
||||
await expect(
|
||||
runGitCommand(["status"], {
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
exitCode: 0,
|
||||
stdout: "ok",
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-zero exit codes that are not accepted and frees the slot", async () => {
|
||||
const { runGitCommand } = await loadRunGitCommand(1);
|
||||
|
||||
enqueueSpawnBehaviors(
|
||||
{
|
||||
delayMs: 0,
|
||||
exitCode: 1,
|
||||
stderrData: "fatal: nope\n",
|
||||
},
|
||||
{ delayMs: 0, stdoutData: "ok" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
runGitCommand(["status"], {
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).rejects.toThrow(/Git command failed: git status \(exit code: 1, signal: none\)/);
|
||||
|
||||
await expect(
|
||||
runGitCommand(["status"], {
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
exitCode: 0,
|
||||
stdout: "ok",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves accepted non-zero exit codes", async () => {
|
||||
const { runGitCommand } = await loadRunGitCommand(1);
|
||||
|
||||
enqueueSpawnBehaviors({
|
||||
delayMs: 0,
|
||||
exitCode: 1,
|
||||
stderrData: "fatal: but allowed\n",
|
||||
});
|
||||
|
||||
await expect(
|
||||
runGitCommand(["status"], {
|
||||
acceptExitCodes: [0, 1],
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("releases concurrency slots after timeouts so later commands can run", async () => {
|
||||
const { runGitCommand } = await loadRunGitCommand(2);
|
||||
|
||||
enqueueSpawnBehaviors({ delayMs: 5_000 }, { delayMs: 5_000 });
|
||||
|
||||
const firstBatch = await Promise.allSettled([
|
||||
runGitCommand(["status"], { cwd: process.cwd(), timeout: 100 }),
|
||||
runGitCommand(["rev-parse", "--show-toplevel"], {
|
||||
cwd: process.cwd(),
|
||||
timeout: 100,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(firstBatch[0].status).toBe("rejected");
|
||||
expect(firstBatch[1].status).toBe("rejected");
|
||||
expect(fakeSpawnController.processes[0]?.killSignals).toEqual(["SIGKILL"]);
|
||||
expect(fakeSpawnController.processes[1]?.killSignals).toEqual(["SIGKILL"]);
|
||||
|
||||
enqueueSpawnBehaviors(
|
||||
{ delayMs: 0, stdoutData: "third" },
|
||||
{ delayMs: 0, stdoutData: "fourth" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
Promise.all([
|
||||
runGitCommand(["status"], { cwd: process.cwd() }),
|
||||
runGitCommand(["status"], { cwd: process.cwd() }),
|
||||
]),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({ exitCode: 0, stdout: "third" }),
|
||||
expect.objectContaining({ exitCode: 0, stdout: "fourth" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("releases concurrency slots after truncation so later commands can run", async () => {
|
||||
const { runGitCommand } = await loadRunGitCommand(2);
|
||||
|
||||
enqueueSpawnBehaviors(
|
||||
{ delayMs: 5_000, stdoutData: "a".repeat(1_000) },
|
||||
{ delayMs: 5_000, stdoutData: "b".repeat(1_000) },
|
||||
);
|
||||
|
||||
const firstBatch = await Promise.all([
|
||||
runGitCommand(["log", "--all", "--oneline"], {
|
||||
cwd: process.cwd(),
|
||||
maxOutputBytes: 100,
|
||||
}),
|
||||
runGitCommand(["log", "--all", "--oneline"], {
|
||||
cwd: process.cwd(),
|
||||
maxOutputBytes: 100,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(firstBatch).toEqual([
|
||||
expect.objectContaining({ truncated: true }),
|
||||
expect.objectContaining({ truncated: true }),
|
||||
]);
|
||||
expect(fakeSpawnController.processes[0]?.killSignals).toEqual(["SIGKILL"]);
|
||||
expect(fakeSpawnController.processes[1]?.killSignals).toEqual(["SIGKILL"]);
|
||||
|
||||
enqueueSpawnBehaviors(
|
||||
{ delayMs: 0, stdoutData: "third" },
|
||||
{ delayMs: 0, stdoutData: "fourth" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
Promise.all([
|
||||
runGitCommand(["status"], { cwd: process.cwd() }),
|
||||
runGitCommand(["status"], { cwd: process.cwd() }),
|
||||
]),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({ exitCode: 0, stdout: "third", truncated: false }),
|
||||
expect.objectContaining({ exitCode: 0, stdout: "fourth", truncated: false }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import pLimit from "p-limit";
|
||||
import { spawnProcess } from "./spawn.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_MAX_OUTPUT_BYTES = 20 * 1024 * 1024; // 20MB
|
||||
const DEFAULT_STDERR_LIMIT = 2048;
|
||||
|
||||
const gitConcurrency = parseInt(process.env.PASEO_GIT_CONCURRENCY ?? "8", 10) || 8;
|
||||
const gitLimit = pLimit(gitConcurrency);
|
||||
|
||||
export interface GitCommandOptions {
|
||||
cwd: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeout?: number;
|
||||
maxOutputBytes?: number;
|
||||
acceptExitCodes?: number[];
|
||||
}
|
||||
|
||||
export interface GitCommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
truncated: boolean;
|
||||
exitCode: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
}
|
||||
|
||||
export function runGitCommand(
|
||||
args: string[],
|
||||
options: GitCommandOptions,
|
||||
): Promise<GitCommandResult> {
|
||||
return gitLimit(
|
||||
() =>
|
||||
new Promise<GitCommandResult>((resolve, reject) => {
|
||||
const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
||||
const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
||||
const acceptExitCodes = options.acceptExitCodes ?? [0];
|
||||
const command = formatGitCommand(args);
|
||||
|
||||
const child = spawnProcess("git", args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
let truncated = false;
|
||||
let stdoutBytes = 0;
|
||||
let stderrBytes = 0;
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
|
||||
const settle = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
callback();
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const error = new Error(`Git command timed out after ${timeout}ms: ${command}`);
|
||||
child.kill("SIGKILL");
|
||||
settle(() => reject(error));
|
||||
}, timeout);
|
||||
|
||||
child.stdout!.on("data", (chunk: Buffer | string) => {
|
||||
if (settled || truncated) return;
|
||||
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
const remainingBytes = maxOutputBytes - stdoutBytes;
|
||||
|
||||
if (remainingBytes <= 0) {
|
||||
truncated = true;
|
||||
child.kill("SIGKILL");
|
||||
return;
|
||||
}
|
||||
|
||||
if (buffer.length > remainingBytes) {
|
||||
stdoutChunks.push(buffer.subarray(0, remainingBytes));
|
||||
stdoutBytes += remainingBytes;
|
||||
truncated = true;
|
||||
child.kill("SIGKILL");
|
||||
return;
|
||||
}
|
||||
|
||||
stdoutChunks.push(buffer);
|
||||
stdoutBytes += buffer.length;
|
||||
});
|
||||
|
||||
child.stderr!.on("data", (chunk: Buffer | string) => {
|
||||
if (settled || stderrBytes >= DEFAULT_STDERR_LIMIT) return;
|
||||
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
const remainingBytes = DEFAULT_STDERR_LIMIT - stderrBytes;
|
||||
|
||||
if (buffer.length > remainingBytes) {
|
||||
stderrChunks.push(buffer.subarray(0, remainingBytes));
|
||||
stderrBytes += remainingBytes;
|
||||
return;
|
||||
}
|
||||
|
||||
stderrChunks.push(buffer);
|
||||
stderrBytes += buffer.length;
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
settle(() => reject(error));
|
||||
});
|
||||
|
||||
child.on("close", (exitCode, signal) => {
|
||||
const result: GitCommandResult = {
|
||||
stdout: Buffer.concat(stdoutChunks).toString("utf8"),
|
||||
stderr: Buffer.concat(stderrChunks).toString("utf8"),
|
||||
truncated,
|
||||
exitCode,
|
||||
signal,
|
||||
};
|
||||
|
||||
if (!truncated && !acceptExitCodes.includes(exitCode ?? -1)) {
|
||||
const stderrPreview = result.stderr.trim() || "(no stderr)";
|
||||
const truncationNote = result.truncated ? " (stdout truncated)" : "";
|
||||
|
||||
settle(() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Git command failed: ${command}${truncationNote} (exit code: ${String(exitCode)}, signal: ${signal ?? "none"})\n${stderrPreview}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
settle(() => resolve(result));
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function formatGitCommand(args: string[]): string {
|
||||
return ["git", ...args].join(" ");
|
||||
}
|
||||
@@ -2,8 +2,7 @@ import { slugify } from "./worktree.js";
|
||||
|
||||
export function buildScriptHostname(branchName: string | null, scriptName: string): string {
|
||||
const serviceHostnameLabel = slugify(scriptName);
|
||||
const isDefaultBranch =
|
||||
branchName === null || branchName === "main" || branchName === "master";
|
||||
const isDefaultBranch = branchName === null || branchName === "main" || branchName === "master";
|
||||
|
||||
if (isDefaultBranch) {
|
||||
return `${serviceHostnameLabel}.localhost`;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { execCommand } from "./spawn.js";
|
||||
|
||||
describe("execCommand", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const tempDir of tempDirs) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
test("returns stdout and stderr for a successful command", async () => {
|
||||
const result = await execCommand("echo", ["hello"]);
|
||||
|
||||
expect(result.stdout.trim()).toBe("hello");
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
|
||||
test("rejects when the command times out", async () => {
|
||||
const command =
|
||||
process.platform === "win32"
|
||||
? {
|
||||
command: process.execPath,
|
||||
args: ["-e", "setTimeout(() => {}, 10_000)"],
|
||||
}
|
||||
: { command: "sleep", args: ["10"] };
|
||||
|
||||
await expect(execCommand(command.command, command.args, { timeout: 100 })).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("runs the command in the provided cwd", async () => {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "spawn-test-")));
|
||||
tempDirs.push(cwd);
|
||||
|
||||
const command =
|
||||
process.platform === "win32"
|
||||
? {
|
||||
command: process.execPath,
|
||||
args: ["-e", "console.log(process.cwd())"],
|
||||
}
|
||||
: { command: "pwd", args: [] };
|
||||
|
||||
const result = await execCommand(command.command, command.args, { cwd });
|
||||
|
||||
expect(result.stdout.trim()).toBe(cwd);
|
||||
expect(result.stderr).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,23 @@
|
||||
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||
import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { quoteWindowsArgument, quoteWindowsCommand } from "./executable.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface ExecCommandOptions {
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
encoding?: BufferEncoding;
|
||||
timeout?: number;
|
||||
maxBuffer?: number;
|
||||
}
|
||||
|
||||
interface ExecCommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform-aware spawn that centralizes Windows shell and quoting concerns.
|
||||
*
|
||||
* On Windows:
|
||||
* - Enables `shell: true` (routes through cmd.exe) unless the caller explicitly sets `shell`
|
||||
* - Quotes the command and arguments so paths with spaces survive cmd.exe parsing
|
||||
* - Always sets `windowsHide: true` to prevent console window flashes
|
||||
*
|
||||
* On other platforms the call is passed through to `spawn` unchanged (with `windowsHide: true`).
|
||||
*/
|
||||
export function spawnProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
@@ -17,8 +25,8 @@ export function spawnProcess(
|
||||
): ChildProcess {
|
||||
const isWindows = process.platform === "win32";
|
||||
|
||||
const resolvedCommand = isWindows ? quoteForCmd(command) : command;
|
||||
const resolvedArgs = isWindows ? args.map(quoteForCmd) : args;
|
||||
const resolvedCommand = isWindows ? quoteWindowsCommand(command) : command;
|
||||
const resolvedArgs = isWindows ? args.map(quoteWindowsArgument) : args;
|
||||
|
||||
return spawn(resolvedCommand, resolvedArgs, {
|
||||
...options,
|
||||
@@ -27,12 +35,38 @@ export function spawnProcess(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote a string for cmd.exe if it contains spaces and isn't already quoted.
|
||||
* No-op for strings without spaces or strings that are already double-quoted.
|
||||
*/
|
||||
function quoteForCmd(value: string): string {
|
||||
if (!value.includes(" ")) return value;
|
||||
if (value.startsWith('"') && value.endsWith('"')) return value;
|
||||
return `"${value}"`;
|
||||
export async function execCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: ExecCommandOptions,
|
||||
): Promise<ExecCommandResult> {
|
||||
const isWindows = process.platform === "win32";
|
||||
const resolvedCommand = isWindows ? quoteWindowsCommand(command) : command;
|
||||
const resolvedArgs = isWindows ? args.map(quoteWindowsArgument) : args;
|
||||
|
||||
return execFileAsync(resolvedCommand, resolvedArgs, {
|
||||
cwd: options?.cwd,
|
||||
env: options?.env,
|
||||
encoding: options?.encoding ?? "utf8",
|
||||
timeout: options?.timeout,
|
||||
maxBuffer: options?.maxBuffer,
|
||||
shell: isWindows,
|
||||
windowsHide: true,
|
||||
}) as Promise<ExecCommandResult>;
|
||||
}
|
||||
|
||||
export function platformShell(): { command: string; flag: string[] } {
|
||||
if (process.platform === "win32") {
|
||||
return { command: "cmd.exe", flag: ["/c"] };
|
||||
}
|
||||
|
||||
return { command: "/bin/sh", flag: ["-lc"] };
|
||||
}
|
||||
|
||||
export function platformBash(): { command: string; flag: string[] } {
|
||||
if (process.platform === "win32") {
|
||||
return { command: "cmd.exe", flag: ["/c"] };
|
||||
}
|
||||
|
||||
return { command: "/bin/bash", flag: ["-lc"] };
|
||||
}
|
||||
|
||||
@@ -19,7 +19,12 @@ describe("worktree shell selection", () => {
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset();
|
||||
execFileMock.mockImplementation(
|
||||
(_file: string, _args: string[], _options: unknown, callback?: (error: Error | null, stdout: string, stderr: string) => void) => {
|
||||
(
|
||||
_file: string,
|
||||
_args: string[],
|
||||
_options: unknown,
|
||||
callback?: (error: Error | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
callback?.(null, "", "");
|
||||
return {};
|
||||
},
|
||||
|
||||
@@ -15,12 +15,20 @@ import {
|
||||
} from "./worktree";
|
||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
existsSync,
|
||||
realpathSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
} from "fs";
|
||||
import { dirname, join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import net from "node:net";
|
||||
|
||||
describe("createWorktree", () => {
|
||||
describe.skipIf(process.platform === "win32")("createWorktree", () => {
|
||||
let tempDir: string;
|
||||
let repoDir: string;
|
||||
let paseoHome: string;
|
||||
@@ -32,13 +40,13 @@ describe("createWorktree", () => {
|
||||
paseoHome = join(tempDir, "paseo-home");
|
||||
|
||||
// Create a git repo with an initial commit
|
||||
execSync(`mkdir -p ${repoDir}`);
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
execSync("git init -b main", { cwd: repoDir });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir });
|
||||
execSync("git config user.name 'Test'", { cwd: repoDir });
|
||||
execSync("echo 'hello' > file.txt", { cwd: repoDir });
|
||||
execSync('git config user.email "test@test.com"', { cwd: repoDir });
|
||||
execSync('git config user.name "Test"', { cwd: repoDir });
|
||||
execSync('echo "hello" > file.txt', { cwd: repoDir });
|
||||
execSync("git add .", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir });
|
||||
execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: repoDir });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -70,13 +78,13 @@ describe("createWorktree", () => {
|
||||
const privateTempDir = realpathSync(varTempDir);
|
||||
const varRepoDir = join(varTempDir, "test-repo");
|
||||
const varPaseoHome = join(varTempDir, "paseo-home");
|
||||
execSync(`mkdir -p ${varRepoDir}`);
|
||||
mkdirSync(varRepoDir, { recursive: true });
|
||||
execSync("git init -b main", { cwd: varRepoDir });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: varRepoDir });
|
||||
execSync("git config user.name 'Test'", { cwd: varRepoDir });
|
||||
execSync("echo 'hello' > file.txt", { cwd: varRepoDir });
|
||||
execSync('git config user.email "test@test.com"', { cwd: varRepoDir });
|
||||
execSync('git config user.name "Test"', { cwd: varRepoDir });
|
||||
execSync('echo "hello" > file.txt', { cwd: varRepoDir });
|
||||
execSync("git add .", { cwd: varRepoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: varRepoDir });
|
||||
execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: varRepoDir });
|
||||
|
||||
const result = await createWorktree({
|
||||
branchName: "main",
|
||||
@@ -120,7 +128,7 @@ describe("createWorktree", () => {
|
||||
|
||||
it("treats non-git directories as non-worktrees without throwing", async () => {
|
||||
const nonGitDir = join(tempDir, "not-a-repo");
|
||||
execSync(`mkdir -p ${nonGitDir}`);
|
||||
mkdirSync(nonGitDir, { recursive: true });
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(nonGitDir, { paseoHome });
|
||||
|
||||
@@ -157,19 +165,19 @@ describe("createWorktree", () => {
|
||||
execSync("git push -u origin main", { cwd: repoDir });
|
||||
|
||||
execSync(`git clone ${remoteDir} ${remoteCloneDir}`);
|
||||
execSync("git config user.email 'test@test.com'", { cwd: remoteCloneDir });
|
||||
execSync("git config user.name 'Test'", { cwd: remoteCloneDir });
|
||||
execSync('git config user.email "test@test.com"', { cwd: remoteCloneDir });
|
||||
execSync('git config user.name "Test"', { cwd: remoteCloneDir });
|
||||
execSync("git checkout -B main origin/main", { cwd: remoteCloneDir });
|
||||
writeFileSync(join(remoteCloneDir, "file.txt"), "from-origin\n");
|
||||
execSync("git add file.txt", { cwd: remoteCloneDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'advance origin main'", {
|
||||
execSync('git -c commit.gpgsign=false commit -m "advance origin main"', {
|
||||
cwd: remoteCloneDir,
|
||||
});
|
||||
execSync("git push origin main", { cwd: remoteCloneDir });
|
||||
|
||||
writeFileSync(join(repoDir, "file.txt"), "from-local\n");
|
||||
execSync("git add file.txt", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'advance local main'", { cwd: repoDir });
|
||||
execSync('git -c commit.gpgsign=false commit -m "advance local main"', { cwd: repoDir });
|
||||
|
||||
execSync("git fetch origin", { cwd: repoDir });
|
||||
|
||||
@@ -188,7 +196,7 @@ describe("createWorktree", () => {
|
||||
it("falls back to local {branch} when origin/{branch} does not exist", async () => {
|
||||
writeFileSync(join(repoDir, "file.txt"), "from-local-only\n");
|
||||
execSync("git add file.txt", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'advance local main only'", { cwd: repoDir });
|
||||
execSync('git -c commit.gpgsign=false commit -m "advance local main only"', { cwd: repoDir });
|
||||
|
||||
const result = await createWorktree({
|
||||
branchName: "prefer-local-fallback-feature",
|
||||
@@ -280,7 +288,7 @@ describe("createWorktree", () => {
|
||||
},
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'", {
|
||||
execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add paseo.json"', {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
@@ -314,7 +322,7 @@ describe("createWorktree", () => {
|
||||
},
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'", {
|
||||
execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add paseo.json"', {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
@@ -338,7 +346,7 @@ describe("createWorktree", () => {
|
||||
},
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add streaming setup'", {
|
||||
execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add streaming setup"', {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
@@ -428,7 +436,7 @@ describe("createWorktree", () => {
|
||||
},
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
execSync("git add paseo.json && git -c commit.gpgsign=false commit -m 'add paseo.json'", {
|
||||
execSync('git add paseo.json && git -c commit.gpgsign=false commit -m "add paseo.json"', {
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
@@ -581,13 +589,13 @@ describe("paseo worktree manager", () => {
|
||||
repoDir = join(tempDir, "test-repo");
|
||||
paseoHome = join(tempDir, "paseo-home");
|
||||
|
||||
execSync(`mkdir -p ${repoDir}`);
|
||||
mkdirSync(repoDir, { recursive: true });
|
||||
execSync("git init -b main", { cwd: repoDir });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir });
|
||||
execSync("git config user.name 'Test'", { cwd: repoDir });
|
||||
execSync("echo 'hello' > file.txt", { cwd: repoDir });
|
||||
execSync('git config user.email "test@test.com"', { cwd: repoDir });
|
||||
execSync('git config user.name "Test"', { cwd: repoDir });
|
||||
execSync('echo "hello" > file.txt', { cwd: repoDir });
|
||||
execSync("git add .", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir });
|
||||
execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: repoDir });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -599,13 +607,13 @@ describe("paseo worktree manager", () => {
|
||||
const repoB = join(tempDir, "team-b", "test-repo");
|
||||
|
||||
for (const repo of [repoA, repoB]) {
|
||||
execSync(`mkdir -p ${repo}`);
|
||||
mkdirSync(repo, { recursive: true });
|
||||
execSync("git init -b main", { cwd: repo });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repo });
|
||||
execSync("git config user.name 'Test'", { cwd: repo });
|
||||
execSync("echo 'hello' > file.txt", { cwd: repo });
|
||||
execSync('git config user.email "test@test.com"', { cwd: repo });
|
||||
execSync('git config user.name "Test"', { cwd: repo });
|
||||
execSync('echo "hello" > file.txt', { cwd: repo });
|
||||
execSync("git add .", { cwd: repo });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repo });
|
||||
execSync('git -c commit.gpgsign=false commit -m "initial"', { cwd: repo });
|
||||
}
|
||||
|
||||
const fromRepoA = await createWorktree({
|
||||
@@ -671,7 +679,7 @@ describe("paseo worktree manager", () => {
|
||||
});
|
||||
|
||||
const nestedDir = join(created.worktreePath, "nested", "dir");
|
||||
execSync(`mkdir -p ${nestedDir}`);
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
|
||||
await deletePaseoWorktree({ cwd: repoDir, worktreePath: nestedDir, paseoHome });
|
||||
expect(existsSync(created.worktreePath)).toBe(false);
|
||||
@@ -694,7 +702,7 @@ describe("paseo worktree manager", () => {
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
execSync(
|
||||
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add teardown commands'",
|
||||
'git add paseo.json && git -c commit.gpgsign=false commit -m "add teardown commands"',
|
||||
{
|
||||
cwd: repoDir,
|
||||
},
|
||||
@@ -733,7 +741,7 @@ describe("paseo worktree manager", () => {
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
execSync(
|
||||
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add teardown port logging'",
|
||||
'git add paseo.json && git -c commit.gpgsign=false commit -m "add teardown port logging"',
|
||||
{ cwd: repoDir },
|
||||
);
|
||||
|
||||
@@ -762,7 +770,7 @@ describe("paseo worktree manager", () => {
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
execSync(
|
||||
"git add paseo.json && git -c commit.gpgsign=false commit -m 'add failing teardown commands'",
|
||||
'git add paseo.json && git -c commit.gpgsign=false commit -m "add failing teardown commands"',
|
||||
{ cwd: repoDir },
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { exec, execFile, spawn } from "child_process";
|
||||
import { execFile, spawn } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs";
|
||||
import { join, basename, dirname, resolve, sep } from "path";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
writePaseoWorktreeMetadata,
|
||||
writePaseoWorktreeRuntimeMetadata,
|
||||
} from "./worktree-metadata.js";
|
||||
import { runGitCommand } from "./run-git-command.js";
|
||||
import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||
import { ensureNodePtySpawnHelperExecutableForCurrentPlatform } from "../terminal/terminal.js";
|
||||
|
||||
@@ -34,7 +35,6 @@ interface PaseoConfig {
|
||||
>;
|
||||
}
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
@@ -255,7 +255,11 @@ export function getScriptConfigs(repoRoot: string): Map<string, ScriptConfig> {
|
||||
}
|
||||
: { command };
|
||||
|
||||
if (isServiceScript(scriptConfig) && typeof entry.port === "number" && Number.isFinite(entry.port)) {
|
||||
if (
|
||||
isServiceScript(scriptConfig) &&
|
||||
typeof entry.port === "number" &&
|
||||
Number.isFinite(entry.port)
|
||||
) {
|
||||
scriptConfig.port = entry.port;
|
||||
}
|
||||
|
||||
@@ -325,6 +329,7 @@ async function execSetupCommand(
|
||||
const { stdout, stderr } = await execFileAsync(shellInvocation.shell, shellInvocation.args, {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
...(process.platform === "win32" ? {} : { shell: "/bin/bash" }),
|
||||
});
|
||||
return {
|
||||
command,
|
||||
@@ -525,10 +530,13 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<
|
||||
} catch {
|
||||
// Fallback: best-effort resolve toplevel (will be the worktree root in typical cases)
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", {
|
||||
cwd: worktreePath,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const { stdout } = await runGitCommand(
|
||||
["rev-parse", "--path-format=absolute", "--show-toplevel"],
|
||||
{
|
||||
cwd: worktreePath,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
const topLevel = stdout.trim();
|
||||
if (topLevel) {
|
||||
return normalizePathForOwnership(topLevel);
|
||||
@@ -586,8 +594,9 @@ export async function runWorktreeSetupCommands(options: {
|
||||
if (result.exitCode !== 0) {
|
||||
if (options.cleanupOnFailure) {
|
||||
try {
|
||||
await execAsync(`git worktree remove "${options.worktreePath}" --force`, {
|
||||
await runGitCommand(["worktree", "remove", options.worktreePath, "--force"], {
|
||||
cwd: options.worktreePath,
|
||||
timeout: 120_000,
|
||||
});
|
||||
} catch {
|
||||
rmSync(options.worktreePath, { recursive: true, force: true });
|
||||
@@ -605,7 +614,7 @@ export async function runWorktreeSetupCommands(options: {
|
||||
|
||||
async function resolveBranchNameForWorktreePath(worktreePath: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git branch --show-current", {
|
||||
const { stdout } = await runGitCommand(["branch", "--show-current"], {
|
||||
cwd: worktreePath,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -708,10 +717,13 @@ export async function runWorktreeTeardownCommands(options: {
|
||||
* This is where refs, objects, etc. are stored.
|
||||
*/
|
||||
export async function getGitCommonDir(cwd: string): Promise<string> {
|
||||
const { stdout } = await execAsync("git rev-parse --path-format=absolute --git-common-dir", {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const { stdout } = await runGitCommand(
|
||||
["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||
{
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
const commonDir = stdout.trim();
|
||||
if (!commonDir) {
|
||||
throw new Error("Not in a git repository");
|
||||
@@ -941,7 +953,7 @@ export async function listPaseoWorktrees({
|
||||
paseoHome?: string;
|
||||
}): Promise<PaseoWorktreeInfo[]> {
|
||||
const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
|
||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
||||
const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
@@ -972,10 +984,13 @@ export async function resolvePaseoWorktreeRootForCwd(
|
||||
|
||||
let worktreeRoot: string | null = null;
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const { stdout } = await runGitCommand(
|
||||
["rev-parse", "--path-format=absolute", "--show-toplevel"],
|
||||
{
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
const trimmed = stdout.trim();
|
||||
worktreeRoot = trimmed.length > 0 ? trimmed : null;
|
||||
} catch {
|
||||
@@ -1038,8 +1053,9 @@ export async function deletePaseoWorktree({
|
||||
worktreePath: resolvedWorktree,
|
||||
});
|
||||
|
||||
await execAsync(`git worktree remove "${resolvedWorktree}" --force`, {
|
||||
await runGitCommand(["worktree", "remove", resolvedWorktree, "--force"], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
if (existsSync(resolvedWorktree)) {
|
||||
@@ -1075,11 +1091,11 @@ export async function createWorktree({
|
||||
// Resolve the base branch - prefer origin/{branch}, then fall back to local
|
||||
let resolvedBaseBranch = normalizedBaseBranch;
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify origin/${normalizedBaseBranch}`, { cwd });
|
||||
await runGitCommand(["rev-parse", "--verify", `origin/${normalizedBaseBranch}`], { cwd });
|
||||
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
|
||||
} catch {
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd });
|
||||
await runGitCommand(["rev-parse", "--verify", normalizedBaseBranch], { cwd });
|
||||
} catch {
|
||||
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
|
||||
}
|
||||
@@ -1094,7 +1110,9 @@ export async function createWorktree({
|
||||
// Check if branch already exists
|
||||
let branchExists = false;
|
||||
try {
|
||||
await execAsync(`git show-ref --verify --quiet refs/heads/${branchName}`, { cwd });
|
||||
await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
|
||||
cwd,
|
||||
});
|
||||
branchExists = true;
|
||||
} catch {
|
||||
branchExists = false;
|
||||
@@ -1111,7 +1129,9 @@ export async function createWorktree({
|
||||
let suffix = 1;
|
||||
while (true) {
|
||||
try {
|
||||
await execAsync(`git show-ref --verify --quiet refs/heads/${newBranchName}`, { cwd });
|
||||
await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${newBranchName}`], {
|
||||
cwd,
|
||||
});
|
||||
// Branch exists, try with suffix
|
||||
newBranchName = `${candidateBranch}-${suffix}`;
|
||||
suffix++;
|
||||
@@ -1128,8 +1148,10 @@ export async function createWorktree({
|
||||
pathSuffix++;
|
||||
}
|
||||
|
||||
const command = `git worktree add "${finalWorktreePath}" -b "${newBranchName}" "${base}"`;
|
||||
await execAsync(command, { cwd });
|
||||
await runGitCommand(["worktree", "add", finalWorktreePath, "-b", newBranchName, base], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
worktreePath = normalizePathForOwnership(finalWorktreePath);
|
||||
|
||||
writePaseoWorktreeMetadata(worktreePath, { baseRefName: normalizedBaseBranch });
|
||||
|
||||
Reference in New Issue
Block a user