diff --git a/apps/code/src/main/services/workspace/githubIdentity.test.ts b/apps/code/src/main/services/workspace/githubIdentity.test.ts new file mode 100644 index 000000000..4ca14c620 --- /dev/null +++ b/apps/code/src/main/services/workspace/githubIdentity.test.ts @@ -0,0 +1,171 @@ +import { execFile } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { promisify } from "node:util"; +import { makeLoggerMock } from "@test/loggerMock"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../utils/logger.js", () => makeLoggerMock()); + +import { + applyWorktreeIdentity, + computeNoreplyIdentity, + fetchGitHubUserInfo, +} from "./githubIdentity"; + +const execFileAsync = promisify(execFile); + +describe("computeNoreplyIdentity", () => { + it("returns noreply identity when GitHub user has email privacy enabled", () => { + expect( + computeNoreplyIdentity({ + id: 12345, + login: "octocat", + name: "Octo Cat", + email: null, + }), + ).toEqual({ + name: "Octo Cat", + email: "12345+octocat@users.noreply.github.com", + }); + }); + + it("falls back to login when name is missing", () => { + expect( + computeNoreplyIdentity({ id: 12345, login: "octocat", email: null }), + ).toEqual({ + name: "octocat", + email: "12345+octocat@users.noreply.github.com", + }); + }); + + it("returns null when GitHub user has a public email", () => { + expect( + computeNoreplyIdentity({ + id: 12345, + login: "octocat", + email: "octo@example.com", + }), + ).toBeNull(); + }); + + it("returns null when id or login is missing", () => { + expect( + computeNoreplyIdentity({ + id: 0, + login: "octocat", + email: null, + }), + ).toBeNull(); + expect( + computeNoreplyIdentity({ + id: 12345, + login: "", + email: null, + }), + ).toBeNull(); + }); +}); + +describe("fetchGitHubUserInfo", () => { + it("returns parsed user info when gh api user succeeds", async () => { + const runGh = vi + .fn() + .mockResolvedValue( + '{"id":12345,"login":"octocat","name":"Octo Cat","email":null}', + ); + const result = await fetchGitHubUserInfo({ runGh }); + expect(result).toEqual({ + id: 12345, + login: "octocat", + name: "Octo Cat", + email: null, + }); + expect(runGh).toHaveBeenCalledWith(["api", "user"]); + }); + + it("returns null when gh fails (not installed / not authenticated)", async () => { + const runGh = vi.fn().mockRejectedValue(new Error("gh: command not found")); + expect(await fetchGitHubUserInfo({ runGh })).toBeNull(); + }); + + it("returns null when response is malformed JSON", async () => { + const runGh = vi.fn().mockResolvedValue("not json"); + expect(await fetchGitHubUserInfo({ runGh })).toBeNull(); + }); + + it("returns null when required fields are missing", async () => { + const runGh = vi.fn().mockResolvedValue('{"login":"octocat"}'); + expect(await fetchGitHubUserInfo({ runGh })).toBeNull(); + }); +}); + +describe("applyWorktreeIdentity (integration)", () => { + let mainRepo: string; + let worktreePath: string; + + beforeEach(async () => { + mainRepo = await fs.mkdtemp(path.join(os.tmpdir(), "posthog-id-main-")); + await execFileAsync("git", ["init", "-q"], { cwd: mainRepo }); + await execFileAsync( + "git", + ["config", "user.email", "private@example.com"], + { cwd: mainRepo }, + ); + await execFileAsync("git", ["config", "user.name", "Real Name"], { + cwd: mainRepo, + }); + await execFileAsync("git", ["commit", "--allow-empty", "-m", "init"], { + cwd: mainRepo, + }); + + const worktreeBase = await fs.mkdtemp( + path.join(os.tmpdir(), "posthog-id-wt-"), + ); + worktreePath = path.join(worktreeBase, "wt"); + await execFileAsync("git", ["worktree", "add", "--detach", worktreePath], { + cwd: mainRepo, + }); + }); + + afterEach(async () => { + await fs.rm(mainRepo, { recursive: true, force: true }); + await fs.rm(path.dirname(worktreePath), { recursive: true, force: true }); + }); + + it("sets user.email and user.name only in the worktree, not the main repo", async () => { + await applyWorktreeIdentity(worktreePath, { + name: "Octo Cat", + email: "12345+octocat@users.noreply.github.com", + }); + + const { stdout: worktreeEmail } = await execFileAsync( + "git", + ["config", "user.email"], + { cwd: worktreePath }, + ); + expect(worktreeEmail.trim()).toBe("12345+octocat@users.noreply.github.com"); + + const { stdout: worktreeName } = await execFileAsync( + "git", + ["config", "user.name"], + { cwd: worktreePath }, + ); + expect(worktreeName.trim()).toBe("Octo Cat"); + + // Main repo must retain its original identity. + const { stdout: mainEmail } = await execFileAsync( + "git", + ["config", "--local", "user.email"], + { cwd: mainRepo }, + ); + expect(mainEmail.trim()).toBe("private@example.com"); + const { stdout: mainName } = await execFileAsync( + "git", + ["config", "--local", "user.name"], + { cwd: mainRepo }, + ); + expect(mainName.trim()).toBe("Real Name"); + }); +}); diff --git a/apps/code/src/main/services/workspace/githubIdentity.ts b/apps/code/src/main/services/workspace/githubIdentity.ts new file mode 100644 index 000000000..63a24add1 --- /dev/null +++ b/apps/code/src/main/services/workspace/githubIdentity.ts @@ -0,0 +1,113 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { logger } from "../../utils/logger"; + +const log = logger.scope("workspace-github-identity"); + +const execFileAsync = promisify(execFile); + +export interface GitHubUserInfo { + id: number; + login: string; + name?: string | null; + email?: string | null; +} + +export interface WorktreeIdentity { + name: string; + email: string; +} + +/** + * Resolve a `+@users.noreply.github.com` identity for the user, + * but only when GitHub reports their email as private. When the user has a + * public email there is no GH007 push rejection to avoid, so we leave the + * worktree using whatever email the user has configured. + */ +export function computeNoreplyIdentity( + user: GitHubUserInfo, +): WorktreeIdentity | null { + if (!user.id || !user.login) return null; + if (user.email) return null; + return { + name: user.name?.trim() || user.login, + email: `${user.id}+${user.login}@users.noreply.github.com`, + }; +} + +export interface FetchGitHubUserInfoOptions { + /** Override for tests; defaults to invoking the local `gh` CLI. */ + runGh?: (args: string[]) => Promise; +} + +const GH_API_TIMEOUT_MS = 5_000; + +async function defaultRunGh(args: string[]): Promise { + const { stdout } = await execFileAsync("gh", args, { + timeout: GH_API_TIMEOUT_MS, + }); + return stdout; +} + +/** + * Fetch the authenticated GitHub user via the user's `gh` CLI. Returns null + * if `gh` is not installed, not authenticated, or returns an unexpected + * shape — pre-configuring the noreply is a best-effort optimization and + * must not fail workspace creation. + */ +export async function fetchGitHubUserInfo( + options: FetchGitHubUserInfoOptions = {}, +): Promise { + const runGh = options.runGh ?? defaultRunGh; + let stdout: string; + try { + stdout = await runGh(["api", "user"]); + } catch (error) { + log.debug("gh api user failed; skipping noreply pre-configuration", { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + try { + const parsed = JSON.parse(stdout) as Record; + if (typeof parsed.id !== "number" || typeof parsed.login !== "string") { + return null; + } + return { + id: parsed.id, + login: parsed.login, + name: typeof parsed.name === "string" ? parsed.name : null, + email: typeof parsed.email === "string" ? parsed.email : null, + }; + } catch { + return null; + } +} + +/** + * Set `user.name` and `user.email` on a worktree without affecting the main + * repo. Uses `git config --worktree`, which requires the repo extension + * `extensions.worktreeConfig` to be enabled — we toggle it here. The flag + * is purely an opt-in to per-worktree config storage and has no effect on + * any existing setting. + */ +export async function applyWorktreeIdentity( + worktreePath: string, + identity: WorktreeIdentity, +): Promise { + await execFileAsync( + "git", + ["config", "--local", "extensions.worktreeConfig", "true"], + { cwd: worktreePath }, + ); + await execFileAsync( + "git", + ["config", "--worktree", "user.email", identity.email], + { cwd: worktreePath }, + ); + await execFileAsync( + "git", + ["config", "--worktree", "user.name", identity.name], + { cwd: worktreePath }, + ); +} diff --git a/apps/code/src/main/services/workspace/service.ts b/apps/code/src/main/services/workspace/service.ts index 851b627d4..3b70abfa1 100644 --- a/apps/code/src/main/services/workspace/service.ts +++ b/apps/code/src/main/services/workspace/service.ts @@ -34,6 +34,11 @@ import type { ProcessTrackingService } from "../process-tracking/service"; import type { ProvisioningService } from "../provisioning/service"; import { getWorktreeLocation } from "../settingsStore"; import type { SuspensionService } from "../suspension/service.js"; +import { + applyWorktreeIdentity, + computeNoreplyIdentity, + fetchGitHubUserInfo, +} from "./githubIdentity"; import type { BranchChangedPayload, CreateWorkspaceInput, @@ -644,6 +649,12 @@ export class WorkspaceService extends TypedEventEmitter ); } } + + // Pre-configure the worktree with the user's GitHub noreply identity + // when they have email privacy enabled, so the first `git push` doesn't + // get rejected with GH007. Best-effort: any failure (gh not installed, + // not authenticated, public email, etc.) leaves the worktree untouched. + await this.preconfigureNoreplyIdentity(worktree.worktreePath); } catch (error) { log.error(`Failed to create worktree for task ${taskId}:`, error); throw new Error(`Failed to create worktree: ${String(error)}`); @@ -1231,4 +1242,25 @@ export class WorkspaceService extends TypedEventEmitter ): void { this.emit(WorkspaceServiceEvent.Warning, { taskId, title, message }); } + + private async preconfigureNoreplyIdentity( + worktreePath: string, + ): Promise { + try { + const user = await fetchGitHubUserInfo(); + if (!user) return; + const identity = computeNoreplyIdentity(user); + if (!identity) return; + await applyWorktreeIdentity(worktreePath, identity); + log.info("Pre-configured worktree with GitHub noreply identity", { + worktreePath, + email: identity.email, + }); + } catch (error) { + log.debug("Failed to pre-configure worktree noreply identity", { + worktreePath, + error: error instanceof Error ? error.message : String(error), + }); + } + } }