From ef4ec8dbe96b8cac8a158aed5ce035a4e69bf852 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 23:00:03 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20currentAuthor()=20carries=20git=20user.?= =?UTF-8?q?email=20=E2=80=94=20fail-open=20identity=20(F5=20SLICE-2,=20#14?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/attributionController.ts | 4 +++- src/identity.ts | 25 +++++++++++++++++++++++++ src/threadController.ts | 4 +++- test/identity.test.ts | 25 +++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 src/identity.ts create mode 100644 test/identity.test.ts diff --git a/src/attributionController.ts b/src/attributionController.ts index dd74390..990e310 100644 --- a/src/attributionController.ts +++ b/src/attributionController.ts @@ -13,6 +13,7 @@ import * as vscode from "vscode"; import { CoauthorStore } from "./store"; import { newId, type AttributionRecord, type Provenance } from "./model"; import { buildFingerprint, resolve, type OffsetRange } from "./anchorer"; +import { gitUserEmail } from "./identity"; import { applyChange, coalesce, type LiveSpan } from "./attributionTracker"; import { minimizeReplace, PendingEditRegistry } from "./pendingEdits"; @@ -79,7 +80,8 @@ export class AttributionController implements vscode.Disposable { } private currentAuthor(): Provenance { const id = vscode.workspace.getConfiguration("git").get("user.name") || process.env.USER || "human"; - return { kind: "human", id }; + const email = gitUserEmail(this.rootDir); + return { kind: "human", id, ...(email !== undefined ? { email } : {}) }; } private state(docPath: string): DocAttribution { let s = this.docs.get(docPath); diff --git a/src/identity.ts b/src/identity.ts new file mode 100644 index 0000000..b1d8289 --- /dev/null +++ b/src/identity.ts @@ -0,0 +1,25 @@ +/** + * Cross-rung identity (F5, spec §6.3 fork c): `email` is git's own join key — + * forges already map email → account. Resolved from the workspace git config, + * cached per root, FAIL-OPEN: absence just omits the optional field and the + * sidecar stays valid (spec §6.9). vscode-free, so it is unit-testable. + */ +import { execFileSync } from "node:child_process"; + +const cache = new Map(); + +export function gitUserEmail(rootDir: string): string | undefined { + if (!cache.has(rootDir)) { + try { + const out = execFileSync("git", ["config", "user.email"], { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + cache.set(rootDir, out || undefined); + } catch { + cache.set(rootDir, undefined); + } + } + return cache.get(rootDir); +} diff --git a/src/threadController.ts b/src/threadController.ts index d0cf898..b929cf2 100644 --- a/src/threadController.ts +++ b/src/threadController.ts @@ -11,6 +11,7 @@ import * as vscode from "vscode"; import { CoauthorStore } from "./store"; import { emptyArtifact, type Artifact, type Provenance } from "./model"; import { buildFingerprint, resolve, shift, type OffsetRange } from "./anchorer"; +import { gitUserEmail } from "./identity"; import { addThread, appendMessage, setStatus } from "./threadModel"; /** Test-facing snapshot of what is currently rendered for a document. */ @@ -72,7 +73,8 @@ export class ThreadController implements vscode.Disposable { private currentAuthor(): Provenance { const id = vscode.workspace.getConfiguration("git").get("user.name") || process.env.USER || "human"; - return { kind: "human", id }; + const email = gitUserEmail(this.rootDir); + return { kind: "human", id, ...(email !== undefined ? { email } : {}) }; } private persist(state: DocState): void { diff --git a/test/identity.test.ts b/test/identity.test.ts new file mode 100644 index 0000000..7e68791 --- /dev/null +++ b/test/identity.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { execFileSync } from "node:child_process"; +import { gitUserEmail } from "../src/identity"; + +let repo: string; + +beforeAll(() => { + repo = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-identity-")); + execFileSync("git", ["init"], { cwd: repo, stdio: "ignore" }); + execFileSync("git", ["config", "user.email", "rung@example.org"], { cwd: repo }); +}); +afterAll(() => fs.rmSync(repo, { recursive: true, force: true })); + +describe("gitUserEmail (F5 SLICE-2)", () => { + it("reads the workspace git config user.email", () => { + expect(gitUserEmail(repo)).toBe("rung@example.org"); + }); + + it("fails open (undefined) when git cannot run there", () => { + expect(gitUserEmail("/nonexistent-dir-xyz")).toBeUndefined(); + }); +});