fix(server): centralize git subprocesses with p-limit throttling (#310)
* Fix uncaught timeout rejection when archiving agents * fix(server): centralize git subprocesses under runGitCommand with p-limit throttling Supersedes #299. Same problem (unbounded git subprocess concurrency causing zombie accumulation), different approach: a single `runGitCommand(args, opts)` function backed by `p-limit` instead of a custom semaphore pool. - Always uses `spawn` (one code path, no exec/execFile split) - Concurrency configurable via PASEO_GIT_CONCURRENCY env var (default 8) - 30s default timeout for read-only ops, 120s for mutations - Centralized error formatting, stdout truncation, process reaping - Runtime tests verifying throttling, timeout kills, deadlock prevention * fix: update lockfile signatures and Nix hash * fix(server): add Windows compatibility to runGitCommand - shell: true on Windows (resolves .cmd/.bat git shims) - windowsHide: true always (prevents console window flashes) - quoteForCmd for args with spaces when routing through cmd.exe - Tests verify shell/windowsHide behavior per platform * refactor: use spawnProcess instead of duplicating Windows handling spawnProcess from spawn.ts already handles shell: true on Windows, arg quoting for cmd.exe, and windowsHide: true. No need to reimplement. * fix: non-null assertions for stdio streams in strict build --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent
e6ff15a1e0
commit
964bf3e13f
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-URkLpcPEB530mm6w87YZ/ggyKIYHcHsQCfzA9f9xBZU=";
|
npmDepsHash = "sha256-bLM+tKHQiiu2ggZeYKFAO6OQFNGVxzXgxNQf+vNpTUQ=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# Prevent onnxruntime-node's install script from running during automatic
|
||||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+28
@@ -35406,6 +35406,7 @@
|
|||||||
"node-pty": "1.2.0-beta.11",
|
"node-pty": "1.2.0-beta.11",
|
||||||
"onnxruntime-node": "^1.23.0",
|
"onnxruntime-node": "^1.23.0",
|
||||||
"openai": "^4.20.0",
|
"openai": "^4.20.0",
|
||||||
|
"p-limit": "^7.3.0",
|
||||||
"pi-acp": "^0.0.24",
|
"pi-acp": "^0.0.24",
|
||||||
"pino": "^10.2.0",
|
"pino": "^10.2.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
@@ -35681,6 +35682,21 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/server/node_modules/p-limit": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"yocto-queue": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/server/node_modules/raw-body": {
|
"packages/server/node_modules/raw-body": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
|
||||||
@@ -35778,6 +35794,18 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/server/node_modules/yocto-queue": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/server/node_modules/zod": {
|
"packages/server/node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
|||||||
@@ -258,7 +258,8 @@ export function AgentList({
|
|||||||
if (!actionAgent || !actionClient) {
|
if (!actionAgent || !actionClient) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void actionClient.archiveAgent(actionAgent.id);
|
// Timeout errors are swallowed — the daemon will still process the archive
|
||||||
|
void actionClient.archiveAgent(actionAgent.id).catch(() => {});
|
||||||
setActionAgent(null);
|
setActionAgent(null);
|
||||||
}, [actionAgent, actionClient]);
|
}, [actionAgent, actionClient]);
|
||||||
|
|
||||||
|
|||||||
@@ -1291,7 +1291,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void archiveAgent({ serverId: normalizedServerId, agentId });
|
// Errors (e.g. timeout) are handled by the mutation's onSettled callback
|
||||||
|
void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey],
|
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey],
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
"node-pty": "1.2.0-beta.11",
|
"node-pty": "1.2.0-beta.11",
|
||||||
"onnxruntime-node": "^1.23.0",
|
"onnxruntime-node": "^1.23.0",
|
||||||
"openai": "^4.20.0",
|
"openai": "^4.20.0",
|
||||||
|
"p-limit": "^7.3.0",
|
||||||
"pi-acp": "^0.0.24",
|
"pi-acp": "^0.0.24",
|
||||||
"pino": "^10.2.0",
|
"pino": "^10.2.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
|
|||||||
@@ -1,19 +1,10 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
const { execMock, getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } =
|
const { getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, watchCalls } = vi.hoisted(
|
||||||
vi.hoisted(() => {
|
() => {
|
||||||
const hoistedWatchCalls: Array<{ path: string; close: ReturnType<typeof vi.fn> }> = [];
|
const hoistedWatchCalls: Array<{ path: string; close: ReturnType<typeof vi.fn> }> = [];
|
||||||
return {
|
return {
|
||||||
execMock: vi.fn(
|
|
||||||
(
|
|
||||||
_command: string,
|
|
||||||
_options: unknown,
|
|
||||||
callback: (error: null, result: { stdout: string; stderr: string }) => void,
|
|
||||||
) => {
|
|
||||||
callback(null, { stdout: "/tmp/repo\n", stderr: "" });
|
|
||||||
},
|
|
||||||
),
|
|
||||||
getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
|
getCheckoutDiffMock: vi.fn(async () => ({ diff: "", structured: [] })),
|
||||||
resolveCheckoutGitDirMock: vi.fn(async () => "/tmp/repo/.git"),
|
resolveCheckoutGitDirMock: vi.fn(async () => "/tmp/repo/.git"),
|
||||||
readdirMock: vi.fn(async (directory: string) => {
|
readdirMock: vi.fn(async (directory: string) => {
|
||||||
@@ -40,10 +31,17 @@ const { execMock, getCheckoutDiffMock, resolveCheckoutGitDirMock, readdirMock, w
|
|||||||
}),
|
}),
|
||||||
watchCalls: hoistedWatchCalls,
|
watchCalls: hoistedWatchCalls,
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
vi.mock("child_process", () => ({
|
vi.mock("../utils/run-git-command.js", () => ({
|
||||||
exec: execMock,
|
runGitCommand: vi.fn(async () => ({
|
||||||
|
stdout: "/tmp/repo\n",
|
||||||
|
stderr: "",
|
||||||
|
truncated: false,
|
||||||
|
exitCode: 0,
|
||||||
|
signal: null,
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("node:fs/promises", async () => {
|
vi.mock("node:fs/promises", async () => {
|
||||||
@@ -88,7 +86,6 @@ describe("CheckoutDiffManager Linux watchers", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
watchCalls.length = 0;
|
watchCalls.length = 0;
|
||||||
execMock.mockClear();
|
|
||||||
getCheckoutDiffMock.mockClear();
|
getCheckoutDiffMock.mockClear();
|
||||||
resolveCheckoutGitDirMock.mockClear();
|
resolveCheckoutGitDirMock.mockClear();
|
||||||
readdirMock.mockClear();
|
readdirMock.mockClear();
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import { watch, type FSWatcher } from "node:fs";
|
import { watch, type FSWatcher } from "node:fs";
|
||||||
import { readdir } from "node:fs/promises";
|
import { readdir } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { exec } from "child_process";
|
|
||||||
import { promisify } from "util";
|
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
|
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
|
||||||
import { getCheckoutDiff } from "../utils/checkout-git.js";
|
import { getCheckoutDiff } from "../utils/checkout-git.js";
|
||||||
import { expandTilde } from "../utils/path.js";
|
import { expandTilde } from "../utils/path.js";
|
||||||
|
import { runGitCommand } from "../utils/run-git-command.js";
|
||||||
import { READ_ONLY_GIT_ENV, resolveCheckoutGitDir, toCheckoutError } from "./checkout-git-utils.js";
|
import { READ_ONLY_GIT_ENV, resolveCheckoutGitDir, toCheckoutError } from "./checkout-git-utils.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
|
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
|
||||||
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
|
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
|
||||||
|
|
||||||
@@ -177,10 +174,13 @@ export class CheckoutDiffManager {
|
|||||||
|
|
||||||
private async resolveCheckoutWatchRoot(cwd: string): Promise<string | null> {
|
private async resolveCheckoutWatchRoot(cwd: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", {
|
const { stdout } = await runGitCommand(
|
||||||
cwd,
|
["rev-parse", "--path-format=absolute", "--show-toplevel"],
|
||||||
env: READ_ONLY_GIT_ENV,
|
{
|
||||||
});
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
},
|
||||||
|
);
|
||||||
const root = stdout.trim();
|
const root = stdout.trim();
|
||||||
return root.length > 0 ? root : null;
|
return root.length > 0 ? root : null;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { exec } from "child_process";
|
|
||||||
import { promisify } from "util";
|
|
||||||
import {
|
import {
|
||||||
MergeConflictError,
|
MergeConflictError,
|
||||||
MergeFromBaseConflictError,
|
MergeFromBaseConflictError,
|
||||||
NotGitRepoError,
|
NotGitRepoError,
|
||||||
} from "../utils/checkout-git.js";
|
} from "../utils/checkout-git.js";
|
||||||
|
import { runGitCommand } from "../utils/run-git-command.js";
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
export const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
export const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -22,7 +19,7 @@ export type CheckoutErrorPayload = {
|
|||||||
|
|
||||||
export async function resolveCheckoutGitDir(cwd: string): Promise<string | null> {
|
export async function resolveCheckoutGitDir(cwd: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git rev-parse --absolute-git-dir", {
|
const { stdout } = await runGitCommand(["rev-parse", "--absolute-git-dir"], {
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { execFile } from "node:child_process";
|
|
||||||
import { watch, type FSWatcher } from "node:fs";
|
import { watch, type FSWatcher } from "node:fs";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import { promisify } from "node:util";
|
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import type { CheckoutContext } from "../utils/checkout-git.js";
|
import type { CheckoutContext } from "../utils/checkout-git.js";
|
||||||
import {
|
import {
|
||||||
@@ -13,10 +11,9 @@ import {
|
|||||||
resolveGhPath,
|
resolveGhPath,
|
||||||
resolveAbsoluteGitDir,
|
resolveAbsoluteGitDir,
|
||||||
} from "../utils/checkout-git.js";
|
} from "../utils/checkout-git.js";
|
||||||
|
import { runGitCommand } from "../utils/run-git-command.js";
|
||||||
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
||||||
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||||
|
|
||||||
@@ -587,11 +584,12 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runGitFetch(cwd: string): Promise<void> {
|
async function runGitFetch(cwd: string): Promise<void> {
|
||||||
await execFileAsync("git", ["fetch", "origin", "--prune"], {
|
await runGitCommand(["fetch", "origin", "--prune"], {
|
||||||
cwd,
|
cwd,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
GIT_TERMINAL_PROMPT: "0",
|
GIT_TERMINAL_PROMPT: "0",
|
||||||
},
|
},
|
||||||
|
timeout: 120_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import { exec } from "node:child_process";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
import { sep } from "node:path";
|
import { sep } from "node:path";
|
||||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
import type { TerminalSession } from "../terminal/terminal.js";
|
import type { TerminalSession } from "../terminal/terminal.js";
|
||||||
|
import { runGitCommand } from "../utils/run-git-command.js";
|
||||||
import {
|
import {
|
||||||
createWorktree,
|
createWorktree,
|
||||||
getWorktreeTerminalSpecs,
|
getWorktreeTerminalSpecs,
|
||||||
@@ -50,7 +49,6 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
|||||||
...process.env,
|
...process.env,
|
||||||
GIT_OPTIONAL_LOCKS: "0",
|
GIT_OPTIONAL_LOCKS: "0",
|
||||||
};
|
};
|
||||||
const execAsync = promisify(exec);
|
|
||||||
const worktreeSetupEligibility = new WeakMap<WorktreeConfig, boolean>();
|
const worktreeSetupEligibility = new WeakMap<WorktreeConfig, boolean>();
|
||||||
|
|
||||||
type MiddleTruncationAccumulator = {
|
type MiddleTruncationAccumulator = {
|
||||||
@@ -195,7 +193,7 @@ async function findExistingPaseoWorktreeBySlug(options: CreateAgentWorktreeOptio
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveBranchNameForWorktreePath(worktreePath: string): Promise<string> {
|
async function resolveBranchNameForWorktreePath(worktreePath: string): Promise<string> {
|
||||||
const { stdout } = await execAsync("git branch --show-current", {
|
const { stdout } = await runGitCommand(["branch", "--show-current"], {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { exec } from "node:child_process";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
@@ -36,9 +34,8 @@ import {
|
|||||||
validateBranchSlug,
|
validateBranchSlug,
|
||||||
type WorktreeConfig,
|
type WorktreeConfig,
|
||||||
} from "../utils/worktree.js";
|
} from "../utils/worktree.js";
|
||||||
|
import { runGitCommand } from "../utils/run-git-command.js";
|
||||||
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
|
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
|
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
|
||||||
|
|
||||||
export type NormalizedGitOptions = {
|
export type NormalizedGitOptions = {
|
||||||
@@ -154,7 +151,7 @@ export async function buildAgentSessionConfig(
|
|||||||
if (normalized.createNewBranch) {
|
if (normalized.createNewBranch) {
|
||||||
targetBranch = normalized.newBranchName!;
|
targetBranch = normalized.newBranchName!;
|
||||||
} else {
|
} else {
|
||||||
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
|
const { stdout } = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ vi.mock("child_process", async () => {
|
|||||||
normalizedArgs[0] === "diff" &&
|
normalizedArgs[0] === "diff" &&
|
||||||
normalizedArgs.includes("HEAD") &&
|
normalizedArgs.includes("HEAD") &&
|
||||||
!normalizedArgs.includes("--numstat") &&
|
!normalizedArgs.includes("--numstat") &&
|
||||||
!normalizedArgs.includes("--no-index");
|
!normalizedArgs.includes("--no-index") &&
|
||||||
|
!normalizedArgs.includes("--shortstat") &&
|
||||||
|
!normalizedArgs.includes("--name-status");
|
||||||
if (isTrackedTextDiff) {
|
if (isTrackedTextDiff) {
|
||||||
spawnCounters.trackedTextDiffCalls += 1;
|
spawnCounters.trackedTextDiffCalls += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { exec, execFile } from "child_process";
|
import { execFile } from "child_process";
|
||||||
import { spawnProcess } from "./spawn.js";
|
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { resolve, dirname, basename } from "path";
|
import { resolve, dirname, basename } from "path";
|
||||||
import { existsSync, realpathSync } from "fs";
|
import { existsSync, realpathSync } from "fs";
|
||||||
@@ -8,17 +7,16 @@ import { TTLCache } from "@isaacs/ttlcache";
|
|||||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||||
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
||||||
import { findExecutable } from "./executable.js";
|
import { findExecutable } from "./executable.js";
|
||||||
|
import { runGitCommand } from "./run-git-command.js";
|
||||||
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
|
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
|
||||||
import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js";
|
import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
GIT_OPTIONAL_LOCKS: "0",
|
GIT_OPTIONAL_LOCKS: "0",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SMALL_OUTPUT_MAX_BUFFER = 20 * 1024 * 1024; // 20MB
|
|
||||||
const DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS = 30_000;
|
const DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS = 30_000;
|
||||||
const PULL_REQUEST_STATUS_CACHE_MAX = 1_000;
|
const PULL_REQUEST_STATUS_CACHE_MAX = 1_000;
|
||||||
|
|
||||||
@@ -63,91 +61,6 @@ export function __setGhPathForTests(path: string | null): void {
|
|||||||
cachedGhPath = path;
|
cachedGhPath = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = {
|
type CheckoutFileChange = {
|
||||||
path: string;
|
path: string;
|
||||||
oldPath?: string;
|
oldPath?: string;
|
||||||
@@ -187,8 +100,13 @@ interface GitRef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
|
async function listGitRefs(cwd: string, refPrefix: string): Promise<GitRef[]> {
|
||||||
const { stdout } = await execGit(
|
const { stdout } = await runGitCommand(
|
||||||
`git for-each-ref --sort=-committerdate --format="%(refname)%09%(committerdate:unix)" ${refPrefix}`,
|
[
|
||||||
|
"for-each-ref",
|
||||||
|
"--sort=-committerdate",
|
||||||
|
"--format=%(refname)%09%(committerdate:unix)",
|
||||||
|
refPrefix,
|
||||||
|
],
|
||||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||||
);
|
);
|
||||||
return stdout
|
return stdout
|
||||||
@@ -290,17 +208,12 @@ async function listCheckoutFileChanges(
|
|||||||
): Promise<CheckoutFileChange[]> {
|
): Promise<CheckoutFileChange[]> {
|
||||||
const changes: CheckoutFileChange[] = [];
|
const changes: CheckoutFileChange[] = [];
|
||||||
|
|
||||||
const { stdout: nameStatusOut } = await execFileAsync(
|
const { stdout: nameStatusOut } = await runGitCommand(
|
||||||
"git",
|
|
||||||
buildGitDiffArgs({
|
buildGitDiffArgs({
|
||||||
ignoreWhitespace,
|
ignoreWhitespace,
|
||||||
extra: ["--name-status", ref],
|
extra: ["--name-status", ref],
|
||||||
}),
|
}),
|
||||||
{
|
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||||
cwd,
|
|
||||||
env: READ_ONLY_GIT_ENV,
|
|
||||||
maxBuffer: SMALL_OUTPUT_MAX_BUFFER,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
for (const line of nameStatusOut
|
for (const line of nameStatusOut
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -337,10 +250,13 @@ async function listCheckoutFileChanges(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stdout: untrackedOut } = await execGit("git ls-files --others --exclude-standard", {
|
const { stdout: untrackedOut } = await runGitCommand(
|
||||||
cwd,
|
["ls-files", "--others", "--exclude-standard"],
|
||||||
env: READ_ONLY_GIT_ENV,
|
{
|
||||||
});
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
},
|
||||||
|
);
|
||||||
for (const file of untrackedOut
|
for (const file of untrackedOut
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((l) => l.trim())
|
.map((l) => l.trim())
|
||||||
@@ -375,10 +291,9 @@ async function readGitFileContentAtRef(
|
|||||||
path: string,
|
path: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execFileAsync("git", ["show", `${ref}:${path}`], {
|
const { stdout } = await runGitCommand(["show", `${ref}:${path}`], {
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
maxBuffer: SMALL_OUTPUT_MAX_BUFFER,
|
|
||||||
});
|
});
|
||||||
return stdout;
|
return stdout;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -388,7 +303,7 @@ async function readGitFileContentAtRef(
|
|||||||
|
|
||||||
async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string | null> {
|
async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execGit(`git merge-base ${baseRef} HEAD`, {
|
const { stdout } = await runGitCommand(["merge-base", baseRef, "HEAD"], {
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -428,20 +343,21 @@ async function getTrackedNumstatByPath(
|
|||||||
ref: string,
|
ref: string,
|
||||||
ignoreWhitespace = false,
|
ignoreWhitespace = false,
|
||||||
): Promise<Map<string, FileStat>> {
|
): Promise<Map<string, FileStat>> {
|
||||||
const result = await spawnLimitedText({
|
const result = await runGitCommand(
|
||||||
cmd: "git",
|
buildGitDiffArgs({
|
||||||
args: buildGitDiffArgs({
|
|
||||||
ignoreWhitespace,
|
ignoreWhitespace,
|
||||||
extra: ["--numstat", ref],
|
extra: ["--numstat", ref],
|
||||||
}),
|
}),
|
||||||
cwd,
|
{
|
||||||
env: READ_ONLY_GIT_ENV,
|
cwd,
|
||||||
maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
|
env: READ_ONLY_GIT_ENV,
|
||||||
acceptExitCodes: [0],
|
maxOutputBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
|
||||||
});
|
acceptExitCodes: [0],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const stats = new Map<string, FileStat>();
|
const stats = new Map<string, FileStat>();
|
||||||
const lines = result.text
|
const lines = result.stdout
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -637,14 +553,14 @@ function isGitError(error: unknown): boolean {
|
|||||||
|
|
||||||
async function requireGitRepo(cwd: string): Promise<void> {
|
async function requireGitRepo(cwd: string): Promise<void> {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
throw new NotGitRepoError(cwd);
|
throw new NotGitRepoError(cwd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCurrentBranch(cwd: string): Promise<string | null> {
|
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,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -654,10 +570,13 @@ export async function getCurrentBranch(cwd: string): Promise<string | null> {
|
|||||||
|
|
||||||
async function getWorktreeRoot(cwd: string): Promise<string | null> {
|
async function getWorktreeRoot(cwd: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", {
|
const { stdout } = await runGitCommand(
|
||||||
cwd,
|
["rev-parse", "--path-format=absolute", "--show-toplevel"],
|
||||||
env: READ_ONLY_GIT_ENV,
|
{
|
||||||
});
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
},
|
||||||
|
);
|
||||||
const root = stdout.trim();
|
const root = stdout.trim();
|
||||||
return root.length > 0 ? root : null;
|
return root.length > 0 ? root : null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -666,8 +585,8 @@ async function getWorktreeRoot(cwd: string): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getMainRepoRoot(cwd: string): Promise<string> {
|
export async function getMainRepoRoot(cwd: string): Promise<string> {
|
||||||
const { stdout: commonDirOut } = await execAsync(
|
const { stdout: commonDirOut } = await runGitCommand(
|
||||||
"git rev-parse --path-format=absolute --git-common-dir",
|
["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||||
);
|
);
|
||||||
const commonDir = commonDirOut.trim();
|
const commonDir = commonDirOut.trim();
|
||||||
@@ -677,7 +596,7 @@ export async function getMainRepoRoot(cwd: string): Promise<string> {
|
|||||||
return dirname(normalized);
|
return dirname(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stdout: worktreeOut } = await execAsync("git worktree list --porcelain", {
|
const { stdout: worktreeOut } = await runGitCommand(["worktree", "list", "--porcelain"], {
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -743,7 +662,7 @@ export function parseWorktreeList(output: string): GitWorktreeEntry[] {
|
|||||||
|
|
||||||
async function getWorktreePathForBranch(cwd: string, branchName: string): Promise<string | null> {
|
async function getWorktreePathForBranch(cwd: string, branchName: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -766,8 +685,9 @@ export async function renameCurrentBranch(
|
|||||||
throw new Error("Cannot rename branch in detached HEAD state");
|
throw new Error("Cannot rename branch in detached HEAD state");
|
||||||
}
|
}
|
||||||
|
|
||||||
await execAsync(`git branch -m "${newName}"`, {
|
await runGitCommand(["branch", "-m", newName], {
|
||||||
cwd,
|
cwd,
|
||||||
|
timeout: 120_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentBranch = await getCurrentBranch(cwd);
|
const currentBranch = await getCurrentBranch(cwd);
|
||||||
@@ -800,7 +720,7 @@ async function getConfiguredBaseRefForCwd(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function isWorkingTreeDirty(cwd: string): Promise<boolean> {
|
async function isWorkingTreeDirty(cwd: string): Promise<boolean> {
|
||||||
const { stdout } = await execAsync("git status --porcelain", {
|
const { stdout } = await runGitCommand(["status", "--porcelain"], {
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -809,7 +729,7 @@ async function isWorkingTreeDirty(cwd: string): Promise<boolean> {
|
|||||||
|
|
||||||
export async function getOriginRemoteUrl(cwd: string): Promise<string | null> {
|
export async function getOriginRemoteUrl(cwd: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git config --get remote.origin.url", {
|
const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], {
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -827,7 +747,7 @@ export async function hasOriginRemote(cwd: string): Promise<boolean> {
|
|||||||
|
|
||||||
export async function resolveAbsoluteGitDir(cwd: string): Promise<string | null> {
|
export async function resolveAbsoluteGitDir(cwd: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git rev-parse --absolute-git-dir", {
|
const { stdout } = await runGitCommand(["rev-parse", "--absolute-git-dir"], {
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -850,7 +770,7 @@ async function abortGitPullConflictState(cwd: string): Promise<void> {
|
|||||||
|
|
||||||
if (existsSync(mergeHeadPath)) {
|
if (existsSync(mergeHeadPath)) {
|
||||||
try {
|
try {
|
||||||
await execAsync("git merge --abort", { cwd });
|
await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -858,7 +778,7 @@ async function abortGitPullConflictState(cwd: string): Promise<void> {
|
|||||||
|
|
||||||
if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) {
|
if (existsSync(rebaseMergePath) || existsSync(rebaseApplyPath)) {
|
||||||
try {
|
try {
|
||||||
await execAsync("git rebase --abort", { cwd });
|
await runGitCommand(["rebase", "--abort"], { cwd, timeout: 120_000 });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -867,10 +787,13 @@ async function abortGitPullConflictState(cwd: string): Promise<void> {
|
|||||||
|
|
||||||
export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<string | null> {
|
export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", {
|
const { stdout } = await runGitCommand(
|
||||||
cwd: repoRoot,
|
["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"],
|
||||||
env: READ_ONLY_GIT_ENV,
|
{
|
||||||
});
|
cwd: repoRoot,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
},
|
||||||
|
);
|
||||||
const ref = stdout.trim();
|
const ref = stdout.trim();
|
||||||
if (ref) {
|
if (ref) {
|
||||||
// Prefer a local branch name (e.g. "main") over the remote-tracking ref (e.g. "origin/main")
|
// Prefer a local branch name (e.g. "main") over the remote-tracking ref (e.g. "origin/main")
|
||||||
@@ -880,7 +803,7 @@ export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<
|
|||||||
? remoteShort.slice("origin/".length)
|
? remoteShort.slice("origin/".length)
|
||||||
: remoteShort;
|
: remoteShort;
|
||||||
try {
|
try {
|
||||||
await execAsync(`git show-ref --verify --quiet refs/heads/${localName}`, {
|
await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${localName}`], {
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -893,7 +816,7 @@ export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stdout } = await execAsync("git branch --format='%(refname:short)'", {
|
const { stdout } = await runGitCommand(["branch", "--format=%(refname:short)"], {
|
||||||
cwd: repoRoot,
|
cwd: repoRoot,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -922,7 +845,7 @@ function normalizeLocalBranchRefName(input: string): string {
|
|||||||
|
|
||||||
async function doesGitRefExist(cwd: string, fullRef: string): Promise<boolean> {
|
async function doesGitRefExist(cwd: string, fullRef: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await execAsync(`git show-ref --verify --quiet ${fullRef}`, {
|
await runGitCommand(["show-ref", "--verify", "--quiet", fullRef], {
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -953,8 +876,8 @@ async function resolveBestComparisonBaseRef(
|
|||||||
|
|
||||||
// Both exist: choose the ref with more unique commits compared to the other.
|
// Both exist: choose the ref with more unique commits compared to the other.
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(
|
const { stdout } = await runGitCommand(
|
||||||
`git rev-list --left-right --count ${normalizedBaseRef}...origin/${normalizedBaseRef}`,
|
["rev-list", "--left-right", "--count", `${normalizedBaseRef}...origin/${normalizedBaseRef}`],
|
||||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||||
);
|
);
|
||||||
const [localOnlyRaw, originOnlyRaw] = stdout.trim().split(/\s+/);
|
const [localOnlyRaw, originOnlyRaw] = stdout.trim().split(/\s+/);
|
||||||
@@ -980,8 +903,8 @@ async function getAheadBehind(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, normalizedBaseRef);
|
const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, normalizedBaseRef);
|
||||||
const { stdout } = await execAsync(
|
const { stdout } = await runGitCommand(
|
||||||
`git rev-list --left-right --count ${comparisonBaseRef}...${currentBranch}`,
|
["rev-list", "--left-right", "--count", `${comparisonBaseRef}...${currentBranch}`],
|
||||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||||
);
|
);
|
||||||
const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/);
|
const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/);
|
||||||
@@ -998,15 +921,15 @@ async function getAheadOfOrigin(cwd: string, currentBranch: string): Promise<num
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(
|
const { stdout } = await runGitCommand(
|
||||||
`git rev-list --count origin/${currentBranch}..${currentBranch}`,
|
["rev-list", "--count", `origin/${currentBranch}..${currentBranch}`],
|
||||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||||
);
|
);
|
||||||
const count = Number.parseInt(stdout.trim(), 10);
|
const count = Number.parseInt(stdout.trim(), 10);
|
||||||
return Number.isNaN(count) ? null : count;
|
return Number.isNaN(count) ? null : count;
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(`git rev-list --count ${currentBranch}`, {
|
const { stdout } = await runGitCommand(["rev-list", "--count", currentBranch], {
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -1023,8 +946,8 @@ async function getBehindOfOrigin(cwd: string, currentBranch: string): Promise<nu
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(
|
const { stdout } = await runGitCommand(
|
||||||
`git rev-list --count ${currentBranch}..origin/${currentBranch}`,
|
["rev-list", "--count", `${currentBranch}..origin/${currentBranch}`],
|
||||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||||
);
|
);
|
||||||
const count = Number.parseInt(stdout.trim(), 10);
|
const count = Number.parseInt(stdout.trim(), 10);
|
||||||
@@ -1162,19 +1085,20 @@ async function getUntrackedDiffText(
|
|||||||
// Fall through to git diff path if metadata probing fails.
|
// Fall through to git diff path if metadata probing fails.
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await spawnLimitedText({
|
const result = await runGitCommand(
|
||||||
cmd: "git",
|
buildGitDiffArgs({
|
||||||
args: buildGitDiffArgs({
|
|
||||||
ignoreWhitespace,
|
ignoreWhitespace,
|
||||||
extra: ["--no-index", "/dev/null", "--", change.path],
|
extra: ["--no-index", "/dev/null", "--", change.path],
|
||||||
}),
|
}),
|
||||||
cwd,
|
{
|
||||||
env: READ_ONLY_GIT_ENV,
|
cwd,
|
||||||
maxBytes: PER_FILE_DIFF_MAX_BYTES,
|
env: READ_ONLY_GIT_ENV,
|
||||||
acceptExitCodes: [0, 1],
|
maxOutputBytes: PER_FILE_DIFF_MAX_BYTES,
|
||||||
});
|
acceptExitCodes: [0, 1],
|
||||||
|
},
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
text: result.text,
|
text: result.stdout,
|
||||||
truncated: result.truncated,
|
truncated: result.truncated,
|
||||||
stat: { additions: 0, deletions: 0, isBinary: false },
|
stat: { additions: 0, deletions: 0, isBinary: false },
|
||||||
};
|
};
|
||||||
@@ -1301,7 +1225,7 @@ export async function getCheckoutShortstat(
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync(`git merge-base HEAD ${comparisonBaseRef}`, {
|
const { stdout } = await runGitCommand(["merge-base", "HEAD", comparisonBaseRef], {
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -1326,7 +1250,7 @@ export async function getCheckoutShortstat(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Omit HEAD so the diff includes uncommitted (staged + unstaged) changes
|
// 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,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -1438,17 +1362,18 @@ export async function getCheckoutDiff(
|
|||||||
let trackedDiffText = "";
|
let trackedDiffText = "";
|
||||||
let trackedDiffTruncated = false;
|
let trackedDiffTruncated = false;
|
||||||
if (trackedDiffPaths.length > 0) {
|
if (trackedDiffPaths.length > 0) {
|
||||||
const trackedDiffResult = await spawnLimitedText({
|
const trackedDiffResult = await runGitCommand(
|
||||||
cmd: "git",
|
buildGitDiffArgs({
|
||||||
args: buildGitDiffArgs({
|
|
||||||
ignoreWhitespace,
|
ignoreWhitespace,
|
||||||
extra: [refForDiff, "--", ...trackedDiffPaths],
|
extra: [refForDiff, "--", ...trackedDiffPaths],
|
||||||
}),
|
}),
|
||||||
cwd,
|
{
|
||||||
env: READ_ONLY_GIT_ENV,
|
cwd,
|
||||||
maxBytes: TOTAL_DIFF_MAX_BYTES,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
maxOutputBytes: TOTAL_DIFF_MAX_BYTES,
|
||||||
trackedDiffText = trackedDiffResult.text;
|
},
|
||||||
|
);
|
||||||
|
trackedDiffText = trackedDiffResult.stdout;
|
||||||
trackedDiffTruncated = trackedDiffResult.truncated;
|
trackedDiffTruncated = trackedDiffResult.truncated;
|
||||||
appendDiff(trackedDiffText);
|
appendDiff(trackedDiffText);
|
||||||
if (trackedDiffTruncated) {
|
if (trackedDiffTruncated) {
|
||||||
@@ -1598,10 +1523,11 @@ export async function commitChanges(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await requireGitRepo(cwd);
|
await requireGitRepo(cwd);
|
||||||
if (options.addAll ?? true) {
|
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,
|
cwd,
|
||||||
|
timeout: 120_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1640,16 +1566,23 @@ export async function mergeToBase(
|
|||||||
const originalBranch = await getCurrentBranch(operationCwd);
|
const originalBranch = await getCurrentBranch(operationCwd);
|
||||||
const mode = options.mode ?? "merge";
|
const mode = options.mode ?? "merge";
|
||||||
try {
|
try {
|
||||||
await execAsync(`git checkout ${normalizedBaseRef}`, { cwd: operationCwd });
|
await runGitCommand(["checkout", normalizedBaseRef], {
|
||||||
|
cwd: operationCwd,
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
if (mode === "squash") {
|
if (mode === "squash") {
|
||||||
await execAsync(`git merge --squash ${currentBranch}`, { cwd: operationCwd });
|
await runGitCommand(["merge", "--squash", currentBranch], {
|
||||||
|
cwd: operationCwd,
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
const message =
|
const message =
|
||||||
options.commitMessage ?? `Squash merge ${currentBranch} into ${normalizedBaseRef}`;
|
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,
|
cwd: operationCwd,
|
||||||
|
timeout: 120_000,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await execAsync(`git merge ${currentBranch}`, { cwd: operationCwd });
|
await runGitCommand(["merge", currentBranch], { cwd: operationCwd, timeout: 120_000 });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorDetails =
|
const errorDetails =
|
||||||
@@ -1658,9 +1591,9 @@ export async function mergeToBase(
|
|||||||
: String(error);
|
: String(error);
|
||||||
try {
|
try {
|
||||||
const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([
|
const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([
|
||||||
execAsync("git diff --name-only --diff-filter=U", { cwd: operationCwd }),
|
runGitCommand(["diff", "--name-only", "--diff-filter=U"], { cwd: operationCwd }),
|
||||||
execAsync("git ls-files -u", { cwd: operationCwd }),
|
runGitCommand(["ls-files", "-u"], { cwd: operationCwd }),
|
||||||
execAsync("git status --porcelain", { cwd: operationCwd }),
|
runGitCommand(["status", "--porcelain"], { cwd: operationCwd }),
|
||||||
]);
|
]);
|
||||||
const statusConflicts = statusOutput.stdout
|
const statusConflicts = statusOutput.stdout
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -1684,7 +1617,7 @@ export async function mergeToBase(
|
|||||||
conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails);
|
conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails);
|
||||||
if (conflictDetected) {
|
if (conflictDetected) {
|
||||||
try {
|
try {
|
||||||
await execAsync("git merge --abort", { cwd: operationCwd });
|
await runGitCommand(["merge", "--abort"], { cwd: operationCwd, timeout: 120_000 });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -1705,7 +1638,10 @@ export async function mergeToBase(
|
|||||||
} finally {
|
} finally {
|
||||||
if (isSameCheckout && originalBranch && originalBranch !== normalizedBaseRef) {
|
if (isSameCheckout && originalBranch && originalBranch !== normalizedBaseRef) {
|
||||||
try {
|
try {
|
||||||
await execAsync(`git checkout ${originalBranch}`, { cwd: operationCwd });
|
await runGitCommand(["checkout", originalBranch], {
|
||||||
|
cwd: operationCwd,
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -1735,7 +1671,7 @@ export async function mergeFromBase(
|
|||||||
|
|
||||||
const requireCleanTarget = options.requireCleanTarget ?? true;
|
const requireCleanTarget = options.requireCleanTarget ?? true;
|
||||||
if (requireCleanTarget) {
|
if (requireCleanTarget) {
|
||||||
const { stdout } = await execAsync("git status --porcelain", {
|
const { stdout } = await runGitCommand(["status", "--porcelain"], {
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -1751,7 +1687,7 @@ export async function mergeFromBase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await execAsync(`git merge ${bestBaseRef}`, { cwd });
|
await runGitCommand(["merge", bestBaseRef], { cwd, timeout: 120_000 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorDetails =
|
const errorDetails =
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
@@ -1759,9 +1695,9 @@ export async function mergeFromBase(
|
|||||||
: String(error);
|
: String(error);
|
||||||
try {
|
try {
|
||||||
const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([
|
const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([
|
||||||
execAsync("git diff --name-only --diff-filter=U", { cwd }),
|
runGitCommand(["diff", "--name-only", "--diff-filter=U"], { cwd }),
|
||||||
execAsync("git ls-files -u", { cwd }),
|
runGitCommand(["ls-files", "-u"], { cwd }),
|
||||||
execAsync("git status --porcelain", { cwd }),
|
runGitCommand(["status", "--porcelain"], { cwd }),
|
||||||
]);
|
]);
|
||||||
const statusConflicts = statusOutput.stdout
|
const statusConflicts = statusOutput.stdout
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -1785,7 +1721,7 @@ export async function mergeFromBase(
|
|||||||
conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails);
|
conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails);
|
||||||
if (conflictDetected) {
|
if (conflictDetected) {
|
||||||
try {
|
try {
|
||||||
await execAsync("git merge --abort", { cwd });
|
await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -1817,7 +1753,7 @@ export async function pullCurrentBranch(cwd: string): Promise<void> {
|
|||||||
throw new Error("Remote 'origin' is not configured.");
|
throw new Error("Remote 'origin' is not configured.");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await execAsync("git pull", { cwd });
|
await runGitCommand(["pull"], { cwd, timeout: 120_000 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await abortGitPullConflictState(cwd);
|
await abortGitPullConflictState(cwd);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -1834,7 +1770,7 @@ export async function pushCurrentBranch(cwd: string): Promise<void> {
|
|||||||
if (!hasRemote) {
|
if (!hasRemote) {
|
||||||
throw new Error("Remote 'origin' is not configured.");
|
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 {
|
export interface CreatePullRequestOptions {
|
||||||
@@ -1892,7 +1828,7 @@ function isGhAuthError(error: unknown): boolean {
|
|||||||
|
|
||||||
async function resolveGitHubRepo(cwd: string): Promise<string | null> {
|
async function resolveGitHubRepo(cwd: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git config --get remote.origin.url", {
|
const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], {
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -1954,7 +1890,7 @@ export async function createPullRequest(
|
|||||||
throw new Error(`Base ref mismatch: expected ${base}, got ${options.base}`);
|
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 ghEnv: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||||
const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`];
|
const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`];
|
||||||
|
|||||||
@@ -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(" ");
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
writePaseoWorktreeMetadata,
|
writePaseoWorktreeMetadata,
|
||||||
writePaseoWorktreeRuntimeMetadata,
|
writePaseoWorktreeRuntimeMetadata,
|
||||||
} from "./worktree-metadata.js";
|
} from "./worktree-metadata.js";
|
||||||
|
import { runGitCommand } from "./run-git-command.js";
|
||||||
import { resolvePaseoHome } from "../server/paseo-home.js";
|
import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||||
|
|
||||||
interface PaseoConfig {
|
interface PaseoConfig {
|
||||||
@@ -377,10 +378,13 @@ async function inferRepoRootPathFromWorktreePath(worktreePath: string): Promise<
|
|||||||
} catch {
|
} catch {
|
||||||
// Fallback: best-effort resolve toplevel (will be the worktree root in typical cases)
|
// Fallback: best-effort resolve toplevel (will be the worktree root in typical cases)
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", {
|
const { stdout } = await runGitCommand(
|
||||||
cwd: worktreePath,
|
["rev-parse", "--path-format=absolute", "--show-toplevel"],
|
||||||
env: READ_ONLY_GIT_ENV,
|
{
|
||||||
});
|
cwd: worktreePath,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
},
|
||||||
|
);
|
||||||
const topLevel = stdout.trim();
|
const topLevel = stdout.trim();
|
||||||
if (topLevel) {
|
if (topLevel) {
|
||||||
return normalizePathForOwnership(topLevel);
|
return normalizePathForOwnership(topLevel);
|
||||||
@@ -438,8 +442,9 @@ export async function runWorktreeSetupCommands(options: {
|
|||||||
if (result.exitCode !== 0) {
|
if (result.exitCode !== 0) {
|
||||||
if (options.cleanupOnFailure) {
|
if (options.cleanupOnFailure) {
|
||||||
try {
|
try {
|
||||||
await execAsync(`git worktree remove "${options.worktreePath}" --force`, {
|
await runGitCommand(["worktree", "remove", options.worktreePath, "--force"], {
|
||||||
cwd: options.worktreePath,
|
cwd: options.worktreePath,
|
||||||
|
timeout: 120_000,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
rmSync(options.worktreePath, { recursive: true, force: true });
|
rmSync(options.worktreePath, { recursive: true, force: true });
|
||||||
@@ -457,7 +462,7 @@ export async function runWorktreeSetupCommands(options: {
|
|||||||
|
|
||||||
async function resolveBranchNameForWorktreePath(worktreePath: string): Promise<string> {
|
async function resolveBranchNameForWorktreePath(worktreePath: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git branch --show-current", {
|
const { stdout } = await runGitCommand(["branch", "--show-current"], {
|
||||||
cwd: worktreePath,
|
cwd: worktreePath,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -560,10 +565,13 @@ export async function runWorktreeTeardownCommands(options: {
|
|||||||
* This is where refs, objects, etc. are stored.
|
* This is where refs, objects, etc. are stored.
|
||||||
*/
|
*/
|
||||||
export async function getGitCommonDir(cwd: string): Promise<string> {
|
export async function getGitCommonDir(cwd: string): Promise<string> {
|
||||||
const { stdout } = await execAsync("git rev-parse --path-format=absolute --git-common-dir", {
|
const { stdout } = await runGitCommand(
|
||||||
cwd,
|
["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
||||||
env: READ_ONLY_GIT_ENV,
|
{
|
||||||
});
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
},
|
||||||
|
);
|
||||||
const commonDir = stdout.trim();
|
const commonDir = stdout.trim();
|
||||||
if (!commonDir) {
|
if (!commonDir) {
|
||||||
throw new Error("Not in a git repository");
|
throw new Error("Not in a git repository");
|
||||||
@@ -793,7 +801,7 @@ export async function listPaseoWorktrees({
|
|||||||
paseoHome?: string;
|
paseoHome?: string;
|
||||||
}): Promise<PaseoWorktreeInfo[]> {
|
}): Promise<PaseoWorktreeInfo[]> {
|
||||||
const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
|
const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
|
||||||
const { stdout } = await execAsync("git worktree list --porcelain", {
|
const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
|
||||||
cwd,
|
cwd,
|
||||||
env: READ_ONLY_GIT_ENV,
|
env: READ_ONLY_GIT_ENV,
|
||||||
});
|
});
|
||||||
@@ -824,10 +832,13 @@ export async function resolvePaseoWorktreeRootForCwd(
|
|||||||
|
|
||||||
let worktreeRoot: string | null = null;
|
let worktreeRoot: string | null = null;
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execAsync("git rev-parse --path-format=absolute --show-toplevel", {
|
const { stdout } = await runGitCommand(
|
||||||
cwd,
|
["rev-parse", "--path-format=absolute", "--show-toplevel"],
|
||||||
env: READ_ONLY_GIT_ENV,
|
{
|
||||||
});
|
cwd,
|
||||||
|
env: READ_ONLY_GIT_ENV,
|
||||||
|
},
|
||||||
|
);
|
||||||
const trimmed = stdout.trim();
|
const trimmed = stdout.trim();
|
||||||
worktreeRoot = trimmed.length > 0 ? trimmed : null;
|
worktreeRoot = trimmed.length > 0 ? trimmed : null;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -890,8 +901,9 @@ export async function deletePaseoWorktree({
|
|||||||
worktreePath: resolvedWorktree,
|
worktreePath: resolvedWorktree,
|
||||||
});
|
});
|
||||||
|
|
||||||
await execAsync(`git worktree remove "${resolvedWorktree}" --force`, {
|
await runGitCommand(["worktree", "remove", resolvedWorktree, "--force"], {
|
||||||
cwd,
|
cwd,
|
||||||
|
timeout: 120_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existsSync(resolvedWorktree)) {
|
if (existsSync(resolvedWorktree)) {
|
||||||
@@ -927,11 +939,11 @@ export async function createWorktree({
|
|||||||
// Resolve the base branch - prefer origin/{branch}, then fall back to local
|
// Resolve the base branch - prefer origin/{branch}, then fall back to local
|
||||||
let resolvedBaseBranch = normalizedBaseBranch;
|
let resolvedBaseBranch = normalizedBaseBranch;
|
||||||
try {
|
try {
|
||||||
await execAsync(`git rev-parse --verify origin/${normalizedBaseBranch}`, { cwd });
|
await runGitCommand(["rev-parse", "--verify", `origin/${normalizedBaseBranch}`], { cwd });
|
||||||
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
|
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd });
|
await runGitCommand(["rev-parse", "--verify", normalizedBaseBranch], { cwd });
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
|
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
|
||||||
}
|
}
|
||||||
@@ -946,7 +958,9 @@ export async function createWorktree({
|
|||||||
// Check if branch already exists
|
// Check if branch already exists
|
||||||
let branchExists = false;
|
let branchExists = false;
|
||||||
try {
|
try {
|
||||||
await execAsync(`git show-ref --verify --quiet refs/heads/${branchName}`, { cwd });
|
await runGitCommand(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
|
||||||
|
cwd,
|
||||||
|
});
|
||||||
branchExists = true;
|
branchExists = true;
|
||||||
} catch {
|
} catch {
|
||||||
branchExists = false;
|
branchExists = false;
|
||||||
@@ -963,7 +977,9 @@ export async function createWorktree({
|
|||||||
let suffix = 1;
|
let suffix = 1;
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
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
|
// Branch exists, try with suffix
|
||||||
newBranchName = `${candidateBranch}-${suffix}`;
|
newBranchName = `${candidateBranch}-${suffix}`;
|
||||||
suffix++;
|
suffix++;
|
||||||
@@ -980,8 +996,10 @@ export async function createWorktree({
|
|||||||
pathSuffix++;
|
pathSuffix++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = `git worktree add "${finalWorktreePath}" -b "${newBranchName}" "${base}"`;
|
await runGitCommand(["worktree", "add", finalWorktreePath, "-b", newBranchName, base], {
|
||||||
await execAsync(command, { cwd });
|
cwd,
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
worktreePath = normalizePathForOwnership(finalWorktreePath);
|
worktreePath = normalizePathForOwnership(finalWorktreePath);
|
||||||
|
|
||||||
writePaseoWorktreeMetadata(worktreePath, { baseRefName: normalizedBaseBranch });
|
writePaseoWorktreeMetadata(worktreePath, { baseRefName: normalizedBaseBranch });
|
||||||
|
|||||||
Reference in New Issue
Block a user