diff --git a/packages/code/CHANGELOG.md b/packages/code/CHANGELOG.md index 731a898..4e59d15 100644 --- a/packages/code/CHANGELOG.md +++ b/packages/code/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- **Default branch detection**: repository automations now ask the remote for its authoritative default branch before checkout or fetch operations, avoiding failed `master` attempts for repositories whose default is `main` (and supporting custom default branch names). Cached `origin/HEAD` remains an offline fallback - **Structured agent responses**: feasibility checks now accept the valid bare JSON commonly returned by Codex instead of requiring a Markdown code fence. Feasibility, estimation, and auto-review share brace-aware extraction that also tolerates narration and ignores unrelated braces in prose ## [2.3.2] - 2026-08-18 diff --git a/packages/code/src/lib/utils.ts b/packages/code/src/lib/utils.ts index e8107a2..b757c1e 100644 --- a/packages/code/src/lib/utils.ts +++ b/packages/code/src/lib/utils.ts @@ -244,7 +244,12 @@ export class Utils { */ static async executeGitCommand( args: string[], - options?: { verbose?: boolean; cwd?: string }, + options?: { + verbose?: boolean; + cwd?: string; + timeoutMs?: number; + env?: NodeJS.ProcessEnv; + }, ): Promise<{ success: boolean; output: string; error?: string }> { const verbose = options?.verbose ?? false; const cwd = options?.cwd; @@ -254,35 +259,34 @@ export class Utils { } return new Promise((resolve) => { + const useProcessGroup = Boolean(options?.timeoutMs) && process.platform !== "win32"; const git = spawn("git", args, { stdio: ["pipe", "pipe", "pipe"], cwd: cwd || process.cwd(), + env: options?.env ? { ...process.env, ...options.env } : process.env, + detached: useProcessGroup, }); let output = ""; let error = ""; + let settled = false; + let timedOut = false; + let timeout: ReturnType | undefined; - git.stdout.on("data", (data) => { - const text = data.toString(); - output += text; - if (verbose) { - process.stdout.write(text); + const finish = (code: number | null, spawnError?: Error) => { + if (timeout) { + clearTimeout(timeout); } - }); - - git.stderr.on("data", (data) => { - const text = data.toString(); - error += text; - if (verbose) { - process.stderr.write(text); + if (settled) { + return; } - }); - - git.on("close", (code) => { + settled = true; const result = { - success: code === 0, + success: !timedOut && !spawnError && code === 0, output: output.trim(), - error: error.trim(), + error: timedOut + ? `Git command timed out after ${options?.timeoutMs}ms` + : spawnError?.message || error.trim(), }; if (verbose) { @@ -300,7 +304,57 @@ export class Utils { } resolve(result); + }; + + git.stdout.on("data", (data) => { + const text = data.toString(); + output += text; + if (verbose) { + process.stdout.write(text); + } + }); + + git.stderr.on("data", (data) => { + const text = data.toString(); + error += text; + if (verbose) { + process.stderr.write(text); + } }); + + git.on("error", (spawnError) => finish(null, spawnError)); + git.on("close", (code) => finish(code)); + + if (options?.timeoutMs) { + timeout = setTimeout(() => { + timedOut = true; + let killedProcessGroup = false; + if (useProcessGroup && git.pid) { + try { + process.kill(-git.pid, "SIGKILL"); + killedProcessGroup = true; + } catch { + // The process may have exited between the timeout and termination. + } + } + if (process.platform === "win32" && git.pid) { + const taskkill = spawn("taskkill", ["/PID", String(git.pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + taskkill.on("error", () => git.kill("SIGKILL")); + taskkill.on("close", (code) => { + if (code !== 0) { + git.kill("SIGKILL"); + } + }); + return; + } + if (!killedProcessGroup) { + git.kill("SIGKILL"); + } + }, options.timeoutMs); + } }); } @@ -586,9 +640,22 @@ export class Utils { console.log(`📥 Switching to branch '${branch}'...`); } let targetBranch = branch; + let fetchedTargetBeforeCheckout = false; + + const targetExistsLocally = + (await Utils.gitRefExists(`refs/heads/${targetBranch}`, { cwd })) || + (await Utils.gitRefExists(`refs/remotes/origin/${targetBranch}`, { cwd })); + if (!targetExistsLocally) { + if (verbose) { + console.log(`📥 Fetching '${targetBranch}' from origin...`); + } + await Utils.fetchRemoteBranch(targetBranch, { verbose, cwd }); + fetchedTargetBeforeCheckout = true; + } + let switchResult = await Utils.checkoutBranch(targetBranch, { verbose, cwd }); - if (!switchResult.success) { + if (!switchResult.success && !fetchedTargetBeforeCheckout) { if (verbose) { console.log(`📥 Fetching '${targetBranch}' from origin...`); } @@ -736,29 +803,51 @@ export class Utils { } /** - * Detect the repository default branch (`main`, `master`, or origin HEAD). + * Detect the repository default branch from remote metadata, with local + * conventional-branch fallbacks for repositories without a reachable origin. * * @param options - Optional working directory */ static async getMainBranchName(options?: { cwd?: string }): Promise { const gitOptions = options?.cwd ? { cwd: options.cwd } : undefined; - // Prefer the remote's default branch when origin is configured - const defaultBranch = await Utils.executeGitCommand( - ["symbolic-ref", "refs/remotes/origin/HEAD"], - gitOptions, - ); - if (defaultBranch.success) { - const branchName = defaultBranch.output.replace("refs/remotes/origin/", "").trim(); + let sshCommand = process.env.GIT_SSH_COMMAND; + if (sshCommand === undefined) { + const configuredSshCommand = await Utils.executeGitCommand( + ["config", "--get", "core.sshCommand"], + gitOptions, + ); + if (!configuredSshCommand.success || !configuredSshCommand.output) { + sshCommand = "ssh -o BatchMode=yes"; + } + } + + // Ask the remote first. refs/remotes/origin/HEAD is only a local cache and can + // remain pointed at `master` after the repository changes its default to `main`. + const remoteHead = await Utils.executeGitCommand(["ls-remote", "--symref", "origin", "HEAD"], { + ...gitOptions, + timeoutMs: 5000, + env: { + GIT_TERMINAL_PROMPT: "0", + GCM_INTERACTIVE: "Never", + ...(sshCommand === undefined ? {} : { GIT_SSH_COMMAND: sshCommand }), + }, + }); + if (remoteHead.success) { + const match = remoteHead.output.match(/^ref:\s+refs\/heads\/(.+)\s+HEAD$/m); + const branchName = match?.[1]?.trim(); if (branchName) { return branchName; } } - const remoteShow = await Utils.executeGitCommand(["remote", "show", "origin"], gitOptions); - if (remoteShow.success) { - const match = remoteShow.output.match(/HEAD branch:\s*(.+)/); - const branchName = match?.[1]?.trim(); + // Fall back to the cached remote HEAD when origin is temporarily unreachable. + const cachedRemoteHead = await Utils.executeGitCommand( + ["symbolic-ref", "refs/remotes/origin/HEAD"], + gitOptions, + ); + if (cachedRemoteHead.success) { + const branchName = cachedRemoteHead.output.replace("refs/remotes/origin/", "").trim(); if (branchName) { return branchName; } diff --git a/packages/code/tests/default-branch-detection.test.ts b/packages/code/tests/default-branch-detection.test.ts index 9f0b44d..10c8760 100644 --- a/packages/code/tests/default-branch-detection.test.ts +++ b/packages/code/tests/default-branch-detection.test.ts @@ -1,12 +1,29 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { execSync } from "child_process"; -import { mkdirSync, rmSync, writeFileSync } from "fs"; +import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; -import { join } from "path"; +import { delimiter, join } from "path"; import { Utils } from "../src/lib/utils"; describe("Default branch detection", () => { let repoDir: string; + const remoteDirs: string[] = []; + + function configureOrigin(defaultBranch: string): string { + execSync(`git branch -M ${defaultBranch}`, { cwd: repoDir }); + + const remoteDir = join( + tmpdir(), + `default-branch-remote-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + remoteDirs.push(remoteDir); + mkdirSync(remoteDir, { recursive: true }); + execSync("git init --bare", { cwd: remoteDir }); + execSync(`git symbolic-ref HEAD refs/heads/${defaultBranch}`, { cwd: remoteDir }); + execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); + execSync(`git push -u origin ${defaultBranch}`, { cwd: repoDir }); + return remoteDir; + } beforeEach(() => { repoDir = join( @@ -25,6 +42,9 @@ describe("Default branch detection", () => { afterEach(() => { rmSync(repoDir, { recursive: true, force: true }); + for (const remoteDir of remoteDirs.splice(0)) { + rmSync(remoteDir, { recursive: true, force: true }); + } }); test("should detect master when main does not exist", async () => { @@ -41,6 +61,243 @@ describe("Default branch detection", () => { await expect(Utils.resolveDefaultBranch("master", { cwd: repoDir })).resolves.toBe("main"); }); + test("uses main immediately when remote metadata identifies main", async () => { + configureOrigin("main"); + execSync("git checkout -b feature/test", { cwd: repoDir }); + + const gitCommands = spyOn(Utils, "executeGitCommand"); + try { + const branch = await Utils.getMainBranchName({ cwd: repoDir }); + const result = await Utils.pullLatestChanges(branch, { cwd: repoDir }); + + expect(branch).toBe("main"); + expect(result.success).toBe(true); + expect(await Utils.getCurrentBranch(repoDir)).toBe("main"); + expect(gitCommands.mock.calls.map(([args]) => args).flat()).not.toContain("master"); + } finally { + gitCommands.mockRestore(); + } + }); + + test("uses master when remote metadata identifies master", async () => { + configureOrigin("master"); + + await expect(Utils.getMainBranchName({ cwd: repoDir })).resolves.toBe("master"); + }); + + test("supports a custom default branch from remote metadata", async () => { + configureOrigin("develop"); + + await expect(Utils.getMainBranchName({ cwd: repoDir })).resolves.toBe("develop"); + }); + + test("prefers authoritative remote metadata over a stale cached origin HEAD", async () => { + const remoteDir = configureOrigin("main"); + execSync("git branch master", { cwd: repoDir }); + execSync("git push origin master", { cwd: repoDir }); + execSync("git remote set-head origin master", { cwd: repoDir }); + execSync("git symbolic-ref HEAD refs/heads/main", { cwd: remoteDir }); + + await expect(Utils.getMainBranchName({ cwd: repoDir })).resolves.toBe("main"); + }); + + test("fetches and switches to a newly selected remote default branch", async () => { + const remoteDir = configureOrigin("master"); + const publisherDir = join( + tmpdir(), + `default-branch-publisher-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + remoteDirs.push(publisherDir); + execSync(`git clone "${remoteDir}" "${publisherDir}"`); + execSync("git config user.email 'test@test.com'", { cwd: publisherDir }); + execSync("git config user.name 'Test User'", { cwd: publisherDir }); + execSync("git checkout -b main", { cwd: publisherDir }); + writeFileSync(join(publisherDir, "main.txt"), "main branch\n", "utf8"); + execSync("git add main.txt && git commit -m 'Create main' && git push -u origin main", { + cwd: publisherDir, + }); + execSync("git symbolic-ref HEAD refs/heads/main", { cwd: remoteDir }); + + expect(await Utils.gitRefExists("refs/remotes/origin/main", { cwd: repoDir })).toBe(false); + + const branch = await Utils.getMainBranchName({ cwd: repoDir }); + const result = await Utils.pullLatestChanges(branch, { cwd: repoDir }); + + expect(branch).toBe("main"); + expect(result.success).toBe(true); + expect(await Utils.getCurrentBranch(repoDir)).toBe("main"); + expect(await Utils.gitRefExists("refs/remotes/origin/main", { cwd: repoDir })).toBe(true); + expect( + execSync("git rev-parse --abbrev-ref --symbolic-full-name @{upstream}", { + cwd: repoDir, + encoding: "utf8", + }).trim(), + ).toBe("origin/main"); + }); + + test("uses cached origin HEAD when the bounded noninteractive remote probe fails", async () => { + configureOrigin("master"); + execSync("git remote set-head origin master", { cwd: repoDir }); + const executeGitCommand = Utils.executeGitCommand; + const gitCommands = spyOn(Utils, "executeGitCommand").mockImplementation((args, options) => { + if (args.join(" ") === "ls-remote --symref origin HEAD") { + expect(options?.timeoutMs).toBe(5000); + expect(options?.env?.GIT_TERMINAL_PROMPT).toBe("0"); + expect(options?.env?.GCM_INTERACTIVE).toBe("Never"); + expect(options?.env?.GIT_SSH_COMMAND).toBe( + process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes", + ); + return Promise.resolve({ success: false, output: "", error: "authentication failed" }); + } + return executeGitCommand(args, options); + }); + + try { + await expect(Utils.getMainBranchName({ cwd: repoDir })).resolves.toBe("master"); + } finally { + gitCommands.mockRestore(); + } + }); + + test("uses only local fallbacks when the bounded noninteractive remote probe fails", async () => { + execSync("git branch -M master", { cwd: repoDir }); + const executeGitCommand = Utils.executeGitCommand; + const gitCommands = spyOn(Utils, "executeGitCommand").mockImplementation((args, options) => { + if (args.join(" ") === "ls-remote --symref origin HEAD") { + expect(options?.timeoutMs).toBe(5000); + expect(options?.env?.GIT_TERMINAL_PROMPT).toBe("0"); + expect(options?.env?.GCM_INTERACTIVE).toBe("Never"); + expect(options?.env?.GIT_SSH_COMMAND).toBe( + process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes", + ); + return Promise.resolve({ success: false, output: "", error: "authentication failed" }); + } + return executeGitCommand(args, options); + }); + + try { + await expect(Utils.getMainBranchName({ cwd: repoDir })).resolves.toBe("master"); + expect(gitCommands.mock.calls.map(([args]) => args.join(" "))).not.toContain( + "remote show origin", + ); + } finally { + gitCommands.mockRestore(); + } + }); + + test("preserves a custom GIT_SSH_COMMAND for the remote probe", async () => { + execSync("git branch -M master", { cwd: repoDir }); + const originalSshCommand = process.env.GIT_SSH_COMMAND; + process.env.GIT_SSH_COMMAND = "custom-ssh-wrapper --nonstandard-option"; + const executeGitCommand = Utils.executeGitCommand; + const gitCommands = spyOn(Utils, "executeGitCommand").mockImplementation((args, options) => { + if (args.join(" ") === "ls-remote --symref origin HEAD") { + expect(options?.env?.GIT_SSH_COMMAND).toBe("custom-ssh-wrapper --nonstandard-option"); + return Promise.resolve({ success: false, output: "", error: "unreachable" }); + } + return executeGitCommand(args, options); + }); + + try { + await expect(Utils.getMainBranchName({ cwd: repoDir })).resolves.toBe("master"); + } finally { + gitCommands.mockRestore(); + if (originalSshCommand === undefined) { + delete process.env.GIT_SSH_COMMAND; + } else { + process.env.GIT_SSH_COMMAND = originalSshCommand; + } + } + }); + + test("preserves core.sshCommand for the remote probe", async () => { + if (process.platform === "win32") { + return; + } + + const remoteDir = configureOrigin("main"); + execSync("git branch master", { cwd: repoDir }); + execSync("git push origin master", { cwd: repoDir }); + execSync("git remote set-head origin master", { cwd: repoDir }); + const sshWrapper = join(repoDir, "ssh-wrapper"); + const wrapperMarker = join(repoDir, "ssh-wrapper-invoked"); + writeFileSync( + sshWrapper, + `#!/bin/sh\nprintf invoked > "${wrapperMarker}"\nif [ "$1" = "-G" ]; then\n exit 0\nfi\nfor argument do\n command="$argument"\ndone\nexec /bin/sh -c "$command"\n`, + "utf8", + ); + chmodSync(sshWrapper, 0o755); + execSync(`git config core.sshCommand "${sshWrapper}"`, { cwd: repoDir }); + execSync(`git remote set-url origin "ssh://required-wrapper${remoteDir}"`, { cwd: repoDir }); + + const originalSshCommand = process.env.GIT_SSH_COMMAND; + delete process.env.GIT_SSH_COMMAND; + try { + await expect(Utils.getMainBranchName({ cwd: repoDir })).resolves.toBe("main"); + expect(readFileSync(wrapperMarker, "utf8")).toBe("invoked"); + } finally { + if (originalSshCommand !== undefined) { + process.env.GIT_SSH_COMMAND = originalSshCommand; + } + } + }); + + test("terminates descendant processes before a timed-out git command resolves", async () => { + const shimDir = join( + tmpdir(), + `default-branch-git-shim-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + remoteDirs.push(shimDir); + mkdirSync(shimDir, { recursive: true }); + const childPidFile = join(shimDir, "child.pid"); + const gitShim = join(shimDir, process.platform === "win32" ? "git.cmd" : "git"); + if (process.platform === "win32") { + const childScript = join(shimDir, "child.ts"); + writeFileSync( + childScript, + "await Bun.write(process.argv[2]!, String(process.pid));\nawait Bun.sleep(30_000);\n", + "utf8", + ); + writeFileSync( + gitShim, + '@echo off\r\n"%BUN_EXEC_PATH%" "%CHILD_SCRIPT%" "%CHILD_PID_FILE%"\r\n', + "utf8", + ); + } else { + writeFileSync( + gitShim, + '#!/bin/sh\nsleep 30 &\nchild_pid=$!\nprintf "%s\\n" "$child_pid" > "$CHILD_PID_FILE"\nwait "$child_pid"\n', + "utf8", + ); + chmodSync(gitShim, 0o755); + } + + const result = await Utils.executeGitCommand(["status"], { + cwd: repoDir, + timeoutMs: 250, + env: { + PATH: `${shimDir}${delimiter}${process.env.PATH ?? ""}`, + BUN_EXEC_PATH: process.execPath, + CHILD_SCRIPT: join(shimDir, "child.ts"), + CHILD_PID_FILE: childPidFile, + }, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe("Git command timed out after 250ms"); + const childPid = Number(readFileSync(childPidFile, "utf8").trim()); + let childIsAlive = true; + for (let attempt = 0; attempt < 50 && childIsAlive; attempt++) { + try { + process.kill(childPid, 0); + await Bun.sleep(20); + } catch { + childIsAlive = false; + } + } + expect(childIsAlive).toBe(false); + }); + test("should fall back to master when pullLatestChanges is asked for main", async () => { execSync("git branch -M master", { cwd: repoDir }); @@ -58,16 +315,13 @@ describe("Default branch detection", () => { tmpdir(), `default-branch-remote-${Date.now()}-${Math.random().toString(36).slice(2)}`, ); + remoteDirs.push(remoteDir); mkdirSync(remoteDir, { recursive: true }); execSync("git init --bare", { cwd: remoteDir }); execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir }); execSync("git push origin master", { cwd: repoDir }); - try { - await expect(Utils.remoteBranchExists("master", { cwd: repoDir })).resolves.toBe(true); - await expect(Utils.remoteBranchExists("main", { cwd: repoDir })).resolves.toBe(false); - } finally { - rmSync(remoteDir, { recursive: true, force: true }); - } + await expect(Utils.remoteBranchExists("master", { cwd: repoDir })).resolves.toBe(true); + await expect(Utils.remoteBranchExists("main", { cwd: repoDir })).resolves.toBe(false); }); });