From 626c75ac2e723d6a190653b790a2139262259e6d Mon Sep 17 00:00:00 2001 From: Subham Kumar Das <35267544+lost-particles@users.noreply.github.com> Date: Sun, 24 May 2026 23:01:19 -0400 Subject: [PATCH] fix(code): pre-configure worktree with GitHub noreply when email is private MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2156 When the user has "Keep my email address private" enabled on GitHub, commits authored with their real email get rejected on push with GH007 ("Your push would publish a private email address"). The agent previously had to recover from this error, and its behavior was inconsistent — sometimes it discovered the noreply email and amended the commit, sometimes it fell back to asking the user to either toggle the GitHub setting or paste the noreply address. This fix prevents the rejection from happening in the first place. After creating a worktree, we look up the authenticated GitHub user via `gh api user` and, when their public email is null (privacy enabled), set `user.name` / `user.email` on the worktree to the `+@users.noreply.github.com` form. The config is scoped to the worktree via `extensions.worktreeConfig`, so the user's main repo identity is untouched. The pre-configuration is best-effort: if `gh` is missing, unauthenticated, or the user has a public email, it's a no-op and the existing agent-side recovery still applies. --- .../services/workspace/githubIdentity.test.ts | 171 ++++++++++++++++++ .../main/services/workspace/githubIdentity.ts | 113 ++++++++++++ .../src/main/services/workspace/service.ts | 32 ++++ 3 files changed, 316 insertions(+) create mode 100644 apps/code/src/main/services/workspace/githubIdentity.test.ts create mode 100644 apps/code/src/main/services/workspace/githubIdentity.ts 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), + }); + } + } }