/*
RFC-0560: Ed25519 signed commit helper for mission workpiece git commits.
- Does not verify signatures — verification is a future RFC.
- Does not handle GPG signing — uses Ed25519 via @warpgogol/werkstatt-shared/passport signBytes.
- RFC-0560: initial signed commit helper using Ed25519 via @warpgogol/werkstatt-shared/passport.
- RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared
- ADR-0104: scopeFiles parameter — path-limited commits via git commit --only on both the initial and amended commit
*/
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
import path from "node:path";
import { signBytes } from "@warpgogol/werkstatt-shared/passport";
export interface SignedCommitResult {
commitSha: string;
signed: boolean;
actorId: string | null;
signature: string | null;
}
async function git(cwd: string, args: string[], io: WorkspaceIO): Promise {
const result = await io.exec("git", args, {
cwd,
env: { MISSION_GIT_COMMIT: "1" },
});
if (result.exitCode !== 0) {
throw new Error(
`git ${args[0] ?? ""} failed (exit ${result.exitCode}): ${result.stderr.trim()}`,
);
}
return result.stdout.trim();
}
export async function createSignedCommit(
workpieceDir: string,
message: string,
actorId: string,
privateKeyHex: string,
// ADR-0104: when set, the commit is path-limited — only the named paths are
// staged and `git commit --only` ignores the rest of the index.
scopeFiles?: string[],
io: WorkspaceIO = createDefaultIO().io,
): Promise {
if (!(await io.exists(path.join(workpieceDir, ".git")))) {
throw new Error(
`[createSignedCommit] workpiece is not a git repository — run mission.materialize first`,
);
}
const scope = scopeFiles?.length ? scopeFiles : undefined;
if (scope) {
await git(workpieceDir, ["add", "--", ...scope], io);
} else {
await git(workpieceDir, ["add", "-A"], io);
}
// ADR-0104: `commit --only` takes the named paths' worktree state — check
// worktree-vs-HEAD inside the scope, not the whole index.
let hasChanges: boolean;
try {
await git(
workpieceDir,
scope ? ["diff", "--quiet", "HEAD", "--", ...scope] : ["diff", "--cached", "--quiet"],
io,
);
hasChanges = false;
} catch {
hasChanges = true;
}
if (!hasChanges) {
return {
commitSha: await git(workpieceDir, ["rev-parse", "HEAD"], io),
signed: false,
actorId: null,
signature: null,
};
}
await git(
workpieceDir,
scope ? ["commit", "--only", "-m", message, "--", ...scope] : ["commit", "-m", message],
io,
);
const preAmendSha = await git(workpieceDir, ["rev-parse", "HEAD"], io);
const signature = await signBytes(privateKeyHex, new TextEncoder().encode(preAmendSha));
const amendedMessage = `${message}\n\nWerkstatt-Actor: ${actorId}\nWerkstatt-Signature: ${signature}`;
await git(
workpieceDir,
scope
? ["commit", "--amend", "--only", "-m", amendedMessage, "--", ...scope]
: ["commit", "--amend", "-m", amendedMessage],
io,
);
const postAmendSha = await git(workpieceDir, ["rev-parse", "HEAD"], io);
return {
commitSha: postAmendSha,
signed: true,
actorId,
signature,
};
}