diff --git a/src/workspace/get-workspace-changes.ts b/src/workspace/get-workspace-changes.ts index bba6cc3..b217b41 100644 --- a/src/workspace/get-workspace-changes.ts +++ b/src/workspace/get-workspace-changes.ts @@ -1,17 +1,13 @@ -import { execFile } from "node:child_process"; import { readFile, stat } from "node:fs/promises"; import { join } from "node:path"; -import { promisify } from "node:util"; import type { RuntimeWorkspaceChangesResponse, RuntimeWorkspaceFileChange, RuntimeWorkspaceFileStatus, } from "../core/api-contract.js"; -import { createGitProcessEnv } from "../core/git-process-env.js"; +import { getGitStdout } from "./git-utils.js"; -const execFileAsync = promisify(execFile); -const GIT_MAX_BUFFER_BYTES = 10 * 1024 * 1024; const WORKSPACE_CHANGES_CACHE_MAX_ENTRIES = 128; interface WorkspaceChangesCacheEntry { @@ -105,27 +101,6 @@ function parseTrackedChanges(output: string): NameStatusEntry[] { return entries; } -async function runGit(args: string[], cwd: string): Promise { - try { - const { stdout } = await execFileAsync("git", args, { - cwd, - encoding: "utf8", - maxBuffer: GIT_MAX_BUFFER_BYTES, - env: createGitProcessEnv(), - }); - return String(stdout); - } catch (error) { - const message = - typeof error === "object" && error !== null && "stderr" in error - ? String((error as { stderr?: unknown }).stderr ?? "").trim() - : ""; - if (message) { - throw new Error(message); - } - throw error; - } -} - async function buildFileFingerprints(repoRoot: string, paths: string[]): Promise { if (paths.length === 0) { return []; @@ -193,7 +168,7 @@ function pruneWorkspaceChangesCache(): void { async function readHeadFile(repoRoot: string, path: string): Promise { try { - return await runGit(["show", `HEAD:${path}`], repoRoot); + return await getGitStdout(["show", `HEAD:${path}`], repoRoot); } catch { return null; } @@ -201,7 +176,7 @@ async function readHeadFile(repoRoot: string, path: string): Promise { try { - return await runGit(["show", `${ref}:${path}`], repoRoot); + return await getGitStdout(["show", `${ref}:${path}`], repoRoot); } catch { return null; } @@ -236,7 +211,7 @@ function fallbackStats(oldText: string | null, newText: string | null): DiffStat async function readDiffStat(repoRoot: string, path: string): Promise { try { - const output = await runGit(["diff", "--numstat", "HEAD", "--", path], repoRoot); + const output = await getGitStdout(["diff", "--numstat", "HEAD", "--", path], repoRoot); const firstLine = output .split("\n") .map((line) => line.trim()) @@ -263,7 +238,7 @@ async function readDiffStatBetweenRefs( path: string, ): Promise { try { - const output = await runGit(["diff", "--numstat", fromRef, toRef, "--", path], repoRoot); + const output = await getGitStdout(["diff", "--numstat", fromRef, toRef, "--", path], repoRoot); const firstLine = output .split("\n") .map((line) => line.trim()) @@ -285,7 +260,7 @@ async function readDiffStatBetweenRefs( async function readDiffStatFromRef(repoRoot: string, fromRef: string, path: string): Promise { try { - const output = await runGit(["diff", "--numstat", fromRef, "--", path], repoRoot); + const output = await getGitStdout(["diff", "--numstat", fromRef, "--", path], repoRoot); const firstLine = output .split("\n") .map((line) => line.trim()) @@ -377,7 +352,7 @@ async function buildFileChangeFromRef( } export async function createEmptyWorkspaceChangesResponse(cwd: string): Promise { - const repoRoot = (await runGit(["rev-parse", "--show-toplevel"], cwd)).trim(); + const repoRoot = (await getGitStdout(["rev-parse", "--show-toplevel"], cwd)).trim(); if (!repoRoot) { throw new Error("Could not resolve git repository root."); } @@ -389,15 +364,15 @@ export async function createEmptyWorkspaceChangesResponse(cwd: string): Promise< } export async function getWorkspaceChanges(cwd: string): Promise { - const repoRoot = (await runGit(["rev-parse", "--show-toplevel"], cwd)).trim(); + const repoRoot = (await getGitStdout(["rev-parse", "--show-toplevel"], cwd)).trim(); if (!repoRoot) { throw new Error("Could not resolve git repository root."); } const [trackedChangesOutput, untrackedOutput, headCommitOutput] = await Promise.all([ - runGit(["diff", "--name-status", "HEAD", "--"], repoRoot), - runGit(["ls-files", "--others", "--exclude-standard"], repoRoot), - runGit(["rev-parse", "--verify", "HEAD"], repoRoot).catch(() => ""), + getGitStdout(["diff", "--name-status", "HEAD", "--"], repoRoot), + getGitStdout(["ls-files", "--others", "--exclude-standard"], repoRoot), + getGitStdout(["rev-parse", "--verify", "HEAD"], repoRoot).catch(() => ""), ]); const trackedChanges = parseTrackedChanges(trackedChangesOutput); const untrackedPaths = untrackedOutput @@ -449,12 +424,12 @@ export async function getWorkspaceChanges(cwd: string): Promise { - const repoRoot = (await runGit(["rev-parse", "--show-toplevel"], input.cwd)).trim(); + const repoRoot = (await getGitStdout(["rev-parse", "--show-toplevel"], input.cwd)).trim(); if (!repoRoot) { throw new Error("Could not resolve git repository root."); } - const trackedChangesOutput = await runGit( + const trackedChangesOutput = await getGitStdout( ["diff", "--name-status", "--find-renames", input.fromRef, input.toRef, "--"], repoRoot, ); @@ -480,14 +455,14 @@ export async function getWorkspaceChangesBetweenRefs( } export async function getWorkspaceChangesFromRef(input: ChangesFromRefInput): Promise { - const repoRoot = (await runGit(["rev-parse", "--show-toplevel"], input.cwd)).trim(); + const repoRoot = (await getGitStdout(["rev-parse", "--show-toplevel"], input.cwd)).trim(); if (!repoRoot) { throw new Error("Could not resolve git repository root."); } const [trackedChangesOutput, untrackedOutput] = await Promise.all([ - runGit(["diff", "--name-status", "--find-renames", input.fromRef, "--"], repoRoot), - runGit(["ls-files", "--others", "--exclude-standard"], repoRoot), + getGitStdout(["diff", "--name-status", "--find-renames", input.fromRef, "--"], repoRoot), + getGitStdout(["ls-files", "--others", "--exclude-standard"], repoRoot), ]); const trackedChanges = parseTrackedChanges(trackedChangesOutput); const untrackedPaths = untrackedOutput diff --git a/src/workspace/git-history.ts b/src/workspace/git-history.ts index c3bd869..56351e4 100644 --- a/src/workspace/git-history.ts +++ b/src/workspace/git-history.ts @@ -1,6 +1,3 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - import type { RuntimeGitCommit, RuntimeGitCommitDiffResponse, @@ -8,52 +5,15 @@ import type { RuntimeGitRef, RuntimeGitRefsResponse, } from "../core/api-contract.js"; -import { createGitProcessEnv } from "../core/git-process-env.js"; - -const execFileAsync = promisify(execFile); -const GIT_MAX_BUFFER_BYTES = 10 * 1024 * 1024; +import { runGit } from "./git-utils.js"; const LOG_FIELD_SEPARATOR = "\x1f"; const LOG_RECORD_SEPARATOR = "\x1e"; const LOG_FORMAT = ["%H", "%h", "%an", "%ae", "%aI", "%s", "%P"].join(LOG_FIELD_SEPARATOR); -interface GitCommandResult { - ok: boolean; - stdout: string; - error: string | null; -} - type CommitRelation = NonNullable; -async function runGit( - cwd: string, - args: string[], - options?: { - trimStdout?: boolean; - }, -): Promise { - try { - const { stdout } = await execFileAsync("git", args, { - cwd, - encoding: "utf8", - maxBuffer: GIT_MAX_BUFFER_BYTES, - env: createGitProcessEnv(), - }); - const stdoutText = String(stdout ?? ""); - return { - ok: true, - stdout: options?.trimStdout === false ? stdoutText : stdoutText.trim(), - error: null, - }; - } catch (error) { - const candidate = error as { stderr?: unknown; message?: unknown }; - const stderr = String(candidate.stderr ?? "").trim(); - const message = String(candidate.message ?? "").trim(); - return { ok: false, stdout: "", error: stderr || message || "Git command failed." }; - } -} - function parseCommitRecord(record: string): RuntimeGitCommit | null { const fields = record.split(LOG_FIELD_SEPARATOR); if (fields.length < 7) { diff --git a/src/workspace/git-sync.ts b/src/workspace/git-sync.ts index c5dabac..7517a0c 100644 --- a/src/workspace/git-sync.ts +++ b/src/workspace/git-sync.ts @@ -1,7 +1,5 @@ -import { execFile } from "node:child_process"; import { readFile, stat } from "node:fs/promises"; import { join } from "node:path"; -import { promisify } from "node:util"; import type { RuntimeGitCheckoutResponse, @@ -10,18 +8,7 @@ import type { RuntimeGitSyncResponse, RuntimeGitSyncSummary, } from "../core/api-contract.js"; -import { createGitProcessEnv } from "../core/git-process-env.js"; - -const execFileAsync = promisify(execFile); -const GIT_MAX_BUFFER_BYTES = 10 * 1024 * 1024; - -interface GitCommandResult { - ok: boolean; - stdout: string; - stderr: string; - output: string; - error: string | null; -} +import { runGit } from "./git-utils.js"; interface GitPathFingerprint { path: string; @@ -126,8 +113,8 @@ function parseStatusPath(line: string): string | null { export async function probeGitWorkspaceState(cwd: string): Promise { const repoRoot = await resolveRepoRoot(cwd); const [statusResult, headCommitResult] = await Promise.all([ - runGitCommand(repoRoot, ["status", "--porcelain=v2", "--branch", "--untracked-files=all"]), - runGitCommand(repoRoot, ["rev-parse", "--verify", "HEAD"]), + runGit(repoRoot, ["status", "--porcelain=v2", "--branch", "--untracked-files=all"]), + runGit(repoRoot, ["rev-parse", "--verify", "HEAD"]), ]); if (!statusResult.ok) { @@ -212,41 +199,8 @@ export async function probeGitWorkspaceState(cwd: string): Promise { - try { - const { stdout, stderr } = await execFileAsync("git", args, { - cwd, - encoding: "utf8", - maxBuffer: GIT_MAX_BUFFER_BYTES, - env: createGitProcessEnv(), - }); - const normalizedStdout = String(stdout ?? "").trim(); - const normalizedStderr = String(stderr ?? "").trim(); - return { - ok: true, - stdout: normalizedStdout, - stderr: normalizedStderr, - output: [normalizedStdout, normalizedStderr].filter(Boolean).join("\n"), - error: null, - }; - } catch (error) { - const candidate = error as { stdout?: unknown; stderr?: unknown; message?: unknown }; - const stdout = String(candidate.stdout ?? "").trim(); - const stderr = String(candidate.stderr ?? "").trim(); - const message = String(candidate.message ?? "").trim(); - const resolvedError = stderr || message || "Git command failed."; - return { - ok: false, - stdout, - stderr, - output: [stdout, stderr].filter(Boolean).join("\n"), - error: resolvedError, - }; - } -} - async function resolveRepoRoot(cwd: string): Promise { - const result = await runGitCommand(cwd, ["rev-parse", "--show-toplevel"]); + const result = await runGit(cwd, ["rev-parse", "--show-toplevel"]); if (!result.ok || !result.stdout) { throw new Error("No git repository detected for this workspace."); } @@ -268,7 +222,7 @@ async function countUntrackedAdditions(repoRoot: string, untrackedPaths: string[ } async function hasGitRef(repoRoot: string, ref: string): Promise { - const result = await runGitCommand(repoRoot, ["show-ref", "--verify", "--quiet", ref]); + const result = await runGit(repoRoot, ["show-ref", "--verify", "--quiet", ref]); return result.ok; } @@ -277,7 +231,7 @@ export async function getGitSyncSummary( options?: { probe?: GitWorkspaceProbe }, ): Promise { const probe = options?.probe ?? (await probeGitWorkspaceState(cwd)); - const diffResult = await runGitCommand(probe.repoRoot, ["diff", "--numstat", "HEAD", "--"]); + const diffResult = await runGit(probe.repoRoot, ["diff", "--numstat", "HEAD", "--"]); const trackedTotals = diffResult.ok ? parseNumstatTotals(diffResult.stdout) : { additions: 0, deletions: 0 }; const untrackedAdditions = await countUntrackedAdditions(probe.repoRoot, probe.untrackedPaths); @@ -313,7 +267,7 @@ export async function runGitSyncAction(options: { pull: ["pull", "--ff-only"], push: ["push"], }; - const commandResult = await runGitCommand(options.cwd, argsByAction[options.action]); + const commandResult = await runGit(options.cwd, argsByAction[options.action]); const nextSummary = await getGitSyncSummary(options.cwd); if (!commandResult.ok) { @@ -364,10 +318,10 @@ export async function runGitCheckoutAction(options: { const hasLocalBranch = await hasGitRef(repoRoot, `refs/heads/${requestedBranch}`); const commandResult = hasLocalBranch - ? await runGitCommand(repoRoot, ["switch", requestedBranch]) + ? await runGit(repoRoot, ["switch", requestedBranch]) : (await hasGitRef(repoRoot, `refs/remotes/origin/${requestedBranch}`)) - ? await runGitCommand(repoRoot, ["switch", "--track", `origin/${requestedBranch}`]) - : await runGitCommand(repoRoot, ["switch", requestedBranch]); + ? await runGit(repoRoot, ["switch", "--track", `origin/${requestedBranch}`]) + : await runGit(repoRoot, ["switch", requestedBranch]); const nextSummary = await getGitSyncSummary(repoRoot); if (!commandResult.ok) { @@ -400,7 +354,7 @@ export async function discardGitChanges(options: { cwd: string }): Promise { + try { + const { stdout, stderr } = await execFileAsync("git", args, { + cwd, + encoding: "utf8", + maxBuffer: GIT_MAX_BUFFER_BYTES, + env: options.env || createGitProcessEnv(), + }); + const normalizedStdout = String(stdout ?? "").trim(); + const normalizedStderr = String(stderr ?? "").trim(); + return { + ok: true, + stdout: options.trimStdout === false ? stdout : normalizedStdout, + stderr: normalizedStderr, + output: [normalizedStdout, normalizedStderr].filter(Boolean).join("\n"), + error: null, + }; + } catch (error) { + const candidate = error as { stdout?: unknown; stderr?: unknown; message?: unknown }; + const stdout = String(candidate.stdout ?? "").trim(); + const stderr = String(candidate.stderr ?? "").trim(); + const message = String(candidate.message ?? "").trim(); + const command = `git ${args.join(" ")} failed` + const errorMessage = `Failed to run Git Command: \n Command: \n ${command} \n ${stderr || message}` + + return { + ok: false, + stdout, + stderr, + output: [stdout, stderr].filter(Boolean).join("\n"), + error: errorMessage, + }; + } +} + +export async function getGitStdout(args: string[], cwd: string, options: RunGitOptions = {}): Promise { + const result = await runGit(cwd, args, options) + if(!result.ok) { + throw new Error(result.error || result.stdout) + } + + return result.stdout +} \ No newline at end of file diff --git a/src/workspace/task-worktree.ts b/src/workspace/task-worktree.ts index a91b130..ed0d7a2 100644 --- a/src/workspace/task-worktree.ts +++ b/src/workspace/task-worktree.ts @@ -1,7 +1,5 @@ -import { execFile } from "node:child_process"; import { access, lstat, mkdir, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join } from "node:path"; -import { promisify } from "node:util"; import type { RuntimeTaskWorkspaceInfoResponse, @@ -13,11 +11,9 @@ import { getWorkspaceFolderLabelForWorktreePath, normalizeTaskIdForWorktreePath, } from "./task-worktree-path.js"; -import { createGitProcessEnv } from "../core/git-process-env.js"; +import { getGitStdout, runGit } from "./git-utils.js"; import { getRuntimeHomePath, loadWorkspaceContext } from "../state/workspace-state.js"; -const execFileAsync = promisify(execFile); -const GIT_MAX_BUFFER_BYTES = 10 * 1024 * 1024; const KANBAN_MANAGED_EXCLUDE_BLOCK_START = "# kanban-managed-symlinked-ignored-paths:start"; const KANBAN_MANAGED_EXCLUDE_BLOCK_END = "# kanban-managed-symlinked-ignored-paths:end"; @@ -67,28 +63,9 @@ async function pathExists(path: string): Promise { } } -async function runGit(args: string[]): Promise { - const { stdout } = await execFileAsync("git", args, { - encoding: "utf8", - maxBuffer: GIT_MAX_BUFFER_BYTES, - env: createGitProcessEnv(), - }); - return String(stdout).trim(); -} - -function getGitCommandErrorMessage(error: unknown): string { - if (error && typeof error === "object" && "stderr" in error) { - const stderr = (error as { stderr?: unknown }).stderr; - if (typeof stderr === "string" && stderr.trim()) { - return stderr.trim(); - } - } - return error instanceof Error ? error.message : String(error); -} - -async function tryRunGit(args: string[]): Promise { +async function tryGetGitStdout(cwd: string, args: string[]): Promise { try { - return await runGit(args); + return await getGitStdout(args, cwd); } catch { return null; } @@ -99,8 +76,8 @@ async function readGitHeadInfo(cwd: string): Promise<{ headCommit: string | null; isDetached: boolean; }> { - const headCommit = await tryRunGit(["-C", cwd, "rev-parse", "--verify", "HEAD"]); - const branch = await tryRunGit(["-C", cwd, "symbolic-ref", "--quiet", "--short", "HEAD"]); + const headCommit = await tryGetGitStdout(cwd, ["rev-parse", "--verify", "HEAD"]); + const branch = await tryGetGitStdout(cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"]); return { branch, headCommit, @@ -157,15 +134,10 @@ function getUniquePaths(relativePaths: string[]): string[] { } async function listIgnoredPaths(repoPath: string): Promise { - const output = await runGit([ - "-C", + const output = await getGitStdout( + ["ls-files", "--others", "--ignored", "--exclude-per-directory=.gitignore", "--directory"], repoPath, - "ls-files", - "--others", - "--ignored", - "--exclude-per-directory=.gitignore", - "--directory", - ]); + ); return output .split("\n") .map((line) => toPlatformRelativePath(line)) @@ -201,7 +173,7 @@ function stripManagedExcludeBlock(content: string): string { } async function syncManagedIgnoredPathExcludes(repoPath: string, relativePaths: string[]): Promise { - const excludePathOutput = (await runGit(["-C", repoPath, "rev-parse", "--git-path", "info/exclude"])).trim(); + const excludePathOutput = (await getGitStdout(["rev-parse", "--git-path", "info/exclude"], repoPath)).trim(); if (!excludePathOutput) { return; } @@ -262,7 +234,7 @@ async function syncIgnoredPathsIntoWorktree(repoPath: string, worktreePath: stri async function removeTaskWorktreeInternal(repoPath: string, worktreePath: string): Promise { const existed = await pathExists(worktreePath); - await tryRunGit(["-C", repoPath, "worktree", "remove", "--force", worktreePath]); + await runGit(repoPath, ["worktree", "remove", "--force", worktreePath]); await rm(worktreePath, { recursive: true, force: true }); return existed; } @@ -296,7 +268,7 @@ export async function ensureTaskWorktreeIfDoesntExist(options: { // compared the worktree HEAD to the latest baseRef commit and recreated the worktree // when the base branch advanced, which could destroy valid task progress. Existing // worktrees are now treated as authoritative and only missing worktrees are created. - const existingCommit = await tryRunGit(["-C", worktreePath, "rev-parse", "HEAD"]); + const existingCommit = await tryGetGitStdout(worktreePath, ["rev-parse", "HEAD"]); if (existingCommit) { await syncIgnoredPathsIntoWorktree(context.repoPath, worktreePath); return { @@ -318,25 +290,36 @@ export async function ensureTaskWorktreeIfDoesntExist(options: { }; } - let baseCommit: string; - try { - baseCommit = await runGit(["-C", context.repoPath, "rev-parse", "--verify", `${requestedBaseRef}^{commit}`]); - } catch (error) { + const resolveResult = await runGit(context.repoPath, [ + "rev-parse", + "--verify", + `${requestedBaseRef}^{commit}`, + ]); + if (!resolveResult.ok) { + let errorMessage = resolveResult.error ?? "Failed to resolve base ref." + + if(errorMessage.includes('fatal: Needed a single revision') ) { + // When the repo doesn't have a single commit, git outputs the above mentioned error. + // While the user might know what it means, we provide a more user-friendly error in case they don't. + errorMessage += '\n\n Please initialize a commit before using kanban.' + } + return { ok: false, path: null, baseRef: requestedBaseRef, baseCommit: null, - error: getGitCommandErrorMessage(error), + error: errorMessage, }; } + const baseCommit = resolveResult.stdout; if (await pathExists(worktreePath)) { await removeTaskWorktreeInternal(context.repoPath, worktreePath); } await mkdir(dirname(worktreePath), { recursive: true }); - await runGit(["-C", context.repoPath, "worktree", "add", "--detach", worktreePath, baseCommit]); + await getGitStdout(["worktree", "add", "--detach", worktreePath, baseCommit], context.repoPath); await syncIgnoredPathsIntoWorktree(context.repoPath, worktreePath); return { diff --git a/src/workspace/turn-checkpoints.ts b/src/workspace/turn-checkpoints.ts index 5397e05..f284a5d 100644 --- a/src/workspace/turn-checkpoints.ts +++ b/src/workspace/turn-checkpoints.ts @@ -1,36 +1,21 @@ -import { execFile } from "node:child_process"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { promisify } from "node:util"; import type { RuntimeTaskTurnCheckpoint } from "../core/api-contract.js"; import { createGitProcessEnv } from "../core/git-process-env.js"; +import { getGitStdout, RunGitOptions } from "./git-utils.js"; -const execFileAsync = promisify(execFile); -const GIT_MAX_BUFFER_BYTES = 10 * 1024 * 1024; const CHECKPOINT_AUTHOR_NAME = "kanban-checkpoint"; const CHECKPOINT_AUTHOR_EMAIL = "kanban-checkpoint@local"; -interface RunGitOptions { - trimStdout?: boolean; - env?: NodeJS.ProcessEnv; -} - -async function runGit(cwd: string, args: string[], options: RunGitOptions = {}): Promise { - const { stdout } = await execFileAsync("git", args, { - cwd, - encoding: "utf8", - maxBuffer: GIT_MAX_BUFFER_BYTES, - env: options.env ?? createGitProcessEnv(), - }); - const text = String(stdout ?? ""); - return options.trimStdout === false ? text : text.trim(); +function runGit(cwd: string, args: string[], options: RunGitOptions = {}) { + return getGitStdout(args, cwd, options) } async function tryRunGit(cwd: string, args: string[], options: RunGitOptions = {}): Promise { try { - return await runGit(cwd, args, options); + return await getGitStdout(args, cwd, options) } catch { return null; } diff --git a/web-ui/src/main.tsx b/web-ui/src/main.tsx index ff26480..b50e5f0 100644 --- a/web-ui/src/main.tsx +++ b/web-ui/src/main.tsx @@ -24,6 +24,7 @@ ReactDOM.createRoot(root).render( border: "1px solid var(--color-border)", color: "var(--color-text-primary)", fontSize: "13px", + whiteSpace: "pre-line" }, }} />