From e8d385580181e55d8b01a07afbfc1c51168a70a7 Mon Sep 17 00:00:00 2001 From: Manthan Mallikarjun Date: Wed, 12 Aug 2026 04:58:04 -0700 Subject: [PATCH] Preserve template authors in update commits --- .changeset/traceable-commits.md | 5 + action.yml | 20 ++- action/entrypoint.mjs | 13 ++ action/runtime.mjs | 92 ++++++++++++ action/runtime.test.mjs | 141 +++++++++++++++++- docs/content/docs/cli-reference.mdx | 8 +- docs/content/docs/github-action.mdx | 34 ++++- packages/patchworks/src/update/index.ts | 71 +++++++-- .../patchworks/test/commands/update.test.ts | 1 + .../patchworks/test/update/errors.test.ts | 1 + .../test/update/integration.test.ts | 120 +++++++++++++++ 11 files changed, 487 insertions(+), 19 deletions(-) create mode 100644 .changeset/traceable-commits.md diff --git a/.changeset/traceable-commits.md b/.changeset/traceable-commits.md new file mode 100644 index 0000000..478fe94 --- /dev/null +++ b/.changeset/traceable-commits.md @@ -0,0 +1,5 @@ +--- +"patchworks": patch +--- + +Preserve each template commit author on generated update commits and record the GitHub Action automation separately as the committer, so author-mapped preview systems can resolve the source contributor instead of an unowned bot address. diff --git a/action.yml b/action.yml index 50bc9eb..a481dca 100644 --- a/action.yml +++ b/action.yml @@ -28,11 +28,11 @@ inputs: required: false default: "" author: - description: "Optional commit author in 'Name ' format" + description: "Optional caller-attested commit author override in 'Name ' format; defaults to the template commit author" required: false default: "" committer: - description: "Optional commit committer in 'Name ' format" + description: "Optional commit committer override in 'Name ' format; defaults to the GitHub Actions bot for github.com GITHUB_TOKEN runs" required: false default: "" @@ -135,6 +135,18 @@ runs: PATCHWORKS_WORKSPACE: ${{ github.workspace }} run: node "$GITHUB_ACTION_PATH/action/entrypoint.mjs" collect + - name: Resolve generated commit identity + if: steps.preflight.outputs.skip != 'true' && steps.patchworks.outputs.has_changes == 'true' + id: identity + shell: bash + env: + PATCHWORKS_AUTHOR: ${{ inputs.author }} + PATCHWORKS_COMMITTER: ${{ inputs.committer }} + PATCHWORKS_CUSTOM_TOKEN: ${{ (inputs.token || env.GITHUB_TOKEN || github.token) != github.token }} + PATCHWORKS_SERVER_URL: ${{ github.server_url }} + PATCHWORKS_TEMPLATE_AUTHOR: ${{ steps.patchworks.outputs.template_author }} + run: node "$GITHUB_ACTION_PATH/action/entrypoint.mjs" identity + - name: Check workflow update permissions if: steps.preflight.outputs.skip != 'true' && steps.patchworks.outputs.has_changes == 'true' shell: bash @@ -149,11 +161,11 @@ runs: uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: add-paths: "." - author: ${{ inputs.author || format('{0} <{1}+{0}@users.noreply.github.com>', github.actor, github.actor_id) }} + author: ${{ steps.identity.outputs.author }} base: ${{ inputs.base || github.event.repository.default_branch }} branch: ${{ inputs.branch }} commit-message: ${{ steps.patchworks.outputs.commit_message }} - committer: ${{ inputs.committer || format('{0} <{1}+{0}@users.noreply.github.com>', github.actor, github.actor_id) }} + committer: ${{ steps.identity.outputs.committer }} title: ${{ steps.patchworks.outputs.pr_title }} body: ${{ steps.patchworks.outputs.pr_body }} token: ${{ inputs.token || env.GITHUB_TOKEN || github.token }} diff --git a/action/entrypoint.mjs b/action/entrypoint.mjs index a391073..60ef5fe 100644 --- a/action/entrypoint.mjs +++ b/action/entrypoint.mjs @@ -6,6 +6,7 @@ import { guardWorkflowChanges, parseWorkflowFiles, preflight, + resolveGeneratedCommitIdentity, runUpdate, writeCommandFile, writeError, @@ -62,6 +63,18 @@ async function main() { return; } + case "identity": { + const result = resolveGeneratedCommitIdentity({ + authorOverride: process.env.PATCHWORKS_AUTHOR || "", + committerOverride: process.env.PATCHWORKS_COMMITTER || "", + customToken: process.env.PATCHWORKS_CUSTOM_TOKEN === "true", + serverUrl: process.env.PATCHWORKS_SERVER_URL || "https://github.com", + templateAuthor: process.env.PATCHWORKS_TEMPLATE_AUTHOR || "", + }); + writeCommandFile(process.env.GITHUB_OUTPUT, result); + return; + } + case "guard-workflows": { const result = guardWorkflowChanges({ customToken: process.env.PATCHWORKS_CUSTOM_TOKEN === "true", diff --git a/action/runtime.mjs b/action/runtime.mjs index 922a4e7..fa85f2b 100644 --- a/action/runtime.mjs +++ b/action/runtime.mjs @@ -12,6 +12,10 @@ import { spawnSync } from "node:child_process"; const WORKFLOW_PREFIX = ".github/workflows/"; const NPM_REGISTRY = "https://registry.npmjs.org/"; const MAX_CONFIG_BYTES = 1024 * 1024; +const GITHUB_ACTIONS_BOT_IDENTITY = + "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"; +const PATCHWORKS_AUTOMATION_IDENTITY = + "Patchworks automation "; function cleanApiErrorBody(body) { try { @@ -122,6 +126,84 @@ function reportValue(report, camelCase, snakeCase) { return report[camelCase] ?? report[snakeCase]; } +function hasControlCharacters(value) { + return [...value].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint < 32 || codePoint === 127; + }); +} + +function validateIdentityPart(value, label) { + const normalized = typeof value === "string" ? value.trim() : ""; + if ( + !normalized || + hasControlCharacters(normalized) || + normalized.includes("<") || + normalized.includes(">") + ) { + throw new Error(`${label} is not safe for Git commit attribution`); + } + return normalized; +} + +export function formatGitIdentity(identity, label = "Git identity") { + if (!identity || typeof identity !== "object" || Array.isArray(identity)) { + throw new Error(`${label} must contain a name and email`); + } + const name = validateIdentityPart(identity.name, `${label} name`); + const email = validateIdentityPart(identity.email, `${label} email`); + return `${name} <${email}>`; +} + +export function parseGitIdentity(value, label = "Git identity") { + const normalized = typeof value === "string" ? value.trim() : ""; + const match = /^([^<>]+)<([^<>]+)>$/.exec(normalized); + if (!match) { + throw new Error(`${label} must use the 'Name ' format`); + } + return { + name: validateIdentityPart(match[1], `${label} name`), + email: validateIdentityPart(match[2], `${label} email`), + }; +} + +export function resolveGeneratedCommitIdentity({ + authorOverride = "", + committerOverride = "", + customToken = false, + serverUrl = "https://github.com", + templateAuthor = "", +}) { + const author = authorOverride.trim() || templateAuthor.trim(); + if (!author) { + throw new Error( + "Patchworks could not determine the template commit author. Pass the author input explicitly as 'Name '.", + ); + } + const parsedAuthor = parseGitIdentity( + author, + authorOverride.trim() ? "author input" : "template commit author", + ); + + let committer = committerOverride.trim(); + if (!committer) { + const githubDotCom = serverUrl.replace(/\/+$/, "") === "https://github.com"; + committer = + githubDotCom && !customToken + ? GITHUB_ACTIONS_BOT_IDENTITY + : PATCHWORKS_AUTOMATION_IDENTITY; + } + const parsedCommitter = parseGitIdentity( + committer, + committerOverride.trim() ? "committer input" : "generated committer", + ); + + return { + author: formatGitIdentity(parsedAuthor, "resolved author"), + committer: formatGitIdentity(parsedCommitter, "resolved committer"), + }; +} + function isRejectArtifact(file) { return ( file.endsWith(".rej") || @@ -627,6 +709,15 @@ export function collectReport({ reportPath, workspace, spawn = spawnSync }) { const commitMessage = reportValue(report, "commitMessage", "commit_message"); const prTitle = reportValue(report, "prTitle", "pr_title"); + const reportedTemplateAuthor = reportValue( + report, + "templateAuthor", + "template_author", + ); + const templateAuthor = + reportedTemplateAuthor === null || reportedTemplateAuthor === undefined + ? "" + : formatGitIdentity(reportedTemplateAuthor, "template commit author"); if (hasChanges && (typeof commitMessage !== "string" || !commitMessage.trim())) { throw new Error("Patchworks report is missing commitMessage"); } @@ -668,6 +759,7 @@ export function collectReport({ reportPath, workspace, spawn = spawnSync }) { reject_files: rejectFiles.join("\n"), reject_files_json: JSON.stringify(rejectFiles), status, + template_author: templateAuthor, workflow_changes: workflowFiles.length > 0 ? "true" : "false", workflow_files: workflowFiles.join("\n"), workflow_files_json: JSON.stringify(workflowFiles), diff --git a/action/runtime.test.mjs b/action/runtime.test.mjs index 2487242..1631ad6 100644 --- a/action/runtime.test.mjs +++ b/action/runtime.test.mjs @@ -1,7 +1,9 @@ +import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, + rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -17,6 +19,7 @@ import { parseWorkflowFiles, preflight, readChangedPaths, + resolveGeneratedCommitIdentity, resolvePackageSpec, runUpdate, writeCommandFile, @@ -37,6 +40,23 @@ function outputFile() { return join(directory, "output.txt"); } +function fixtureGit(directory, ...args) { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (key.startsWith("GIT_")) delete env[key]; + } + const result = spawnSync("git", args, { + cwd: directory, + encoding: "utf8", + env, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`); + } + return result.stdout.trimEnd(); +} + function writeConfig(directory, repository) { writeFileSync( join(directory, ".patchworks.json"), @@ -783,14 +803,128 @@ describe("action metadata", () => { const tokenClassification = "${{ (inputs.token || env.GITHUB_TOKEN || github.token) != github.token }}"; - expect(action.split(tokenClassification)).toHaveLength(3); + expect(action.split(tokenClassification)).toHaveLength(4); expect(action).toContain( "PATCHWORKS_SERVER_URL: ${{ github.server_url }}", ); + expect(action).toContain( + "PATCHWORKS_TEMPLATE_AUTHOR: ${{ steps.patchworks.outputs.template_author }}", + ); + expect(action).toContain("author: ${{ steps.identity.outputs.author }}"); + expect(action).toContain( + "committer: ${{ steps.identity.outputs.committer }}", + ); + expect(action).not.toContain("github.actor_id"); expect(action).not.toContain("inputs.token != '' ||"); }); }); +describe("generated commit identity", () => { + const templateAuthor = "Template Author "; + const actionsBot = + "github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>"; + const neutralAutomation = + "Patchworks automation "; + + it("preserves the template author and uses GitHub's bot for GITHUB_TOKEN", () => { + expect( + resolveGeneratedCommitIdentity({ + customToken: false, + serverUrl: "https://github.com", + templateAuthor, + }), + ).toEqual({ author: templateAuthor, committer: actionsBot }); + }); + + it.each([ + ["a custom token", true, "https://github.com"], + ["GitHub Enterprise", false, "https://github.example.com"], + ])("uses a neutral automation committer for %s", (_label, customToken, serverUrl) => { + expect( + resolveGeneratedCommitIdentity({ + customToken, + serverUrl, + templateAuthor, + }), + ).toEqual({ author: templateAuthor, committer: neutralAutomation }); + }); + + it("preserves caller-attested author and committer overrides", () => { + expect( + resolveGeneratedCommitIdentity({ + authorOverride: "Downstream Author ", + committerOverride: "Template App ", + customToken: true, + serverUrl: "https://github.example.com", + templateAuthor, + }), + ).toEqual({ + author: "Downstream Author ", + committer: "Template App ", + }); + }); + + it("rejects missing or unsafe attribution instead of inventing a user", () => { + expect(() => resolveGeneratedCommitIdentity({})).toThrow( + "could not determine the template commit author", + ); + expect(() => + resolveGeneratedCommitIdentity({ + templateAuthor: "Unsafe\nAuthor ", + }), + ).toThrow("template commit author name is not safe"); + expect(() => + resolveGeneratedCommitIdentity({ + authorOverride: "not an identity", + templateAuthor, + }), + ).toThrow("author input must use the 'Name ' format"); + }); + + it("writes the resolved author and committer into a disposable Git commit", () => { + const directory = mkdtempSync(join(tmpdir(), "patchworks-identity-fixture-")); + try { + const identity = resolveGeneratedCommitIdentity({ + customToken: false, + serverUrl: "https://github.com/", + templateAuthor, + }); + const author = /^([^<]+) <([^>]+)>$/.exec(identity.author); + const committer = /^([^<]+) <([^>]+)>$/.exec(identity.committer); + expect(author).not.toBeNull(); + expect(committer).not.toBeNull(); + if (!author || !committer) throw new Error("Resolved identity did not parse"); + + fixtureGit(directory, "init", "--initial-branch=main"); + fixtureGit( + directory, + "-c", + `author.name=${author[1]}`, + "-c", + `author.email=${author[2]}`, + "-c", + `committer.name=${committer[1]}`, + "-c", + `committer.email=${committer[2]}`, + "-c", + "commit.gpgsign=false", + "-c", + "core.hooksPath=/dev/null", + "commit", + "--allow-empty", + "-m", + "Patchworks generated update", + ); + + expect( + fixtureGit(directory, "show", "-s", "--format=%an <%ae> / %cn <%ce>"), + ).toBe(`${templateAuthor} / ${actionsBot}`); + } finally { + rmSync(directory, { force: true, recursive: true }); + } + }); +}); + describe("git status parsing", () => { it("parses rename and copy records, de-duplicates paths, and sorts output", () => { const spawn = vi.fn(() => ({ @@ -867,6 +1001,10 @@ describe("report collection", () => { prTitle: "Update template", rejectFiles: [".patchworks-rejects/bbbbbbb.patch"], status: "conflicts", + templateAuthor: { + email: "author@example.com", + name: "Template Author", + }, }), ); const spawn = vi.fn(() => ({ @@ -883,6 +1021,7 @@ describe("report collection", () => { reject_files_json: '[".patchworks-rejects/bbbbbbb.patch","conflict.txt.rej"]', status: "conflicts", + template_author: "Template Author ", workflow_changes: "true", workflow_files_json: '[".github/workflows/ci.yml"]', }); diff --git a/docs/content/docs/cli-reference.mdx b/docs/content/docs/cli-reference.mdx index 2dac4c5..2b80d4a 100644 --- a/docs/content/docs/cli-reference.mdx +++ b/docs/content/docs/cli-reference.mdx @@ -127,9 +127,11 @@ ancestors are symbolic links. `--report ` writes the update result as JSON after a successful run. The stable engine status is one of `up-to-date`, `updated`, or `conflicts`. The -report also includes the old and new template commits, changed and reject paths, -staged Gitlink paths, workflow-change detection, commit and pull-request text, -rebase state, and warnings. +report also includes the old and new template commits, the raw author identity +from the prepared template commit, changed and reject paths, staged Gitlink +paths, workflow-change detection, commit and pull-request text, rebase state, +and warnings. The GitHub Action preserves that author when it materializes the +downstream update commit. ### Rewritten history diff --git a/docs/content/docs/github-action.mdx b/docs/content/docs/github-action.mdx index f0a74e1..156083b 100644 --- a/docs/content/docs/github-action.mdx +++ b/docs/content/docs/github-action.mdx @@ -81,6 +81,36 @@ template commit. Rewritten template history is not rebased automatically. Run the CLI locally, review the new history, and use `patchworks update --rebase` explicitly. +## Commit attribution + +Patchworks follows [GitHub's author and committer distinction](https://docs.github.com/en/authentication/troubleshooting-commit-signature-verification/using-a-verified-email-address-in-your-gpg-key) +and treats each generated update commit like a cherry-pick: the original template +commit author remains the author, while the automation that materializes the +change is the committer. This keeps the source author attached to their work +without claiming that the person associated with a nightly schedule wrote it. + +On github.com runs that use the repository `GITHUB_TOKEN`, the default committer +is [GitHub's documented `github-actions[bot]` identity](https://github.com/actions/checkout#push-a-commit-using-the-built-in-token). +Runs that use a custom token or another GitHub host default to the deliberately unlinked +`Patchworks automation ` identity because the +action cannot infer whether that credential belongs to a person or GitHub App. +Set `committer` explicitly to an account- or instance-linked automation identity +when you control that credential. The token still determines who pushes the +branch and opens the pull request; it does not determine the Git author or +committer headers. + +The `author` input is a caller-attested override. Use it only when that identity +actually authored or adapted the downstream change. By default, Patchworks uses +the raw name and email from the template commit and rejects unsafe identity +metadata before changing the child working tree. + +This also gives Vercel a GitHub-linked commit author when the template author is +connected to Vercel and has access to the child project. Vercel still applies its +normal author membership, Hobby/Pro, fork, and verified-commit policies, so +attribution alone cannot guarantee a preview for every repository. See +[Vercel's Git deployment rules](https://vercel.com/docs/git) and +[GitHub's commit-email attribution documentation](https://docs.github.com/en/account-and-profile/how-tos/email-preferences/setting-your-commit-email-address). + ## Version selection The generated workflow pins the exact action release matching the Patchworks @@ -147,8 +177,8 @@ other hosts use their own ambient Git credentials and never receive the token. | `patchworks-package` | Package version matching the action revision | Exact npm package spec used for canary testing | | `branch` | `patchworks/update` | Pull-request branch | | `base` | Repository default branch | Pull-request base branch | -| `author` | Triggering actor's no-reply identity | Commit author in `Name ` form | -| `committer` | Triggering actor's no-reply identity | Commit committer in `Name ` form | +| `author` | Template commit author | Caller-attested commit author override in `Name ` form | +| `committer` | `github-actions[bot]` for github.com `GITHUB_TOKEN` runs; neutral Patchworks automation otherwise | Commit committer override in `Name ` form | ## Outputs diff --git a/packages/patchworks/src/update/index.ts b/packages/patchworks/src/update/index.ts index 6281e13..4312f85 100644 --- a/packages/patchworks/src/update/index.ts +++ b/packages/patchworks/src/update/index.ts @@ -177,23 +177,73 @@ async function fetchRef( ); } -async function commitMessage( +type CommitMetadata = { + subject: string; + body: string; + author: { name: string; email: string }; +}; + +function hasUnsafeAuthorCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0) ?? 0; + if ( + codePoint <= 0x1f || + codePoint === 0x7f || + character === "<" || + character === ">" + ) { + return true; + } + } + return false; +} + +function validateTemplateAuthor( + commit: string, + author: { name: string; email: string }, +): void { + const invalidFields = (["name", "email"] as const).filter((field) => { + const value = author[field]; + return value.trim().length === 0 || hasUnsafeAuthorCharacter(value); + }); + if (invalidFields.length === 0) { + return; + } + + throw new Error( + `Template commit ${commit} has unsafe author ${invalidFields.join(" and ")}. Rewrite that template commit with a non-empty author name and email containing no ASCII control characters, "<", or ">", then retry.`, + ); +} + +async function commitMetadata( gitRunner: GitRunner, commit: string, -): Promise<{ subject: string; body: string }> { +): Promise { const result = await gitRunner([ "show", "--no-patch", - "--format=%s%x00%b", + "--format=%an%x00%ae%x00%s%x00%b", commit, ]); - const separator = result.stdout.indexOf("\0"); - if (separator < 0) { - return { subject: result.stdout.trim(), body: "" }; + const nameSeparator = result.stdout.indexOf("\0"); + const emailSeparator = result.stdout.indexOf("\0", nameSeparator + 1); + const subjectSeparator = result.stdout.indexOf("\0", emailSeparator + 1); + if (nameSeparator < 0 || emailSeparator < 0 || subjectSeparator < 0) { + throw new Error( + `Unable to read author metadata for template commit ${commit}`, + ); } + + const author = { + name: result.stdout.slice(0, nameSeparator), + email: result.stdout.slice(nameSeparator + 1, emailSeparator), + }; + validateTemplateAuthor(commit, author); + return { - subject: result.stdout.slice(0, separator).trim(), - body: result.stdout.slice(separator + 1).trim(), + author, + subject: result.stdout.slice(emailSeparator + 1, subjectSeparator).trim(), + body: result.stdout.slice(subjectSeparator + 1).trim(), }; } @@ -217,6 +267,7 @@ function emptyResult(commit: string): PatchworksResult { changedFiles: [], stagedFiles: [], commitMessage: "", + templateAuthor: null, prTitle: "", prBody: "", rejectFiles: [], @@ -237,6 +288,7 @@ export type PatchworksResult = { changedFiles: string[]; stagedFiles: string[]; commitMessage: string; + templateAuthor: { name: string; email: string } | null; prTitle: string; prBody: string; rejectFiles: string[]; @@ -408,7 +460,7 @@ export async function runPatchworksUpdate( `${rebased ? "Rebasing template history" : "Next template commit"}: ${currentCommit.slice(0, 12)} -> ${nextCommit.slice(0, 12)}`, ); - const metadata = await commitMessage(gitRunner, nextCommit); + const metadata = await commitMetadata(gitRunner, nextCommit); const pathspec = ["--", ".", ":(exclude).patchworks.json"]; const [diff, patchPathResult, rawDiff] = await Promise.all([ gitRunner( @@ -540,6 +592,7 @@ export async function runPatchworksUpdate( changedFiles, stagedFiles, commitMessage: commitSummary, + templateAuthor: metadata.author, prTitle, prBody, rejectFiles, diff --git a/packages/patchworks/test/commands/update.test.ts b/packages/patchworks/test/commands/update.test.ts index 27be66b..55b85c9 100644 --- a/packages/patchworks/test/commands/update.test.ts +++ b/packages/patchworks/test/commands/update.test.ts @@ -11,6 +11,7 @@ const updateResult = { changedFiles: [], stagedFiles: [], commitMessage: "", + templateAuthor: null, prTitle: "", prBody: "", rejectFiles: [], diff --git a/packages/patchworks/test/update/errors.test.ts b/packages/patchworks/test/update/errors.test.ts index 8741474..e43e5b5 100644 --- a/packages/patchworks/test/update/errors.test.ts +++ b/packages/patchworks/test/update/errors.test.ts @@ -156,6 +156,7 @@ describe("update preconditions", () => { rejectFiles: [], currentCommit: repositories.firstCommit, nextCommit: repositories.firstCommit, + templateAuthor: null, warnings: [], }); }); diff --git a/packages/patchworks/test/update/integration.test.ts b/packages/patchworks/test/update/integration.test.ts index 9eec547..815c753 100644 --- a/packages/patchworks/test/update/integration.test.ts +++ b/packages/patchworks/test/update/integration.test.ts @@ -66,6 +66,126 @@ describe("Patchworks update integration", () => { ).toBe(""); }); + it("reports the raw template author for the downstream commit", async () => { + const repositories = await fixture(); + const templateAuthor = { + name: "Distinct Template Author", + email: "template-author@patchworks.invalid", + }; + await git(repositories.template, "config", "user.name", templateAuthor.name); + await git( + repositories.template, + "config", + "user.email", + templateAuthor.email, + ); + await writeFile( + path.join(repositories.project, ".mailmap"), + `Mailmap Author ${templateAuthor.name} <${templateAuthor.email}>\n`, + ); + await commitAll(repositories.project, "Add author mailmap fixture"); + await writeFile( + path.join(repositories.template, "hello.txt"), + "version two\n", + "utf8", + ); + const nextCommit = await commitAll( + repositories.template, + "template: authored update", + ); + + const result = await runPatchworksUpdate({ workspace: repositories.project }); + + expect(result.nextCommit).toBe(nextCommit); + expect(result.templateAuthor).toEqual(templateAuthor); + const reportedAuthor = result.templateAuthor; + if (!reportedAuthor) { + throw new Error("Expected the update to report its template author"); + } + + await git(repositories.project, "add", "-A"); + await git( + repositories.project, + "-c", + "user.name=Patchworks automation", + "-c", + "user.email=automation@patchworks.invalid", + "commit", + "--author", + `${reportedAuthor.name} <${reportedAuthor.email}>`, + "-m", + result.commitMessage, + ); + + expect( + await git( + repositories.project, + "show", + "--no-patch", + "--format=%an%x00%ae%x00%cn%x00%ce", + "HEAD", + ), + ).toBe( + [ + templateAuthor.name, + templateAuthor.email, + "Patchworks automation", + "automation@patchworks.invalid", + ].join("\0"), + ); + }); + + it("rejects unsafe template authors before mutating the project", async () => { + const repositories = await fixture(); + const tree = await git( + repositories.template, + "rev-parse", + `${repositories.firstCommit}^{tree}`, + ); + const rawCommitFile = path.join(repositories.root, "unsafe-commit.txt"); + await writeFile( + rawCommitFile, + [ + `tree ${tree}`, + `parent ${repositories.firstCommit}`, + "author Unsafe\tTemplate 0 +0000", + "committer Patchworks Test 0 +0000", + "", + "unsafe author", + "", + ].join("\n"), + "utf8", + ); + const unsafeCommit = await git( + repositories.template, + "hash-object", + "-t", + "commit", + "-w", + rawCommitFile, + ); + await git( + repositories.template, + "update-ref", + "refs/heads/main", + unsafeCommit, + ); + + await expect( + runPatchworksUpdate({ workspace: repositories.project }), + ).rejects.toThrow( + `Template commit ${unsafeCommit} has unsafe author name`, + ); + const config = JSON.parse( + await readFile( + path.join(repositories.project, ".patchworks.json"), + "utf8", + ), + ) as { commit: string }; + expect(config.commit).toBe(repositories.firstCommit); + expect(await git(repositories.project, "status", "--porcelain")).toBe(""); + }); + it("walks merge-heavy templates on the deterministic first-parent path", async () => { const repositories = await fixture(); await git(repositories.template, "checkout", "-b", "feature");