diff --git a/README.md b/README.md index 4eed135..cf047cf 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,10 @@ yours. `owned` also takes a single key of a shared file, such as `.github/workfl `package.json#devDependencies.lefthook`; repokeeper then leaves that key, and everything under it, alone. Every write command accepts `--dry-run`. +Keep your formatter off the files repokeeper writes: a reformatted copy no longer matches, and +`check` reports it as edited. With Biome (2.6 also formats Markdown and YAML) or Prettier in a Node +repository, `init` and `update` name the new files to exclude in `biome.json` or `.prettierignore`. + `init` applies the whole standard unless you say otherwise: - `repokeeper init --preset essential` writes only the editor settings, `.gitignore` and the CI diff --git a/src/commands/report.ts b/src/commands/report.ts index 36673c9..f12392b 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -90,6 +90,29 @@ export function printResult(io: Io, result: SyncResult): void { const WROTE: string[] = ["create", "write", "adopt"]; +/** Files Biome (2.6 and later) or Prettier would format: a formatted copy no longer matches what check expects. */ +const FORMATTED = /\.(m?js|cjs|json|jsonc|md|ya?ml)$/; + +/** Said when such a file is new: the formatter has to leave repokeeper's files, and its lock, alone. */ +function formatterStep(ctx: ModuleContext, result: SyncResult): string | null { + const jobs = ctx.stacks.filter((stack) => stack.id === "node" && !stack.directory).flatMap((stack) => stack.staged); + const tool = jobs.some((job) => job.name === "node:biome") + ? "Biome" + : jobs.some((job) => job.name === "node:prettier") + ? "Prettier" + : null; + if (!tool) return null; + const paths = result.decisions + .filter((d) => (d.output.kind === "file" || d.output.kind === "seed") && d.action === "create") + .map((d) => d.output.path) + .filter((path) => FORMATTED.test(path)); + if (paths.length === 0) return null; + const why = `${tool} would reformat files repokeeper owns, and \`repokeeper check\` would then report them as edited`; + return tool === "Biome" + ? `${why}; add ${[...paths, ".repokeeper"].map((p) => `"!${p}"`).join(", ")} to files.includes in biome.json` + : `${why}; add ${[...paths, ".repokeeper/"].join(", ")} to .prettierignore`; +} + /** What the user still has to do after a write: install the hooks or the dependencies, renormalize line endings. */ export async function nextSteps(root: string, ctx: ModuleContext, result: SyncResult): Promise { const steps: string[] = []; @@ -127,6 +150,8 @@ export async function nextSteps(root: string, ctx: ModuleContext, result: SyncRe const plan = publishPlan(ctx); if (!("problem" in plan)) steps.push(trustedPublisherStep(ctx, plan.registry)); } + const formatter = formatterStep(ctx, result); + if (formatter) steps.push(formatter); if (wrote(".gitattributes")) { const crlf = await crlfTrackedFiles(root); if (crlf.length > 0) { diff --git a/test/report.test.ts b/test/report.test.ts index 152bc35..016c07a 100644 --- a/test/report.test.ts +++ b/test/report.test.ts @@ -3,7 +3,7 @@ import { nextSteps } from "../src/commands/report.js"; import type { Output } from "../src/model.js"; import type { SyncResult } from "../src/sync/sync.js"; import { TOOL_VERSIONS } from "../src/version.js"; -import { makeContext } from "./helpers.js"; +import { makeContext, nodeResolved } from "./helpers.js"; const hooksFile: Output = { kind: "file", module: "hooks", path: "lefthook.yml", content: "" }; const lefthookRange = (path = "package.json"): Output => ({ @@ -40,7 +40,7 @@ describe("what to do after a write", () => { it("says it once when the hooks are new: installing them is the same step", async () => { const steps = await nextSteps( ".", - makeContext(), + makeContext({ stacks: [nodeResolved({ staged: [] })] }), result({ output: hooksFile, action: "create" }, { output: lefthookRange(), action: "create" }), ); expect(steps).toEqual(["install dependencies; this installs the git hooks"]); @@ -55,3 +55,37 @@ describe("what to do after a write", () => { expect(steps).toEqual([]); }); }); + +describe("telling a formatter to leave repokeeper's files alone", () => { + const file = (path: string): Output => ({ kind: "file", module: "health", path, content: "" }); + const biome = nodeResolved({ + staged: [{ name: "node:biome", glob: "*.{js,json}", run: "npx biome check --write {staged_files}" }], + }); + const created = result( + { output: file("CODE_OF_CONDUCT.md"), action: "create" }, + { output: file("commitlint.config.mjs"), action: "create" }, + { output: file("LICENSE"), action: "create" }, + { output: file("SECURITY.md"), action: "unchanged" }, + ); + + // Biome 2.6 formats Markdown and YAML too; a formatted copy no longer matches, and check reports it as edited. + it("names the new files to exclude in biome.json", async () => { + const steps = await nextSteps(".", makeContext({ stacks: [biome] }), created); + expect(steps).toEqual([ + 'Biome would reformat files repokeeper owns, and `repokeeper check` would then report them as edited; add "!CODE_OF_CONDUCT.md", "!commitlint.config.mjs", "!.repokeeper" to files.includes in biome.json', + ]); + }); + + it("names them for .prettierignore with Prettier", async () => { + const steps = await nextSteps(".", makeContext(), created); + expect(steps).toEqual([ + "Prettier would reformat files repokeeper owns, and `repokeeper check` would then report them as edited; add CODE_OF_CONDUCT.md, commitlint.config.mjs, .repokeeper/ to .prettierignore", + ]); + }); + + it("says nothing without a formatter, or when no such file is new", async () => { + expect(await nextSteps(".", makeContext({ stacks: [nodeResolved({ staged: [] })] }), created)).toEqual([]); + const unchanged = result({ output: file("CODE_OF_CONDUCT.md"), action: "unchanged" }); + expect(await nextSteps(".", makeContext({ stacks: [biome] }), unchanged)).toEqual([]); + }); +});