SLOPSHOPPER

format-oxfmt

No description.

newguardprocess
A shopper browsing a rack in a slop shop
README

format-oxfmt

Formats the files that a Write, an Edit, or a Bash call changed with oxfmt, selected by extension. It runs after each tool call, so the model reads back formatted files and Markdown tables stay aligned while the model edits them.

The hook replaced format-prettier on 2026-10-25. See format-oxfmt replaces format-prettier. The research for the move is in oxfmt-and-prettier-alternatives.md.

One core, core.ts, serves four agents through three adapters. An adapter connects the core to one agent. A mod is a Claude Code hook module that runs in the Claude Code process, and a command hook is a program that the agent starts for each call.

AgentAdapterLoaded by
Claude Codehooks/register.ts, a mod: tool.call on Write, Edit, and Bash formats after nextCLAUDE_CODE_PLUGIN_DIRS in settings.json.tmpl
pipi.ts: tool_result on write, edit, and bashextensions in dot_pi/agent/modify_settings.json
omppi.ts as in pi, and omp.ts: tool_result on an omp edit of more than one file, an ast_edit that a write to xd://resolve applies, and evalextensions in dot_omp/private_agent/private_config.yml
Codexcommand.ts, a command hook: the amend hint as hookSpecificOutput JSON on stdoutPostToolUse on Bash, and on apply_patch through dot_codex/hooks/apply-patch.ts

The git pre-commit hook format-staged-files also runs command.ts, once for each commit. It names all staged files in tool_input.file_paths, a field that no agent payload has.

What it checks

The hook runs after each Write, Edit, and Bash call, also after a call that failed. It tells the calls apart by the file path: a Write or an Edit names one file in file_path, and a Bash call names none.

  • A Write or an Edit: the hook formats the one file that the call names, if oxfmt supports its extension.
  • A Bash call: the hook formats only Markdown files: the files that the command names, and the files that git reports as changed or untracked. See Bash (markdown sweep).
  • omp: omp.ts also formats each file of an edit of more than one file, and each file of an applied ast_edit. It treats the code of an eval call as a Bash command.
  • Codex apply_patch: apply-patch.ts runs command.ts once for each file in the patch, as a Write.

Supported extensions

ExtensionsNote
.md, .markdownNative Markdown formatter since oxfmt 0.72.0
.js, .jsx, .ts, .tsx, .mts, .cts, .mjs, .cjs
.json, .jsonc, .json5
.css, .scss, .less
.yml, .yaml
.toml
.htmloxfmt sends HTML to the Prettier in its npm package

The hook skips any other extension and does not start oxfmt for it. The separate hooks format-org-tables and format-code format .org, Terraform, shell, and Python, because oxfmt has no parser for them. A Bash call formats only .md and .markdown.

Skipped files

  • A file with an extension that is not in the table.
  • A file under /tmp, /var/tmp, or $TMPDIR. See Throwaway files.
  • A file that .prettierignore lists. See Ignore rules.
  • A file whose repository sets agent-hook.format-oxfmt.enabled to false.
  • A path that is not a regular file, or that does not exist.

Requirements

  • oxfmt on PATH, the npm package. The standalone binary from the GitHub release skips .html with exit 0. oxfmt releases each week and has had breaking changes, so install a pinned version and read the release notes before an upgrade. With mise, as on this computer, ~/.config/mise/config.toml holds "npm:oxfmt" = "0.74.0". Without mise:
  pnpm add -g oxfmt@0.74.0
  • git, for the Bash sweep, the repository key, and the amend hint.
  • Node 23.6 or later for command.ts, which Codex and format-staged-files run. With no node, the Codex entry exits 0, and the SessionStart check of Codex shows a warning. The mod and pi.ts need no node of their own.

The hook starts the oxfmt binary directly, as lint-code starts its linters. It never installs oxfmt from the network.

How it works

  1. If AGENT_HOOKS_DISABLE names format-oxfmt, the hook stops.
  2. The hook collects the candidate files: the file of a Write or an Edit, or the Markdown files of the Bash sweep.
  3. It keeps each candidate once, and only if the extension matches, the path is not a throwaway path, and the path is a regular file.
  4. It drops each file whose repository sets agent-hook.format-oxfmt.enabled to false in git config. It runs one git config for each folder, and only for the files that step 3 kept.
  5. It runs oxfmt <files> once, in the session folder. oxfmt finds the config of each file, see Config. The hook stops oxfmt after 20 s.
  6. If a Bash command committed, it compares each file before and after oxfmt and can give the amend hint.

Bash (markdown sweep)

A Bash call has no file_path, but a shell command (sed, perl, echo, a redirect) can change files. Markdown is the most important case: a table that a command changed stays misaligned until the next Edit. So on a Bash call, the hook sweeps the git working tree for Markdown files:

  1. It takes each Markdown path that the command names. See Markdown the command names.
  2. It finds the roots: the session folder and each absolute path in the command, each resolved to its git top level. See Cross-repo Bash edits.
  3. In each root, it lists the Markdown files that changed against HEAD (git diff --name-only --diff-filter=d HEAD) and the untracked files (git ls-files --others --exclude-standard).
  4. If the command commits, it also lists the files of the last commit. See Write and commit in one command.
  5. A folder entry in a list is a repository inside the root, and becomes a root of its own. See Nested repos and submodules.

The sweep is Markdown only. A Write or an Edit formats each supported extension, because the edit is the reason to format. A Bash call does not, because a format of each changed .ts, .css, or .json file after each shell command would interfere with edits that are not complete.

Misaligned Markdown tables are the problem that justifies a sweep of the whole repository. Other files do not. So a non-Markdown file that a shell command writes stays unformatted until an Edit changes it, see The gap that format-staged-files closes.

Markdown the command names

The sweep asks git what changed, so it cannot find a file that git does not report. The hooks run after the full command ends. A command that writes docs/refactor.md and adds it to .gitignore leaves a file that git ls-files --others --exclude-standard does not list.

So before the hook asks git, it takes each .md or .markdown path in the command text and formats it directly. An absolute path stays as it is, and a relative path resolves against the session folder. A file that the command names is a file that the model writes now, so it gets a format pass that the other ignored files do not get.

  • A named path that is not a file drops out at the file check.
  • A named file is formatted also when the session folder is in no repository.
  • A cd in the command does not change the session folder, so a relative path after a cd can resolve to the wrong file. The file check then skips it, or the hook formats a file with the same name that is already formatted.

Cross-repo Bash edits

The session folder of the hook does not change when the command runs cd. A command such as cd /other/repo && cat > UPGRADING.md <<'EOF' writes Markdown in a different repository.

So the sweep has more than one root: the session folder, and each absolute path in tool_input.command. A folder counts as it is, and the hook uses the parent folder of any other path. The hook resolves each root to its git top level and removes duplicates.

  • A path that is not in a repository drops out with no message.
  • A path that does not exist drops out if its parent folder does not exist. If the parent folder is in a repository, that repository becomes a root.
  • A command that names no absolute path sweeps only the session folder.

The hook follows only absolute paths. A relative path is ambiguous after a cd, and a wrong guess sweeps the wrong repository. So the hook misses an edit in a different repository after a relative cd ../other-repo: use an absolute path, or make the edit with a Write or an Edit.

A shortcut: comment in core.ts marks this limit.

One side effect: the hook formats the changed and untracked Markdown of each repository whose absolute path is in a command, also when the command only reads from it. That repository gets the same pass as the session repository, and the hook changes only files that git reports as changed or untracked.

Nested repos and submodules

A git repository inside a repository is opaque to the parent repository, so the sweep of the parent cannot see its Markdown files.

  • An embedded repository shows in git ls-files --others as one nested/ entry, not as the files in it.
  • A submodule shows in git diff HEAD as a gitlink path.
  • A worktree in the repository (git worktree add .worktrees/feat) shows as one .worktrees/feat/ entry, because the folder holds a .git file and no tracked content. This is the most frequent case here.

The roots are a queue. The sweep lists the files of one root and adds each folder entry to the queue as a new root, with its own git top level. For a worktree, git rev-parse --show-toplevel gives the worktree path, so the hook sweeps the worktree as a root of its own.

The sweep continues until the queue is empty, so it covers each nesting depth. The roots are a set, so a symlink that points back to a root does not add that root a second time and cannot cause a loop.

Write and commit in one command

One Bash call can write a file, commit it, and push it: cat > README.md <<'EOF' ... && git commit -am docs && git push. When the hook runs, the working tree is clean, so the working-tree sweep finds no files. The pushed commit holds the unformatted table.

So when the command runs git commit, also with global options as in git -C <dir> commit, the sweep also takes the files of the last commit in each root: git diff-tree --no-commit-id --name-only -r --root --diff-filter=d HEAD. --root makes this work for the first commit of a repository, which has no HEAD~1.

The format changes the file, not the commit. The commit holds the unformatted text, and the working tree is now dirty. Only the model can amend the commit, so the hook gives the model the amend hint, see What the model sees.

In an agent session, the format-staged-files pre-commit hook formats the file before the commit. So the amend hint comes only when that hook skipped the file:

  • A file with unstaged changes.
  • A git commit --no-verify.
  • A computer where git has no node.

Throwaway files

The hook skips a path under /tmp, /var/tmp, or $TMPDIR, for a Write, an Edit, and a Bash call. The scratchpad of the agent is there, and a file that the agent will delete is not project code. lint-code and format-org-tables skip the same paths.

Config

oxfmt does not read .prettierrc. It looks for .oxfmtrc.json, .oxfmtrc.jsonc, oxfmt.config.ts, or oxfmt.config.mts, from the folder of each file up, and the nearest config wins.

With no config, oxfmt uses its defaults: a print width of 100, and it sorts the keys of package.json. Prettier uses 80 and does not sort. So in a project that Prettier formats with no oxfmt config, each file that the hook formats can reflow to 100 columns.

To keep the Prettier style of a project, run oxfmt --migrate prettier in that project, which writes .oxfmtrc.json from a JSON or JS Prettier config. Or turn the hook off there with /toggle-hook --repo format-oxfmt.

proseWrap is preserve by default in oxfmt, so oxfmt keeps the line breaks that the author wrote. A paragraph on one line, as writing.md asks, stays on one line, and oxfmt pads each table also when it is wider than the print width. Why not proseWrap never gives the rejected alternative.

Ignore rules

oxfmt reads .gitignore and .prettierignore in the session folder, but it formats a gitignored file that the command line names. A file that .prettierignore lists stays as it is. So the hook passes no --ignore-path. .prettierignore is a formatting decision, so the hook obeys it, but .gitignore is a version-control decision and does not apply to a file that the hook names.

oxfmt runs in the session folder: $.session.cwd() in the mod, ctx.cwd in pi, and the payload cwd in command.ts. It finds the ignore files from there, so a .prettierignore in a different repository does not apply to an edit in that repository. A --ignore-path to a file that does not exist makes oxfmt exit 1 and format nothing.

What the model sees

In most calls the hook gives the model no output. The model sees the formatted file when it reads the file again. A format directly after a Write can make the copy of the file that the model has stale, see Why after each call, and not Stop.

The amend hint is the only output. It comes when all of these conditions are true:

  • The Bash command runs git commit.
  • oxfmt changed the file. The hook compares the text before and after the oxfmt run.
  • The last commit holds the file (git diff-tree --root HEAD -- <file>).

The hint names each file that it applies to:

oxfmt reformatted markdown after your commit, so the commit holds the unformatted version and the working tree is now dirty: <files>. Fold the formatting into that commit (amend, or a fixup commit if it is already pushed).
AdapterHow the hint reaches the model
Modcontext on the result of the call
pi.ts, omp.tsA text block added after the content of the result
command.tshookSpecificOutput.additionalContext on stdout. hookEventName is the hook_event_name of the payload, or PostToolUse

The hook never blocks a call and never marks a result as an error. command.ts exits 0, also when oxfmt is missing.

What you see

When the hook works, you see only the formatted files. A missing oxfmt, a parse error, and a git error give no message. In Codex, a command.ts that crashes shows as a failed hook, and with no node the SessionStart check shows a warning.

Settings

SettingEffect
AGENT_HOOKS_DISABLEA value that names format-oxfmt, separated by spaces or commas, turns the hook off. The hook reads it on each run. format-staged-files ignores it.
git config agent-hook.format-oxfmt.enabled falseTurns the hook off in one repository, or globally, across sessions, also in format-staged-files. Outside git, or with a value that is not a boolean, the hook stays on.
.oxfmtrc.json and the other oxfmt configsThe style of the project. A file with none gets the oxfmt defaults.
.prettierignoreoxfmt skips the files that it lists.
git config hook.format-staged-files.enabled falseTurns off format-staged-files in one repository. That hook ignores AGENT_HOOKS_DISABLE.

To turn the hook off until you quit the agent, use /toggle-hook in Claude Code, pi, or omp. In Codex, set AGENT_HOOKS_DISABLE when you start Codex.

To turn the hook off in one repository, run /toggle-hook --repo format-oxfmt. In Codex, run git config agent-hook.format-oxfmt.enabled false in your own terminal. The dcg pack local.agent_hook blocks an agent command that writes this key.

To remove the hook from one agent, remove its entry from the file in the "Loaded by" column of the adapter table. In Codex, remove the two entries in dot_codex/modify_hooks.json, one for Bash and one for apply_patch.

Failure behavior

The hook fails open: when it fails, the tool call and its result stay as they are. A formatter changes the layout of working code. It must not reject an edit.

  • oxfmt is missing: the files stay as they are, with no message. No other formatter takes over.
  • oxfmt cannot parse a file: oxfmt exits 2 and leaves that file as it is. It still formats the other files of the run.
  • oxfmt runs for more than 20 s: the core stops oxfmt. Codex stops command.ts after 20 s, the timeout of its entry.
  • git fails (not a repository, or no commits and so no HEAD): the hook ignores the failure and drops the stderr of git, so "not a git repository" does not show. The sweep finds no files from that list and ends with no error.

When the code of the hook throws an error, each adapter fails open:

AdapterOn a failure
ModClaude Code skips a hook that throws, and the result stands.
pi.ts, omp.tsThe handler catches the error and returns nothing.
command.tsNode exits 1, which Codex shows as a failed hook and does not block. A missing oxfmt exits 0.

Testing

test/check.ts runs the hook against real git repositories and the real oxfmt, see docs/testing.md.

Source 2 files
hooks/register.ts 34 lines
1/**
2 * Claude Code mod: after a Write, Edit, or Bash call, formats the files it
3 * changed with oxfmt. A failed Bash call is formatted too: it may still have
4 * written files. Adds the amend hint as context when the call committed a
5 * file that oxfmt then changed. Fails open: a hook that throws is skipped and
6 * the result stands.
7 */
8
9import type { Register } from "claude-code";
10import { format, type Io } from "../core.ts";
11
12export const register: Register = (on) => {
13  on("tool.call", { tool: ["Write", "Edit", "Bash"] }, async ($, e, next) => {
14    const ran = await next(e);
15    if (ran.deny !== undefined) return ran;
16    const cwd = await $.session.cwd();
17    // The engine only takes $ calls spelled at the call site, so each
18    // function of the core is written out here.
19    const io: Io = {
20      stat: (path) => $.fs.stat(path).catch(() => undefined),
21      read: (path) => $.fs.read(path).catch(() => undefined),
22      // Rejects when the program is not installed, or timed out.
23      run: (argv, options) => $.process.run(argv, options).catch(() => undefined),
24    };
25    const call = e.tool === "Bash" ? { command: e.command, cwd } : { filePath: e.file_path, cwd };
26    const hint = await format(call, io, {
27      tmpdir: await $.env.get("TMPDIR"),
28      hooksOff: await $.env.get("AGENT_HOOKS_DISABLE"),
29    });
30    if (!hint) return ran;
31    return { ...ran, context: [...(ran.context ?? []), hint] };
32  });
33};
34
core.ts 213 lines
1/**
2 * The format-oxfmt logic: finds the files a tool call changed, formats them
3 * with oxfmt, and writes the amend hint. Never blocks: a missing oxfmt or a
4 * parse error leaves the files as they are. The adapters pass in the file and
5 * process functions, because a mod reaches them only through `$`.
6 *
7 * Adapters: hooks/register.ts (Claude Code mod), pi.ts (pi and omp),
8 * command.ts (Codex, and the format-staged-files pre-commit hook).
9 * Design notes: README.md.
10 */
11
12export interface Io {
13  /** The kind of a path; undefined if missing. */
14  stat(path: string): Promise<{ kind: "file" | "dir" | "other" } | undefined>;
15  /** The text of a file; undefined if it cannot be read. */
16  read(path: string): Promise<string | undefined>;
17  /** The exit code and stdout; undefined when the program cannot start. */
18  run(
19    argv: string[],
20    options: { cwd: string; timeoutMs?: number },
21  ): Promise<{ exitCode: number; stdout: string } | undefined>;
22}
23
24export interface Settings {
25  /** TMPDIR: files under it are throwaway and not formatted. */
26  tmpdir?: string;
27  /** AGENT_HOOKS_DISABLE: the hooks /toggle-hook turned off. */
28  hooksOff?: string;
29}
30
31/**
32 * The tool call: a Write or Edit names a file, a Bash call a command. The
33 * format-staged-files pre-commit hook names all staged files in filePaths, so
34 * one run takes them all.
35 */
36export interface Call {
37  filePath?: string;
38  filePaths?: string[];
39  command?: string;
40  cwd: string;
41}
42
43/** The extensions oxfmt formats after a Write or an Edit. README "Supported extensions". */
44const EDITED =
45  /\.(md|markdown|js|jsx|ts|tsx|mts|cts|mjs|cjs|json|jsonc|json5|css|scss|less|html|yml|yaml|toml)$/;
46/** The Bash sweep takes Markdown only. README "Bash (markdown sweep)". */
47const MARKDOWN = /\.(md|markdown)$/;
48/** A Markdown path that a command names. */
49const NAMED = /[^\s:;|&"'`()<>=]+\.(?:md|markdown)\b/g;
50/**
51 * An absolute path in a command: a root for the sweep. The `/` starts a word,
52 * so the end of a relative path such as `docs/a.md` does not match.
53 */
54const ABSOLUTE = /(?<![^\s"'`=(;|&<>])\/[^\s:;|&"'`()<>]+/g;
55/**
56 * A command that commits: `git`, then global options such as `-C <dir>`, then
57 * `commit` as a word, in one simple command. `git commit-tree` does not match.
58 */
59const COMMIT = /\bgit[ \t](?:[^;&|\n]*[ \t])?commit(?![\w-])/;
60/** The files of the last commit; --root also answers for the first commit. */
61const LAST_COMMIT = ["diff-tree", "--no-commit-id", "--name-only", "-r", "--root"];
62
63/** Whether a command commits: the sweep then also covers the last commit. */
64const commits = (call: Call) => !call.filePath && COMMIT.test(call.command ?? "");
65
66/** Whether AGENT_HOOKS_DISABLE names the hook. */
67const sessionOff = (settings: Settings) =>
68  (settings.hooksOff ?? "").split(/[\s,]+/).includes("format-oxfmt");
69
70/**
71 * A file under /tmp, /var/tmp, or TMPDIR is a scratch file, not project code.
72 * macOS resolves /tmp to /private/tmp, so both sides drop that prefix.
73 */
74function throwaway(file: string, settings: Settings): boolean {
75  const tmpdir = (settings.tmpdir || "/nonexistent").replace(/^\/private/, "");
76  const plain = file.replace(/^\/private/, "");
77  return plain.startsWith("/tmp/") || plain.startsWith("/var/tmp/") || plain.startsWith(tmpdir);
78}
79
80const parent = (path: string) => path.slice(0, path.lastIndexOf("/")) || "/";
81
82async function toplevel(dir: string, io: Io): Promise<string | undefined> {
83  const ran = await io.run(["git", "rev-parse", "--show-toplevel"], {
84    cwd: dir,
85  });
86  return ran?.exitCode === 0 ? ran.stdout.trim() : undefined;
87}
88
89/**
90 * A boolean git config key as the repository that holds `dir` reads it, with
91 * the global config: "true", "false", or "" when the key is unset, the value
92 * is not a boolean, or git cannot start.
93 */
94async function gitBool(dir: string, key: string, io: Io): Promise<string> {
95  const ran = await io.run(["git", "config", "--type=bool", "--get", key], {
96    cwd: dir,
97  });
98  return ran?.stdout.trim() ?? "";
99}
100
101/**
102 * The Markdown files a Bash call may have changed: the paths the command
103 * names, and the files that git reports as changed or untracked in each
104 * repository the command touches. README "Bash (markdown sweep)".
105 */
106async function sweep(call: Call, io: Io): Promise<string[]> {
107  const command = call.command ?? "";
108  // Named paths come first: git hides an ignored file from the sweep, and a
109  // commit in the same call hides a tracked one.
110  const found = [...command.matchAll(NAMED)].map(([path]) =>
111    path.startsWith("/") ? path : `${call.cwd}/${path}`,
112  );
113  // The roots: the cwd and each absolute path in the command, because a `cd`
114  // in the command never moves the cwd of the hook. shortcut: absolute paths
115  // only, because a relative one is ambiguous after a cd.
116  const roots = new Set<string>();
117  const paths = new Set([call.cwd, ...[...command.matchAll(ABSOLUTE)].map(([path]) => path)]);
118  for (const path of paths) {
119    const dir = (await io.stat(path))?.kind === "dir" ? path : parent(path);
120    const root = await toplevel(dir, io);
121    if (root) roots.add(root);
122  }
123  // A repository inside a repository lists as one folder entry, so each such
124  // entry joins the roots as a root of its own. for...of over a Set visits the
125  // roots that join while it runs.
126  const lists = [
127    ["diff", "--name-only", "-z", "--diff-filter=d", "HEAD"],
128    ["ls-files", "--others", "--exclude-standard", "-z"],
129  ];
130  // A command that writes and commits leaves a clean tree, so the sweep also
131  // takes the files of the last commit. README "Write and commit in one command".
132  if (commits(call)) lists.push([...LAST_COMMIT, "-z", "--diff-filter=d", "HEAD"]);
133  for (const root of roots) {
134    const changed = new Set<string>();
135    for (const args of lists) {
136      // A repository with no commits has no HEAD, so the diff fails and the
137      // untracked files still count.
138      const ran = await io.run(["git", ...args], { cwd: root });
139      if (ran?.exitCode === 0)
140        for (const path of ran.stdout.split("\0")) if (path) changed.add(path);
141    }
142    for (const path of changed) {
143      const full = `${root}/${path}`;
144      if ((await io.stat(full))?.kind !== "dir") found.push(full);
145      else {
146        const nested = await toplevel(full, io);
147        if (nested) roots.add(nested);
148      }
149    }
150  }
151  return found;
152}
153
154/**
155 * The files to format, each once. A Write or Edit: the file it names, by
156 * EDITED. A Bash call: the Markdown files of the sweep. A file whose
157 * repository sets agent-hook.format-oxfmt.enabled to false, as /toggle-hook
158 * --repo does, stays as it is.
159 */
160async function targets(call: Call, io: Io, settings: Settings): Promise<string[]> {
161  const named = call.filePaths ?? (call.filePath ? [call.filePath] : undefined);
162  const candidates = named ?? (await sweep(call, io));
163  const extensions = named ? EDITED : MARKDOWN;
164  const kept: string[] = [];
165  // One git run for each folder: a commit can name many files.
166  const asked = new Map<string, Promise<string>>();
167  for (const file of new Set(candidates)) {
168    if (
169      !extensions.test(file) ||
170      throwaway(file, settings) ||
171      (await io.stat(file))?.kind !== "file"
172    )
173      continue;
174    const dir = parent(file);
175    if (!asked.has(dir)) asked.set(dir, gitBool(dir, "agent-hook.format-oxfmt.enabled", io));
176    if ((await asked.get(dir)) !== "false") kept.push(file);
177  }
178  return kept;
179}
180
181/**
182 * Formats the files a call changed. Returns the amend hint for the model when
183 * the call committed a Markdown file that oxfmt then changed, else undefined.
184 * README "Write and commit in one command".
185 */
186export async function format(call: Call, io: Io, settings: Settings): Promise<string | undefined> {
187  if (sessionOff(settings)) return undefined;
188  const files = await targets(call, io, settings);
189  if (!files.length) return undefined;
190  // The hint names only the files that oxfmt itself changed, so keep the text
191  // from before the run. A commit sweep holds only Markdown.
192  const watched = commits(call) ? files : [];
193  const before = await Promise.all(watched.map((file) => io.read(file)));
194  // oxfmt uses the nearest config of each file, else its defaults. README
195  // "Config".
196  const ran = await io.run(["oxfmt", ...files], {
197    cwd: call.cwd,
198    timeoutMs: 20_000,
199  });
200  if (!ran) return undefined;
201  const dirty: string[] = [];
202  for (const [i, file] of watched.entries()) {
203    if ((await io.read(file)) === before[i]) continue;
204    // Only a file that the last commit holds needs an amend.
205    const held = await io.run(["git", ...LAST_COMMIT, "HEAD", "--", file], {
206      cwd: parent(file),
207    });
208    if (held?.stdout.trim()) dirty.push(file);
209  }
210  if (!dirty.length) return undefined;
211  return `oxfmt reformatted markdown after your commit, so the commit holds the unformatted version and the working tree is now dirty: ${dirty.join(" ")}. Fold the formatting into that commit (amend, or a fixup commit if it is already pushed).`;
212}
213