diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index ec1076a..237512d 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -23,7 +23,7 @@ jobs: env: TURBO_CONCURRENCY: "2" - run: python3 -m unittest discover -s tooling/release -p test_build.py -v - - run: node --test tooling/release/launch.test.mjs + - run: node --test tooling/release/windows-git.test.mjs tooling/release/launch.test.mjs - run: python3 tooling/release/build.py --out "$RUNNER_TEMP/world-packages" - name: Test extracted Linux archive with its bundled runtime run: | @@ -95,8 +95,9 @@ jobs: shell: pwsh run: | $root=Join-Path $env:RUNNER_TEMP 'world-packages' - ./tooling/release/install-smoke.ps1 -Installer "$root\AgentIntersect-World-0.15.0-rc.2-windows-x64-setup.exe" -SmokeTest "$env:GITHUB_WORKSPACE\tooling\release\launch.test.mjs" -Receipt "$root\windows-smoke.json" - $file=Get-Item "$root\AgentIntersect-World-0.15.0-rc.2-windows-x64-setup.exe" + $env:AIW_SMOKE_NO_SYSTEM_GIT='1' + ./tooling/release/install-smoke.ps1 -Installer "$root\AgentIntersect-World-0.15.0-rc.2-windows.2-windows-x64-setup.exe" -SmokeTest "$env:GITHUB_WORKSPACE\tooling\release\launch.test.mjs" -Receipt "$root\windows-smoke.json" + $file=Get-Item "$root\AgentIntersect-World-0.15.0-rc.2-windows.2-windows-x64-setup.exe" ((Get-FileHash $file.FullName -Algorithm SHA256).Hash.ToLower()+' '+$file.Name) | Set-Content "$root\WINDOWS-SHA256SUMS" -Encoding utf8NoBOM - uses: actions/upload-artifact@v7 with: diff --git a/apps/local-server/launch.mjs b/apps/local-server/launch.mjs index 6b950c8..35f117d 100644 --- a/apps/local-server/launch.mjs +++ b/apps/local-server/launch.mjs @@ -9,6 +9,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import { createInterface } from "node:readline"; +import { configureBundledGit } from "./windows-git.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const port = Number(process.env.AIW_APP_PORT || 3771); @@ -96,6 +97,7 @@ for (const signal of ["SIGINT", "SIGTERM"]) void close(); }); try { + await configureBundledGit(resolve(here, "../../runtime/git")); await frontend.listen({ host: "127.0.0.1", port }); await import("./dist/index.js"); if (process.exitCode) throw Error("World backend did not start."); diff --git a/apps/local-server/package.json b/apps/local-server/package.json index 44c336d..dc3a3bf 100644 --- a/apps/local-server/package.json +++ b/apps/local-server/package.json @@ -50,6 +50,7 @@ "LICENSE", "NOTICE", "launch.mjs", + "windows-git.mjs", "admin-shell-helper.mjs" ], "license": "MIT" diff --git a/apps/local-server/src/repository-git.ts b/apps/local-server/src/repository-git.ts index 729dc42..f7eb29e 100644 --- a/apps/local-server/src/repository-git.ts +++ b/apps/local-server/src/repository-git.ts @@ -100,6 +100,24 @@ const Mutation = z.strictObject({ title: z.string().trim().min(1).max(256).optional(), body: z.string().max(8000).optional(), draft: z.boolean().optional(), + identity: z + .strictObject({ + name: z + .string() + .trim() + .min(1) + .max(200) + // eslint-disable-next-line no-control-regex -- reject control characters in commit metadata + .regex(/^[^<>\x00-\x1f\x7f]+$/), + email: z + .string() + .trim() + .min(1) + .max(254) + // eslint-disable-next-line no-control-regex -- reject control characters in commit metadata + .regex(/^[^\s<>\x00-\x1f\x7f]+@[^\s<>\x00-\x1f\x7f]+$/), + }) + .optional(), }); export class RepositoryGitService { #tail: Promise = Promise.resolve(); @@ -175,7 +193,53 @@ export class RepositoryGitService { () => "", ) ).trim() || null; - return { head, branch, changes, commits, remotes, upstream }; + const commitIdentity = await this.readIdentity(cwd); + return { + head, + branch, + changes, + commits, + remotes, + upstream, + commitIdentity, + }; + } + async readIdentity(cwd: string) { + const [name, email, author, committer] = await Promise.all([ + this.git(cwd, ["config", "--get", "user.name"]).catch(() => ""), + this.git(cwd, ["config", "--get", "user.email"]).catch(() => ""), + this.git(cwd, [ + "-c", + "user.useConfigOnly=true", + "var", + "GIT_AUTHOR_IDENT", + ]).catch(() => ""), + this.git(cwd, [ + "-c", + "user.useConfigOnly=true", + "var", + "GIT_COMMITTER_IDENT", + ]).catch(() => ""), + ]); + return { + name: name.trim(), + email: email.trim(), + ready: !!author && !!committer, + }; + } + async prepareIdentity( + cwd: string, + identity?: { name: string; email: string }, + ) { + if (identity) { + await this.git(cwd, ["config", "--local", "user.name", identity.name]); + await this.git(cwd, ["config", "--local", "user.email", identity.email]); + } + if (!(await this.readIdentity(cwd)).ready) + throw new RepositoryIntakeError( + "conflict", + "Set up your commit author in Workbench before creating a checkpoint or commit. No files were staged or committed.", + ); } async github(projectId: string, remote: string, workstreamId?: string) { const cwd = await this.directory(projectId, workstreamId); @@ -387,6 +451,7 @@ export class RepositoryGitService { "conflict", "Review staged files and commit them explicitly first", ); + await this.prepareIdentity(cwd, action.identity); await git(cwd, [ "-c", "commit.gpgsign=false", @@ -502,6 +567,7 @@ export class RepositoryGitService { }), ), ]; + await this.prepareIdentity(cwd, action.identity); await git(cwd, ["--literal-pathspecs", "add", "--", ...selected]); await git(cwd, [ "--literal-pathspecs", diff --git a/apps/local-server/test/repository-git.test.ts b/apps/local-server/test/repository-git.test.ts index a220ae0..d54d5cc 100644 --- a/apps/local-server/test/repository-git.test.ts +++ b/apps/local-server/test/repository-git.test.ts @@ -55,6 +55,142 @@ async function fixture() { url: `/repository-intake/projects/${project.id}/git`, }; } +it("requests a commit author before mutation and saves explicit repository-only identity", async () => { + const f = await fixture(); + // Empty local values mask any developer-machine global identity. + for (const key of ["user.name", "user.email"]) + await exec("git", ["-C", f.repo, "config", "--local", key, ""]); + const beforeConfig = await readFile(join(f.repo, ".git/config"), "utf8"); + const request = { + action: "checkpoint", + expectedHead: null, + expectedBranch: "main", + confirm: true, + }; + const status = await f.server.inject({ method: "GET", url: f.url }); + expect(status.json().data.commitIdentity).toMatchObject({ ready: false }); + const blocked = await f.server.inject({ + method: "POST", + url: f.url, + payload: request, + }); + expect(blocked.statusCode).toBe(409); + expect(blocked.json().error.message).toContain("commit author"); + expect(await readFile(join(f.repo, ".git/config"), "utf8")).toBe( + beforeConfig, + ); + const invalid = await f.server.inject({ + method: "POST", + url: f.url, + payload: { + ...request, + identity: { name: "Test\nInjected", email: "test@example.invalid" }, + }, + }); + expect(invalid.statusCode).toBe(400); + expect(await readFile(join(f.repo, ".git/config"), "utf8")).toBe( + beforeConfig, + ); + const saved = await f.server.inject({ + method: "POST", + url: f.url, + payload: { + ...request, + identity: { + name: "Explicit Test Author", + email: "author@example.invalid", + }, + }, + }); + expect(saved.statusCode).toBe(200); + expect(saved.json().data.status.commitIdentity).toEqual({ + name: "Explicit Test Author", + email: "author@example.invalid", + ready: true, + }); + expect( + ( + await exec("git", ["-C", f.repo, "log", "-1", "--format=%an <%ae>"]) + ).stdout.trim(), + ).toBe("Explicit Test Author "); + expect( + ( + await exec("git", ["-C", f.repo, "config", "--local", "user.email"]) + ).stdout.trim(), + ).toBe("author@example.invalid"); +}); + +it("blocks ordinary commits before staging, preserves global settings, and shares local author with worktrees", async () => { + const f = await fixture(); + const globalConfig = join(f.root, "global.gitconfig"); + await writeFile( + globalConfig, + "[user]\n\tname = Global Fixture\n\temail = global@example.invalid\n", + ); + const env = { + ...process.env, + GIT_CONFIG_GLOBAL: globalConfig, + GIT_CONFIG_NOSYSTEM: "1", + }; + const run: typeof workspaceCommand = async (cwd, executable, args) => + (await exec(executable, [...args], { cwd, env })).stdout; + const service = new RepositoryGitService(async () => f.repo, run); + await exec("git", ["-C", f.repo, "commit", "--allow-empty", "-m", "initial"]); + const head = (await service.status("fixture")).head; + for (const key of ["user.name", "user.email"]) + await exec("git", ["-C", f.repo, "config", "--local", key, ""]); + await writeFile(join(f.repo, "selected.txt"), "selected"); + await writeFile(join(f.repo, "keep.txt"), "untouched"); + const request = { + action: "commit", + expectedHead: head, + expectedBranch: "main", + confirm: true, + files: ["selected.txt"], + message: "Explicit commit", + }; + await expect(service.mutate("fixture", request)).rejects.toThrow( + "commit author", + ); + expect( + (await exec("git", ["-C", f.repo, "diff", "--cached", "--name-only"])) + .stdout, + ).toBe(""); + const identity = { name: "Local Fixture", email: "local@example.invalid" }; + await expect( + service.mutate("fixture", { ...request, expectedHead: null, identity }), + ).rejects.toThrow("HEAD or branch changed"); + expect( + ( + await exec("git", ["-C", f.repo, "config", "--local", "user.name"]) + ).stdout.trim(), + ).toBe(""); + const saved = await service.mutate("fixture", { ...request, identity }); + expect(saved.status.changes.map((file) => file.path)).toEqual(["keep.txt"]); + expect( + ( + await exec("git", ["-C", f.repo, "log", "-1", "--format=%an <%ae>"]) + ).stdout.trim(), + ).toBe("Local Fixture "); + expect(await readFile(globalConfig, "utf8")).toBe( + "[user]\n\tname = Global Fixture\n\temail = global@example.invalid\n", + ); + const worktree = join(f.root, "worktree"); + await exec("git", [ + "-C", + f.repo, + "worktree", + "add", + "-b", + "feature", + worktree, + ]); + expect(await service.readIdentity(worktree)).toEqual({ + ...identity, + ready: true, + }); +}); + it("persists explicit checkpoints and commits as project milestones without auto-committing dirty work", async () => { const f = await fixture(); const checkpoint = await f.server.inject({ diff --git a/apps/local-server/windows-git.mjs b/apps/local-server/windows-git.mjs new file mode 100644 index 0000000..e632906 --- /dev/null +++ b/apps/local-server/windows-git.mjs @@ -0,0 +1,54 @@ +import { access, copyFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execute = promisify(execFile); + +// Process-local only: child agents/terminals inherit Git; Windows/user PATH and +// Git identity/configuration are never modified. Source checkouts have no bundle. +export async function configureBundledGit( + root, + env = process.env, + platform = process.platform, +) { + if (platform !== "win32") return; + try { + await access(root); + } catch (error) { + if (error.code === "ENOENT") return; + throw error; + } + await access(join(root, "cmd", "git.exe")); + await access(join(root, "bin", "bash.exe")); + const pathKey = + Object.keys(env).find((key) => key.toLowerCase() === "path") || "Path"; + env[pathKey] = [join(root, "cmd"), join(root, "bin"), env[pathKey]] + .filter(Boolean) + .join(";"); + env.CLAUDE_CODE_GIT_BASH_PATH ||= join(root, "bin", "bash.exe"); + let needsSetup = false; + try { + await access(join(root, "post-install.bat")); + needsSetup = true; + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + if (needsSetup) { + // Upstream requires this after manual extraction. It initializes only this + // private Git tree and removes its own script. Run on the target, not builder. + // Upstream deletes post-install.bat. Execute identical bytes under a temporary + // filename so cmd does not fail reopening the self-deleted executing script. + const script = `world-post-install-${process.pid}.bat`; + await copyFile(join(root, "post-install.bat"), join(root, script)); + try { + await execute( + join(root, "git-bash.exe"), + ["--no-needs-console", "--hide", "--no-cd", `--command=${script}`], + { cwd: root, env, windowsHide: true, timeout: 30000 }, + ); + } finally { + await rm(join(root, script), { force: true }); + } + } +} diff --git a/apps/web/src/world-entry/GitAuthorDialog.tsx b/apps/web/src/world-entry/GitAuthorDialog.tsx new file mode 100644 index 0000000..afe9a49 --- /dev/null +++ b/apps/web/src/world-entry/GitAuthorDialog.tsx @@ -0,0 +1,108 @@ +import { useEffect, useId, useRef, useState } from "react"; +import type { GitIdentity } from "./repository-workbench-client.js"; +import "./repository-workbench.css"; + +export function GitAuthorDialog({ + initial, + busy, + error, + onSave, + onCancel, +}: { + initial: GitIdentity; + busy: boolean; + error: string | null; + onSave: (identity: GitIdentity) => Promise; + onCancel: () => void; +}) { + const dialog = useRef(null); + const titleId = useId(); + const descriptionId = useId(); + const [name, setName] = useState(initial.name); + const [email, setEmail] = useState(initial.email); + const valid = + !!name.trim() && + // eslint-disable-next-line no-control-regex -- match server commit metadata validation + !/[<>\x00-\x1f\x7f]/.test(name) && + // eslint-disable-next-line no-control-regex -- match server commit metadata validation + /^[^\s<>\x00-\x1f\x7f]+@[^\s<>\x00-\x1f\x7f]+$/.test(email.trim()); + useEffect(() => { + const element = dialog.current; + element?.showModal(); + return () => element?.close(); + }, []); + return ( + event.stopPropagation()} + onCancel={(event) => { + event.preventDefault(); + if (!busy) onCancel(); + }} + > +
{ + event.preventDefault(); + if (valid && !busy) + void onSave({ name: name.trim(), email: email.trim() }); + }} + > +

LOCAL GIT SETUP

+

Set up your commit author

+

+ Used to label your local commits. This does not sign you in or publish + anything. +

+
+ + +
+

+ Saved for this repository only, including its + Workstreams. Your computer-wide Git settings stay unchanged. +

+

+ This name and email become part of commit history and are visible to + others if you publish it. You can use your GitHub-provided private + commit email. +

+ {error ?

{error}

: null} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/world-entry/RepositoryGitPanel.tsx b/apps/web/src/world-entry/RepositoryGitPanel.tsx index 1ddf935..2fd1f6b 100644 --- a/apps/web/src/world-entry/RepositoryGitPanel.tsx +++ b/apps/web/src/world-entry/RepositoryGitPanel.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from "react"; +import { GitAuthorDialog } from "./GitAuthorDialog.js"; import { repositoryGitAction, repositoryGitStatus, repositoryGithubStatus, type GitAction, + type GitIdentity, type GitStatus, type PullRequest, } from "./repository-workbench-client.js"; @@ -50,6 +52,7 @@ export function RepositoryGitPanel({ error: string | null; } | null>(null); const [pending, setPending] = useState(null); + const [authorPrompt, setAuthorPrompt] = useState(false); const [tab, setTab] = useState<"changes" | "commits" | "sync" | "github">( "changes", ); @@ -110,8 +113,17 @@ export function RepositoryGitPanel({ ]); const github = githubRead?.key === githubKey ? githubRead : null; const prs = github?.prs ?? null; - const confirm = async () => { + const confirm = async (identity?: GitIdentity) => { if (!pending || !status || !gitCurrent || busy) return; + if ( + !identity && + (pending.action === "commit" || pending.action === "checkpoint") && + status.commitIdentity?.ready === false + ) { + setError(null); + setAuthorPrompt(true); + return; + } setBusy(true); onBusyChange?.(true); setError(null); @@ -121,7 +133,7 @@ export function RepositoryGitPanel({ projectId, workstreamId, status, - pending, + { ...pending, ...(identity ? { identity } : {}) }, ); setGitRead({ projectId, @@ -157,6 +169,7 @@ export function RepositoryGitPanel({ setGithubRead(null); setVersion((value) => value + 1); } finally { + setAuthorPrompt(false); setBusy(false); onBusyChange?.(false); } @@ -192,6 +205,15 @@ export function RepositoryGitPanel({ }; return (
+ {authorPrompt && status?.commitIdentity ? ( + setAuthorPrompt(false)} + /> + ) : null}

Git workspace