diff --git a/apps/server/src/git.test.ts b/apps/server/src/git.test.ts index dd73725c03240c5079dd94178c3684fb3a409133..3a2a57220d0c657d887a0d9f66ae00ce6627f9ba 100644 --- a/apps/server/src/git.test.ts +++ b/apps/server/src/git.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { + GitCoreService, checkoutGitBranch, createGitBranch, createGitWorktree, @@ -420,4 +421,66 @@ describe("git integration", () => { expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); }); }); + + describe("GitCoreService", () => { + it("reports status details and dirty state", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + const core = new GitCoreService(); + + const clean = await core.status({ cwd: tmp.path }); + expect(clean.hasWorkingTreeChanges).toBe(false); + expect(clean.branch).toBeTruthy(); + + await writeFile(path.join(tmp.path, "README.md"), "updated\n"); + const dirty = await core.statusDetails(tmp.path); + expect(dirty.hasWorkingTreeChanges).toBe(true); + }); + + it("prepares commit context by auto-staging and creates commit", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + const core = new GitCoreService(); + + await writeFile(path.join(tmp.path, "README.md"), "new content\n"); + const context = await core.prepareCommitContext(tmp.path); + expect(context).not.toBeNull(); + expect(context!.stagedSummary.length).toBeGreaterThan(0); + expect(context!.stagedPatch.length).toBeGreaterThan(0); + + const created = await core.commit( + tmp.path, + "Add README update", + "- include updated content", + ); + expect(created.commitSha.length).toBeGreaterThan(0); + expect(await git(tmp.path, "log -1 --pretty=%s")).toBe("Add README update"); + }); + + it("pushes with upstream setup and then skips when up to date", async () => { + await using tmp = await makeTmpDir(); + await using remote = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + await git(remote.path, "init --bare"); + await git(tmp.path, `remote add origin '${remote.path}'`); + await createGitBranch({ cwd: tmp.path, branch: "feature/core-push" }); + await checkoutGitBranch({ cwd: tmp.path, branch: "feature/core-push" }); + + await writeFile(path.join(tmp.path, "feature.txt"), "push me\n"); + const core = new GitCoreService(); + const context = await core.prepareCommitContext(tmp.path); + expect(context).not.toBeNull(); + await core.commit(tmp.path, "Add feature file", ""); + + const pushed = await core.pushCurrentBranch(tmp.path, null); + expect(pushed.status).toBe("pushed"); + expect(pushed.setUpstream).toBe(true); + expect(await git(tmp.path, "rev-parse --abbrev-ref @{upstream}")).toBe( + "origin/feature/core-push", + ); + + const skipped = await core.pushCurrentBranch(tmp.path, null); + expect(skipped.status).toBe("skipped_up_to_date"); + }); + }); }); diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts index 34b3b0a0fac7c6b389ffdf9134a75e9eeaf78408..f0518faa32eeb9ef0d60020d9448de8ce8e3de11 100644 --- a/apps/server/src/git.ts +++ b/apps/server/src/git.ts @@ -3,21 +3,52 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { - GitCheckoutInput, - GitCreateBranchInput, - GitCreateWorktreeInput, - GitCreateWorktreeResult, - GitInitInput, - GitListBranchesInput, - GitListBranchesResult, - GitRemoveWorktreeInput, - TerminalCommandInput, - TerminalCommandResult, +import { + gitStatusInputSchema, + gitStatusResultSchema, + type GitCheckoutInput, + type GitCreateBranchInput, + type GitCreateWorktreeInput, + type GitCreateWorktreeResult, + type GitInitInput, + type GitListBranchesInput, + type GitListBranchesResult, + type GitRemoveWorktreeInput, + type GitStatusInput, + type GitStatusResult, + type TerminalCommandInput, + type TerminalCommandResult, } from "@t3tools/contracts"; +export interface GitStatusDetails extends GitStatusResult { + upstreamRef: string | null; +} + +export interface GitPreparedCommitContext { + stagedSummary: string; + stagedPatch: string; +} + +export interface GitPushResult { + status: "pushed" | "skipped_up_to_date"; + branch: string; + upstreamBranch?: string | undefined; + setUpstream?: boolean | undefined; +} + +export interface GitRangeContext { + commitSummary: string; + diffSummary: string; + diffPatch: string; +} + +interface RunGitOptions { + timeoutMs?: number | undefined; + allowNonZeroExit?: boolean | undefined; +} + /** Spawn git directly with an argv array — no shell, no quoting needed. */ -function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise { +function runGit(args: readonly string[], cwd: string, timeoutMs = 30_000): Promise { return new Promise((resolve, reject) => { const child = spawn("git", args, { cwd, @@ -54,6 +85,221 @@ function runGit(args: string[], cwd: string, timeoutMs = 30_000): Promise 0 ? stderr : `code=${result.code ?? "null"}, signal=${result.signal ?? "null"}`; + return new Error(`${commandLabel(args)} failed: ${detail}`); +} + +async function runGitOrThrow( + cwd: string, + args: readonly string[], + options: RunGitOptions = {}, +): Promise { + let result: TerminalCommandResult; + try { + result = await runGit(args, cwd, options.timeoutMs); + } catch (error) { + throw normalizeGitSpawnError(error); + } + + if (!options.allowNonZeroExit && (result.code !== 0 || result.timedOut)) { + throw normalizeGitExecutionError(args, result); + } + return result; +} + +export class GitCoreService { + async status(raw: GitStatusInput): Promise { + const input = gitStatusInputSchema.parse(raw); + const details = await this.statusDetails(input.cwd); + return gitStatusResultSchema.parse({ + branch: details.branch, + hasWorkingTreeChanges: details.hasWorkingTreeChanges, + hasUpstream: details.hasUpstream, + aheadCount: details.aheadCount, + behindCount: details.behindCount, + }); + } + + async statusDetails(cwd: string): Promise { + const stdout = await this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]); + + let branch: string | null = null; + let upstreamRef: string | null = null; + let aheadCount = 0; + let behindCount = 0; + let hasWorkingTreeChanges = false; + + for (const line of stdout.split(/\r?\n/g)) { + if (line.startsWith("# branch.head ")) { + const value = line.slice("# branch.head ".length).trim(); + branch = value.startsWith("(") ? null : value; + continue; + } + if (line.startsWith("# branch.upstream ")) { + const value = line.slice("# branch.upstream ".length).trim(); + upstreamRef = value.length > 0 ? value : null; + continue; + } + if (line.startsWith("# branch.ab ")) { + const value = line.slice("# branch.ab ".length).trim(); + const parsed = parseBranchAb(value); + aheadCount = parsed.ahead; + behindCount = parsed.behind; + continue; + } + if (line.trim().length > 0 && !line.startsWith("#")) { + hasWorkingTreeChanges = true; + } + } + + return { + branch, + upstreamRef, + hasWorkingTreeChanges, + hasUpstream: upstreamRef !== null, + aheadCount, + behindCount, + }; + } + + async prepareCommitContext(cwd: string): Promise { + await this.git(cwd, ["add", "-A"]); + + const stagedSummary = await this.gitStdout(cwd, [ + "diff", + "--cached", + "--name-status", + ]); + if (trimStdout(stagedSummary).length === 0) { + return null; + } + + const stagedPatch = await this.gitStdout(cwd, [ + "diff", + "--cached", + "--patch", + "--minimal", + ]); + + return { + stagedSummary, + stagedPatch, + }; + } + + async commit(cwd: string, subject: string, body: string): Promise<{ commitSha: string }> { + const args = ["commit", "-m", subject]; + const trimmedBody = body.trim(); + if (trimmedBody.length > 0) { + args.push("-m", trimmedBody); + } + await this.git(cwd, args); + const commitSha = trimStdout(await this.gitStdout(cwd, ["rev-parse", "HEAD"])); + return { commitSha }; + } + + async pushCurrentBranch(cwd: string, fallbackBranch: string | null): Promise { + const details = await this.statusDetails(cwd); + const branch = details.branch ?? fallbackBranch; + if (!branch) { + throw new Error("Cannot push from detached HEAD."); + } + + if (details.hasUpstream && details.aheadCount === 0) { + return { + status: "skipped_up_to_date", + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + }; + } + + if (!details.hasUpstream) { + await this.git(cwd, ["push", "-u", "origin", branch]); + return { + status: "pushed", + branch, + upstreamBranch: `origin/${branch}`, + setUpstream: true, + }; + } + + await this.git(cwd, ["push"]); + return { + status: "pushed", + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + setUpstream: false, + }; + } + + async readRangeContext(cwd: string, baseBranch: string): Promise { + const range = `${baseBranch}..HEAD`; + const [commitSummary, diffSummary, diffPatch] = await Promise.all([ + this.gitStdout(cwd, ["log", "--oneline", range]), + this.gitStdout(cwd, ["diff", "--stat", range]), + this.gitStdout(cwd, ["diff", "--patch", "--minimal", range]), + ]); + + return { + commitSummary, + diffSummary, + diffPatch, + }; + } + + async readConfigValue(cwd: string, key: string): Promise { + const stdout = await this.gitStdout(cwd, ["config", "--get", key], true); + const value = trimStdout(stdout); + return value.length > 0 ? value : null; + } + + async git(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise { + await runGitOrThrow(cwd, args, { allowNonZeroExit }); + } + + async gitStdout(cwd: string, args: readonly string[], allowNonZeroExit = false): Promise { + const result = await runGitOrThrow(cwd, args, { allowNonZeroExit }); + return result.stdout; + } +} + export async function runTerminalCommand( input: TerminalCommandInput, ): Promise { @@ -123,7 +369,6 @@ export async function listGitBranches(input: GitListBranchesInput): Promise(); if (worktreeList.code === 0) { let currentPath: string | null = null; @@ -178,7 +421,6 @@ export async function createGitWorktree( const worktreePath = input.path ?? path.join(os.homedir(), ".t3", "worktrees", repoName, sanitizedBranch); - // Create a new branch from the base branch in a new worktree const result = await runGit( ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch], input.cwd, diff --git a/apps/server/src/gitManager.ts b/apps/server/src/gitManager.ts index 2cffb9d872f29c0b59169132fa5f54803197b783..775587c21214b2d9c67b3761f13226e589d2b20f 100644 --- a/apps/server/src/gitManager.ts +++ b/apps/server/src/gitManager.ts @@ -6,8 +6,6 @@ import path from "node:path"; import { gitRunStackedActionInputSchema, gitRunStackedActionResultSchema, - gitStatusInputSchema, - gitStatusResultSchema, type GitRunStackedActionInput, type GitRunStackedActionResult, type GitStatusInput, @@ -20,12 +18,9 @@ import type { TextGenerationService, } from "./coreServices"; import { CodexTextGenerator } from "./codexTextGenerator"; +import { GitCoreService } from "./git"; import { type ProcessRunOptions, runProcess } from "./processRunner"; -interface GitStatusDetails extends GitStatusResult { - upstreamRef: string | null; -} - interface GitManagerDeps { runProcess?: ( command: string, @@ -39,6 +34,7 @@ interface GitManagerDeps { timedOut: boolean; }>; textGenerator?: TextGenerationService; + gitCore?: GitCoreService; } interface OpenPrInfo { @@ -117,15 +113,6 @@ function extractBranchFromRef(ref: string): string { return afterSlash.trim(); } -function parseBranchAb(value: string): { ahead: number; behind: number } { - const match = value.match(/^\+(\d+)\s+-(\d+)$/); - if (!match) return { ahead: 0, behind: 0 }; - return { - ahead: Number(match[1] ?? "0"), - behind: Number(match[2] ?? "0"), - }; -} - function asCommandNotFound( command: string, error: unknown, @@ -138,9 +125,6 @@ function asCommandNotFound( if (command === "codex") { return new Error("Codex CLI (`codex`) is required but not available on PATH."); } - if (command === "git") { - return new Error("Git is required but not available on PATH."); - } return new Error(`${command} is required but not available on PATH.`); } @@ -158,33 +142,19 @@ function normalizeGitHubAuthError(error: unknown): Error | undefined { return undefined; } -function normalizeNotGitRepoError(error: unknown): Error | undefined { - if (!(error instanceof Error)) return undefined; - if (error.message.toLowerCase().includes("not a git repository")) { - return new Error("Current folder is not a git repository."); - } - return undefined; -} - export class GitManager { private readonly run: NonNullable; private readonly textGenerator: TextGenerationService; + private readonly gitCore: GitCoreService; constructor(deps: GitManagerDeps = {}) { this.run = deps.runProcess ?? runProcess; this.textGenerator = deps.textGenerator ?? new CodexTextGenerator(); + this.gitCore = deps.gitCore ?? new GitCoreService(); } async status(raw: GitStatusInput): Promise { - const input = gitStatusInputSchema.parse(raw); - const details = await this.readStatusDetails(input.cwd); - return gitStatusResultSchema.parse({ - branch: details.branch, - hasWorkingTreeChanges: details.hasWorkingTreeChanges, - hasUpstream: details.hasUpstream, - aheadCount: details.aheadCount, - behindCount: details.behindCount, - }); + return this.gitCore.status(raw); } async runStackedAction( @@ -194,7 +164,7 @@ export class GitManager { const wantsPush = input.action !== "commit"; const wantsPr = input.action === "commit_push_pr"; - const initialStatus = await this.readStatusDetails(input.cwd); + const initialStatus = await this.gitCore.statusDetails(input.cwd); if (wantsPush && !initialStatus.branch) { throw new Error("Cannot push from detached HEAD."); } @@ -205,7 +175,7 @@ export class GitManager { const commit = await this.runCommitStep(input.cwd, initialStatus.branch); const push = wantsPush - ? await this.runPushStep(input.cwd, initialStatus.branch) + ? await this.gitCore.pushCurrentBranch(input.cwd, initialStatus.branch) : { status: "skipped_not_requested" as const }; const pr = wantsPr @@ -220,57 +190,6 @@ export class GitManager { }); } - private async readStatusDetails(cwd: string): Promise { - let stdout = ""; - try { - stdout = await this.runGitStdout(cwd, ["status", "--porcelain=2", "--branch"]); - } catch (error) { - throw ( - asCommandNotFound("git", error) ?? - normalizeNotGitRepoError(error) ?? - (error instanceof Error ? error : new Error("Failed to read git status.")) - ); - } - - let branch: string | null = null; - let upstreamRef: string | null = null; - let aheadCount = 0; - let behindCount = 0; - let hasWorkingTreeChanges = false; - - for (const line of stdout.split(/\r?\n/g)) { - if (line.startsWith("# branch.head ")) { - const value = line.slice("# branch.head ".length).trim(); - branch = value.startsWith("(") ? null : value; - continue; - } - if (line.startsWith("# branch.upstream ")) { - const value = line.slice("# branch.upstream ".length).trim(); - upstreamRef = value.length > 0 ? value : null; - continue; - } - if (line.startsWith("# branch.ab ")) { - const value = line.slice("# branch.ab ".length).trim(); - const parsed = parseBranchAb(value); - aheadCount = parsed.ahead; - behindCount = parsed.behind; - continue; - } - if (line.trim().length > 0 && !line.startsWith("#")) { - hasWorkingTreeChanges = true; - } - } - - return { - branch, - upstreamRef, - hasWorkingTreeChanges, - hasUpstream: upstreamRef !== null, - aheadCount, - behindCount, - }; - } - private async runCommitStep( cwd: string, branch: string | null, @@ -279,44 +198,26 @@ export class GitManager { commitSha?: string | undefined; subject?: string | undefined; }> { - await this.runGit(cwd, ["add", "-A"]); - const stagedSummary = await this.runGitStdout(cwd, [ - "diff", - "--cached", - "--name-status", - ]); - if (trimStdout(stagedSummary).length === 0) { + const context = await this.gitCore.prepareCommitContext(cwd); + if (!context) { return { status: "skipped_no_changes" }; } - const stagedPatch = await this.runGitStdout(cwd, [ - "diff", - "--cached", - "--patch", - "--minimal", - ]); - let generated: CommitMessageGenerationResult; try { generated = sanitizeCommitMessage( await this.textGenerator.generateCommitMessage({ cwd, branch, - stagedSummary: limitContext(stagedSummary, 8_000), - stagedPatch: limitContext(stagedPatch, 50_000), + stagedSummary: limitContext(context.stagedSummary, 8_000), + stagedPatch: limitContext(context.stagedPatch, 50_000), }), ); } catch (error) { throw asCommandNotFound("codex", error) ?? error; } - const commitArgs = ["commit", "-m", generated.subject]; - if (generated.body.length > 0) { - commitArgs.push("-m", generated.body); - } - await this.runGit(cwd, commitArgs); - - const commitSha = trimStdout(await this.runGitStdout(cwd, ["rev-parse", "HEAD"])); + const { commitSha } = await this.gitCore.commit(cwd, generated.subject, generated.body); return { status: "created", commitSha, @@ -324,48 +225,6 @@ export class GitManager { }; } - private async runPushStep( - cwd: string, - fallbackBranch: string | null, - ): Promise<{ - status: "pushed" | "skipped_not_requested" | "skipped_up_to_date"; - branch?: string | undefined; - upstreamBranch?: string | undefined; - setUpstream?: boolean | undefined; - }> { - const details = await this.readStatusDetails(cwd); - const branch = details.branch ?? fallbackBranch; - if (!branch) { - throw new Error("Cannot push from detached HEAD."); - } - - if (details.hasUpstream && details.aheadCount === 0) { - return { - status: "skipped_up_to_date", - branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), - }; - } - - if (!details.hasUpstream) { - await this.runGit(cwd, ["push", "-u", "origin", branch]); - return { - status: "pushed", - branch, - upstreamBranch: `origin/${branch}`, - setUpstream: true, - }; - } - - await this.runGit(cwd, ["push"]); - return { - status: "pushed", - branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), - setUpstream: false, - }; - } - private async runPrStep( cwd: string, fallbackBranch: string | null, @@ -377,7 +236,7 @@ export class GitManager { headBranch?: string | undefined; title?: string | undefined; }> { - const details = await this.readStatusDetails(cwd); + const details = await this.gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { throw new Error("Cannot create a pull request from detached HEAD."); @@ -400,22 +259,7 @@ export class GitManager { } const baseBranch = await this.resolveBaseBranch(cwd, branch, details.upstreamRef); - const commitSummary = await this.runGitStdout(cwd, [ - "log", - "--oneline", - `${baseBranch}..HEAD`, - ]); - const diffSummary = await this.runGitStdout(cwd, [ - "diff", - "--stat", - `${baseBranch}..HEAD`, - ]); - const diffPatch = await this.runGitStdout(cwd, [ - "diff", - "--patch", - "--minimal", - `${baseBranch}..HEAD`, - ]); + const rangeContext = await this.gitCore.readRangeContext(cwd, baseBranch); let generated: PrContentGenerationResult; try { @@ -423,9 +267,9 @@ export class GitManager { cwd, baseBranch, headBranch: branch, - commitSummary: limitContext(commitSummary, 20_000), - diffSummary: limitContext(diffSummary, 20_000), - diffPatch: limitContext(diffPatch, 60_000), + commitSummary: limitContext(rangeContext.commitSummary, 20_000), + diffSummary: limitContext(rangeContext.diffSummary, 20_000), + diffPatch: limitContext(rangeContext.diffPatch, 60_000), }); } catch (error) { throw asCommandNotFound("codex", error) ?? error; @@ -524,13 +368,11 @@ export class GitManager { branch: string, upstreamRef: string | null, ): Promise { - const mergeBaseConfig = await this.runGitStdout( + const configured = await this.gitCore.readConfigValue( cwd, - ["config", "--get", `branch.${branch}.gh-merge-base`], - true, + `branch.${branch}.gh-merge-base`, ); - const configured = trimStdout(mergeBaseConfig); - if (configured.length > 0) return configured; + if (configured) return configured; if (upstreamRef) { const upstreamBranch = extractBranchFromRef(upstreamRef); @@ -557,45 +399,6 @@ export class GitManager { return "main"; } - private async runGit( - cwd: string, - args: readonly string[], - allowNonZeroExit = false, - ): Promise { - try { - await this.run("git", args, { - cwd, - allowNonZeroExit, - }); - } catch (error) { - throw ( - asCommandNotFound("git", error) ?? - normalizeNotGitRepoError(error) ?? - (error instanceof Error ? error : new Error("Git command failed.")) - ); - } - } - - private async runGitStdout( - cwd: string, - args: readonly string[], - allowNonZeroExit = false, - ): Promise { - try { - const result = await this.run("git", args, { - cwd, - allowNonZeroExit, - }); - return result.stdout; - } catch (error) { - throw ( - asCommandNotFound("git", error) ?? - normalizeNotGitRepoError(error) ?? - (error instanceof Error ? error : new Error("Git command failed.")) - ); - } - } - private async runGh(cwd: string, args: readonly string[]): Promise { try { await this.run("gh", args, { cwd });