Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions apps/local-server/launch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.");
Expand Down
1 change: 1 addition & 0 deletions apps/local-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"LICENSE",
"NOTICE",
"launch.mjs",
"windows-git.mjs",
"admin-shell-helper.mjs"
],
"license": "MIT"
Expand Down
68 changes: 67 additions & 1 deletion apps/local-server/src/repository-git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> = Promise.resolve();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
136 changes: 136 additions & 0 deletions apps/local-server/test/repository-git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <author@example.invalid>");
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 <local@example.invalid>");
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({
Expand Down
54 changes: 54 additions & 0 deletions apps/local-server/windows-git.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
}
}
Loading
Loading