repokeeper 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,22 @@ repokeeper update # move to the latest standard without overwriting your edits
28
28
 
29
29
  `init` never overwrites a file you already have: it reports it as unmanaged. Pass
30
30
  `--adopt <path>` to let repokeeper manage it, or list it under `owned` in `.repokeeper.yml` to keep it
31
- yours. Every write command accepts `--dry-run`.
31
+ yours. `owned` also takes a single key of a shared file, such as `.github/workflows/ci.yml#on` or
32
+ `package.json#devDependencies.lefthook`; repokeeper then leaves that key, and everything under it,
33
+ alone. Every write command accepts `--dry-run`.
34
+
35
+ `init` reads the default branch from `origin/HEAD` and records it as `github.default_branch` when it
36
+ isn't `main`. The release manifest starts from the latest `vX.Y.Z` tag when the stack has no version
37
+ of its own.
38
+
39
+ After writing, repokeeper prints what is left to do (installing the git hooks, and
40
+ `git add --renormalize .` when tracked files are stored with CRLF). `init`, `update` and `check` also
41
+ point out what they can't fix: `.gitattributes` lines above the repokeeper block that the block
42
+ overrides, and pull request workflows of your own that run the same tests as the repokeeper ci job.
43
+
44
+ Stack notes: Python runs `mypy` on the files its config names (`files = …`), or on the whole tree
45
+ otherwise. The dotnet stack needs SDK-style projects; `init` stops with the names of .NET Framework
46
+ projects, which the dotnet CLI can't build.
32
47
 
33
48
  Requires Node.js 22.12 or newer.
34
49
 
@@ -37,7 +52,18 @@ Requires Node.js 22.12 or newer.
37
52
  `ci.yml` and `release.yml` call reusable workflows from this repository (`stack-node.yml`,
38
53
  `commitlint.yml`, `release-please.yml`) at the moving major tag, so fixes reach every repository
39
54
  without a pull request. repokeeper owns the `name`, `on` and `permissions` keys and the jobs it
40
- adds; jobs you add yourself are left alone.
55
+ adds; jobs you add yourself are left alone, and so is the formatting of the rest of the file.
56
+
57
+ The script stack runs ShellCheck and `shfmt -d` on `*.sh` (format with `shfmt -w` before pushing)
58
+ and PSScriptAnalyzer on `*.ps1`, which fails on errors and warnings (not on information-level rules). To
59
+ choose the rules yourself, add a `PSScriptAnalyzerSettings.psd1` at the repository root; the job then uses it:
60
+
61
+ ```powershell
62
+ @{
63
+ # installers print for the person running them
64
+ ExcludeRules = @('PSAvoidUsingWriteHost')
65
+ }
66
+ ```
41
67
 
42
68
  `release.yml` runs [release-please](https://github.com/googleapis/release-please): it keeps a release
43
69
  pull request open, and merging it tags the release and updates `CHANGELOG.md`. Two settings make this
@@ -6,7 +6,7 @@ import { readLock } from "../sync/lock.js";
6
6
  import { computeSync } from "../sync/sync.js";
7
7
  import { compareVersions, STANDARD_VERSION } from "../version.js";
8
8
  import { buildContext } from "./context.js";
9
- import { hasDrift, printResult } from "./report.js";
9
+ import { hasDrift, printResult, printWarnings } from "./report.js";
10
10
  export function assertSupportedStandard(standard) {
11
11
  if (compareVersions(standard, STANDARD_VERSION) > 0) {
12
12
  throw new UsageError(`this repository uses standard ${standard}, newer than ${STANDARD_VERSION}; upgrade repokeeper`);
@@ -21,7 +21,11 @@ export async function checkCommand(root, options, io) {
21
21
  return 1;
22
22
  }
23
23
  const ctx = await buildContext(root, config);
24
- const result = await computeSync(root, planOutputs(ctx), lock, { adopt: new Set(), accept: new Set() });
24
+ const result = await computeSync(root, planOutputs(ctx), lock, {
25
+ adopt: new Set(),
26
+ accept: new Set(),
27
+ owned: config.owned,
28
+ });
25
29
  const behind = config.standard !== STANDARD_VERSION || lock.standard !== STANDARD_VERSION;
26
30
  const clean = !hasDrift(result) && !behind;
27
31
  if (options.json) {
@@ -38,6 +42,7 @@ export async function checkCommand(root, options, io) {
38
42
  return clean ? 0 : 1;
39
43
  }
40
44
  printResult(io, result);
45
+ await printWarnings(root, ctx, io);
41
46
  if (behind)
42
47
  io.out(`standard ${config.standard} applied, ${STANDARD_VERSION} available (run \`repokeeper update\`)`);
43
48
  io.out(clean ? "repository matches the standard" : "repository has drifted from the standard");
@@ -4,7 +4,7 @@ import { join } from "node:path";
4
4
  import { CONFIG_FILE, loadConfig, renderConfig } from "../config/load.js";
5
5
  import { defaultConfig } from "../config/types.js";
6
6
  import { UsageError } from "../errors.js";
7
- import { dirtyPaths, gitUserName, repoInfo } from "../git.js";
7
+ import { dirtyPaths, gitUserName, remoteDefaultBranch, repoInfo } from "../git.js";
8
8
  import { outputId } from "../model.js";
9
9
  import { planOutputs } from "../plan.js";
10
10
  import { detectStacks } from "../stacks/index.js";
@@ -15,11 +15,27 @@ import { readCurrent } from "../sync/state.js";
15
15
  import { computeSync, pathsToWrite } from "../sync/sync.js";
16
16
  import { STANDARD_VERSION } from "../version.js";
17
17
  import { buildContext } from "./context.js";
18
- import { printResult } from "./report.js";
19
- export async function guardUncommitted(root, result, force) {
18
+ import { nextSteps, printResult, printWarnings } from "./report.js";
19
+ /** A dirty path is still safe to write when every part repokeeper manages in it is exactly what it last wrote. */
20
+ async function onlyRepokeeperChanges(root, path, lock) {
21
+ const entries = lock?.entries.filter((e) => e.target.path === path) ?? [];
22
+ if (entries.length === 0)
23
+ return false;
24
+ for (const entry of entries) {
25
+ const current = await readCurrent(root, entry.target);
26
+ if (current === null || hashText(current) !== entry.hash)
27
+ return false;
28
+ }
29
+ return true;
30
+ }
31
+ export async function guardUncommitted(root, result, force, lock = null) {
20
32
  if (force)
21
33
  return;
22
- const dirty = await dirtyPaths(root, pathsToWrite(result));
34
+ const dirty = [];
35
+ for (const d of await dirtyPaths(root, pathsToWrite(result))) {
36
+ if (!(await onlyRepokeeperChanges(root, d.path, lock)))
37
+ dirty.push(d);
38
+ }
23
39
  if (dirty.length === 0)
24
40
  return;
25
41
  const names = dirty.map((d) => (d.untracked ? `${d.path} (untracked)` : d.path)).join(", ");
@@ -46,6 +62,9 @@ export async function initCommand(root, options, io) {
46
62
  contact: repo.owner ? `https://github.com/${repo.owner}` : "the repository maintainers",
47
63
  codeowners: repo.owner ? [`@${repo.owner}`] : [],
48
64
  });
65
+ const branch = await remoteDefaultBranch(root);
66
+ if (branch && branch !== "main")
67
+ config.github = { default_branch: branch };
49
68
  const ctx = await buildContext(root, config, repo);
50
69
  const adopt = options.adoptAll ? "all" : new Set(options.adopt);
51
70
  const result = await computeSync(root, planOutputs(ctx), null, { adopt, accept: new Set() });
@@ -59,7 +78,10 @@ export async function initCommand(root, options, io) {
59
78
  await writeFile(join(root, CONFIG_FILE), renderConfig(config));
60
79
  await applySync(root, result, null, STANDARD_VERSION);
61
80
  io.out(`applied standard ${STANDARD_VERSION}; wrote ${CONFIG_FILE}`);
62
- io.out(`next: install dependencies (this installs the git hooks), then commit with "chore(repokeeper): apply standard ${STANDARD_VERSION}"`);
81
+ await printWarnings(root, ctx, io);
82
+ for (const step of await nextSteps(root, ctx, result))
83
+ io.out(`next: ${step}`);
84
+ io.out(`next: commit with "chore(repokeeper): apply standard ${STANDARD_VERSION}"`);
63
85
  return 0;
64
86
  }
65
87
  /** Records the current content of every planned output as repokeeper's own, rebuilding a lost or corrupt lock. */
@@ -1,7 +1,25 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { duplicateTestRuns } from "../duplicates.js";
4
+ import { crlfTrackedFiles } from "../git.js";
1
5
  import { describeOutput } from "../model.js";
6
+ import { overriddenAttributes } from "../sync/attributes.js";
7
+ import { TOOL_VERSIONS } from "../version.js";
8
+ /** How to name the output under `owned`: the path, or `path#key` for one key of a shared file. */
9
+ function ownedName(output) {
10
+ if (output.kind === "yaml" || output.kind === "json")
11
+ return `"${output.path}#${output.keyPath.join(".")}"`;
12
+ if (output.kind === "block")
13
+ return `"${output.path}#${output.id}"`;
14
+ return "it";
15
+ }
2
16
  const HINTS = {
3
- conflict: (p) => `edited locally; take repokeeper's version with --accept ${p}, or add it to owned in .repokeeper.yml`,
4
- unmanaged: (p) => `exists and is not managed; let repokeeper manage it with --adopt ${p}, or add it to owned`,
17
+ conflict: (o) => `edited locally; take repokeeper's version with --accept ${o.path}, or add ${ownedName(o)} to owned in .repokeeper.yml`,
18
+ unmanaged: (o) => `exists and is not managed; let repokeeper manage it with --adopt ${o.path}, or add ${ownedName(o)} to owned`,
19
+ };
20
+ const REMOVAL_NOTES = {
21
+ "orphan-edited": " (no longer generated, but edited locally; left in place)",
22
+ left: " (no longer generated; left for the tools that use it, delete it if none do)",
5
23
  };
6
24
  export function printResult(io, result) {
7
25
  let unchanged = 0;
@@ -10,18 +28,51 @@ export function printResult(io, result) {
10
28
  unchanged++;
11
29
  continue;
12
30
  }
13
- const hint = HINTS[action]?.(output.path);
31
+ const hint = HINTS[action]?.(output);
14
32
  io.out(`${action.padEnd(10)} ${describeOutput(output)}${hint ? ` (${hint})` : ""}`);
15
33
  }
16
34
  for (const { entry, action } of result.removals) {
17
- if (action === "gone")
35
+ if (action === "gone" || action === "release")
18
36
  continue;
19
- const label = action === "delete" ? "delete" : "kept";
20
- const note = action === "delete" ? "" : " (no longer generated, but edited locally; left in place)";
37
+ const label = action === "orphan-edited" ? "kept" : action;
38
+ const note = REMOVAL_NOTES[action] ?? "";
21
39
  io.out(`${label.padEnd(10)} ${entry.target.path}${note}`);
22
40
  }
23
41
  io.out(`${unchanged} output(s) already match the standard`);
24
42
  }
43
+ const WROTE = ["create", "write", "adopt"];
44
+ /** What the user still has to do after a write: install the hooks, renormalize line endings. */
45
+ export async function nextSteps(root, ctx, result) {
46
+ const steps = [];
47
+ const wrote = (path) => result.decisions.some((d) => d.output.path === path && WROTE.includes(d.action));
48
+ if (ctx.config.modules.hooks && wrote("lefthook.yml")) {
49
+ steps.push(ctx.stacks.some((s) => s.id === "node")
50
+ ? "install dependencies; this installs the git hooks"
51
+ : `run \`npx --yes lefthook@${TOOL_VERSIONS.lefthook} install\` to enable the git hooks`);
52
+ }
53
+ if (wrote(".gitattributes")) {
54
+ const crlf = await crlfTrackedFiles(root);
55
+ if (crlf.length > 0) {
56
+ const files = crlf.length === 1 ? "1 file is" : `${crlf.length} files are`;
57
+ steps.push(`${files} stored with CRLF line endings; run \`git add --renormalize .\` so they follow .gitattributes`);
58
+ }
59
+ }
60
+ return steps;
61
+ }
62
+ /** Problems repokeeper can't fix itself; printed, but not counted as drift. */
63
+ export async function printWarnings(root, ctx, io) {
64
+ if (ctx.config.modules.ci) {
65
+ for (const { file, command } of await duplicateTestRuns(root, ctx.stacks)) {
66
+ io.out(`note: ${file} also runs "${command}", which the repokeeper ci job now runs too; drop one of them to save CI time`);
67
+ }
68
+ }
69
+ if (!ctx.config.modules.editorconfig)
70
+ return;
71
+ const text = await readFile(join(root, ".gitattributes"), "utf8").catch(() => "");
72
+ for (const { line, by } of overriddenAttributes(text, "editorconfig")) {
73
+ io.out(`warning: .gitattributes: "${line}" has no effect because repokeeper's "${by}" comes later; move it below the repokeeper block to keep it`);
74
+ }
75
+ }
25
76
  export function hasDrift(result) {
26
- return result.decisions.some((d) => d.action !== "unchanged") || result.removals.length > 0;
77
+ return result.decisions.some((d) => d.action !== "unchanged") || result.removals.some((r) => r.action !== "release");
27
78
  }
@@ -10,7 +10,7 @@ import { STANDARD_VERSION } from "../version.js";
10
10
  import { assertSupportedStandard } from "./check.js";
11
11
  import { buildContext } from "./context.js";
12
12
  import { guardUncommitted } from "./init.js";
13
- import { printResult } from "./report.js";
13
+ import { nextSteps, printResult, printWarnings } from "./report.js";
14
14
  export async function updateCommand(root, options, io) {
15
15
  const config = await loadConfig(root);
16
16
  assertSupportedStandard(config.standard);
@@ -19,15 +19,21 @@ export async function updateCommand(root, options, io) {
19
19
  throw new UsageError(".repokeeper/lock.json is missing; run `repokeeper init --relock` first");
20
20
  const ctx = await buildContext(root, { ...config, standard: STANDARD_VERSION });
21
21
  const adopt = options.adoptAll ? "all" : new Set(options.adopt);
22
- const result = await computeSync(root, planOutputs(ctx), lock, { adopt, accept: new Set(options.accept) });
22
+ const result = await computeSync(root, planOutputs(ctx), lock, {
23
+ adopt,
24
+ accept: new Set(options.accept),
25
+ owned: config.owned,
26
+ });
23
27
  if (!options.dryRun)
24
- await guardUncommitted(root, result, options.force);
28
+ await guardUncommitted(root, result, options.force, lock);
25
29
  printResult(io, result);
26
30
  if (options.dryRun) {
27
31
  io.out("dry run: nothing written");
28
32
  return 0;
29
33
  }
30
34
  await applySync(root, result, lock, STANDARD_VERSION);
35
+ const steps = await nextSteps(root, ctx, result);
36
+ await printWarnings(root, ctx, io);
31
37
  if (config.standard !== STANDARD_VERSION) {
32
38
  const path = join(root, CONFIG_FILE);
33
39
  await writeFile(path, setStandard(await readFile(path, "utf8"), STANDARD_VERSION));
@@ -37,6 +43,8 @@ export async function updateCommand(root, options, io) {
37
43
  io.out(`${conflicts.length} file(s) kept because they were edited locally; new versions are beside them as *.repokeeper-new`);
38
44
  return 1;
39
45
  }
46
+ for (const step of steps)
47
+ io.out(`next: ${step}`);
40
48
  io.out(`repository is on standard ${STANDARD_VERSION}; commit with "chore(repokeeper): update standard to ${STANDARD_VERSION}"`);
41
49
  return 0;
42
50
  }
@@ -1,4 +1,9 @@
1
1
  export const STACK_IDS = ["node", "python", "dart", "script", "java", "dotnet"];
2
+ /** The branch workflows run on and contributors branch from: `github.default_branch`, or `main`. */
3
+ export function defaultBranch(config) {
4
+ const branch = config.github?.default_branch;
5
+ return typeof branch === "string" && branch.length > 0 ? branch : "main";
6
+ }
2
7
  export function defaultConfig(input) {
3
8
  return {
4
9
  schema: 1,
@@ -0,0 +1,36 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { parse } from "yaml";
4
+ import { REUSABLE_REPO } from "./version.js";
5
+ const WORKFLOWS = ".github/workflows";
6
+ const calls = (job) => {
7
+ const uses = job?.uses;
8
+ return (typeof uses === "string" && (uses.startsWith(`${REUSABLE_REPO}/`) || uses.startsWith("./.github/workflows/stack-")));
9
+ };
10
+ /** Workflows of the user's own that run a stack's test command, which repokeeper's ci job now runs as well. */
11
+ export async function duplicateTestRuns(root, stacks) {
12
+ const commands = stacks.map((s) => s.test).filter((c) => c !== null);
13
+ if (commands.length === 0)
14
+ return [];
15
+ const names = await readdir(join(root, WORKFLOWS)).catch(() => []);
16
+ const found = [];
17
+ for (const name of names.filter((n) => /\.ya?ml$/.test(n)).sort()) {
18
+ let workflow;
19
+ try {
20
+ workflow = parse(await readFile(join(root, WORKFLOWS, name), "utf8"));
21
+ }
22
+ catch {
23
+ continue;
24
+ }
25
+ // only pull request CI overlaps with repokeeper's ci job; a release workflow testing before it publishes doesn't
26
+ if (!JSON.stringify(workflow?.on ?? "").includes("pull_request"))
27
+ continue;
28
+ const jobs = workflow?.jobs ?? {};
29
+ const own = JSON.stringify(Object.values(jobs).filter((job) => !calls(job)));
30
+ for (const command of commands) {
31
+ if (own.includes(command))
32
+ found.push({ file: `${WORKFLOWS}/${name}`, command });
33
+ }
34
+ }
35
+ return found;
36
+ }
package/dist/git.js CHANGED
@@ -20,7 +20,27 @@ export function parseRemoteUrl(url) {
20
20
  }
21
21
  export async function repoInfo(root) {
22
22
  const url = await git(root, ["remote", "get-url", "origin"]);
23
- return (url ? parseRemoteUrl(url) : null) ?? { owner: null, name: basename(root) };
23
+ const remote = (url ? parseRemoteUrl(url) : null) ?? { owner: null, name: basename(root) };
24
+ return { ...remote, releasedVersion: await latestReleaseVersion(root) };
25
+ }
26
+ /** The branch `origin/HEAD` points at, or null when the remote's default branch isn't known locally. */
27
+ export async function remoteDefaultBranch(root) {
28
+ const ref = (await git(root, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]))?.trim();
29
+ return ref?.startsWith("origin/") ? ref.slice("origin/".length) : null;
30
+ }
31
+ /** The highest `vX.Y.Z` tag as `X.Y.Z`; major tags such as `v0` are skipped. */
32
+ export async function latestReleaseVersion(root) {
33
+ const tags = (await git(root, ["tag", "--list", "v*", "--sort=-v:refname"])) ?? "";
34
+ const tag = tags.split("\n").find((t) => /^v\d+\.\d+\.\d+$/.test(t.trim()));
35
+ return tag ? tag.trim().slice(1) : null;
36
+ }
37
+ /** Tracked files git stores with CRLF line endings; `.gitattributes` only reaches them after a renormalize. */
38
+ export async function crlfTrackedFiles(root) {
39
+ const out = (await git(root, ["ls-files", "--eol"])) ?? "";
40
+ return out
41
+ .split("\n")
42
+ .filter((line) => line.startsWith("i/crlf"))
43
+ .map((line) => line.split("\t").pop());
24
44
  }
25
45
  export async function gitUserName(root) {
26
46
  return (await git(root, ["config", "user.name"]))?.trim() || null;
@@ -22,10 +22,13 @@ trim_trailing_whitespace = false
22
22
  [Makefile]
23
23
  indent_style = tab
24
24
  `;
25
+ // gitattributes patterns have no brace expansion, so every extension gets its own line
26
+ const CRLF = ["ps1", "psm1", "bat", "cmd"];
27
+ const BINARY = ["png", "jpg", "jpeg", "gif", "ico", "webp", "pdf", "zip", "gz", "woff", "woff2"];
25
28
  const GITATTRIBUTES = [
26
29
  "* text=auto eol=lf",
27
- "*.{ps1,psm1,bat,cmd} text eol=crlf",
28
- "*.{png,jpg,jpeg,gif,ico,webp,pdf,zip,gz,woff,woff2} binary",
30
+ ...CRLF.map((ext) => `*.${ext} text eol=crlf`),
31
+ ...BINARY.map((ext) => `*.${ext} binary`),
29
32
  ].join("\n");
30
33
  export const editorconfigModule = {
31
34
  id: "editorconfig",
@@ -1,3 +1,4 @@
1
+ import { defaultBranch } from "../config/types.js";
1
2
  import { UsageError } from "../errors.js";
2
3
  import { MANAGED_HEADER } from "../model.js";
3
4
  import { readTemplate } from "../templates.js";
@@ -48,9 +49,9 @@ export const healthModule = {
48
49
  const run = install.length ? `Run ${install.map((c) => `\`${c}\``).join(" and ")}.` : "";
49
50
  const hooks = ctx.stacks.some((s) => s.id === "node")
50
51
  ? "Installing the dependencies also installs the git hooks (lefthook)."
51
- : `Then run \`npx --yes lefthook@${TOOL_VERSIONS.lefthook} install\` once to enable the git hooks (they need Node.js 22 or newer).`;
52
+ : `${run ? "Then run" : "Run"} \`npx --yes lefthook@${TOOL_VERSIONS.lefthook} install\` once to enable the git hooks (they need Node.js 22 or newer).`;
52
53
  const setup = [run, hooks].filter(Boolean).join(" ");
53
- outputs.push(md("CONTRIBUTING.md", `# Contributing\n\n<!-- ${MANAGED_HEADER} -->\n\nThanks for helping improve this project.\n\n## Local setup\n\n${setup}\n\n## Workflow\n\n1. Create a branch from \`main\`.\n2. Write commit messages in [Conventional Commits](https://www.conventionalcommits.org/) form, for example \`feat: add export button\` or \`fix(api): handle empty input\`. The \`commit-msg\` hook checks them.\n3. Open a pull request. It is merged once the checks pass and it has been reviewed.\n\nPlease follow the [Code of Conduct](CODE_OF_CONDUCT.md).\n`));
54
+ outputs.push(md("CONTRIBUTING.md", `# Contributing\n\n<!-- ${MANAGED_HEADER} -->\n\nThanks for helping improve this project.\n\n## Local setup\n\n${setup}\n\n## Workflow\n\n1. Create a branch from \`${defaultBranch(ctx.config)}\`.\n2. Write commit messages in [Conventional Commits](https://www.conventionalcommits.org/) form, for example \`feat: add export button\` or \`fix(api): handle empty input\`. The \`commit-msg\` hook checks them.\n3. Open a pull request. It is merged once the checks pass and it has been reviewed.\n\nPlease follow the [Code of Conduct](CODE_OF_CONDUCT.md).\n`));
54
55
  outputs.push(md("CODE_OF_CONDUCT.md", readTemplate("CODE_OF_CONDUCT.md").replace("[INSERT CONTACT METHOD]", health.contact)));
55
56
  outputs.push(...ctx.platform.communityFiles(ctx));
56
57
  return outputs;
package/dist/owned.js ADDED
@@ -0,0 +1,23 @@
1
+ const toPosix = (path) => path.replace(/\\/g, "/").replace(/^\.\//, "");
2
+ /**
3
+ * Whether an `owned` entry covers the target: a whole path (`LICENSE`), a block (`.gitattributes#editorconfig`),
4
+ * or a key and everything under it (`.github/workflows/ci.yml#on`, `package.json#devDependencies.lefthook`).
5
+ */
6
+ export function isOwned(owned, target) {
7
+ return owned.some((entry) => {
8
+ const hash = entry.indexOf("#");
9
+ const path = toPosix(hash === -1 ? entry : entry.slice(0, hash));
10
+ if (path !== target.path)
11
+ return false;
12
+ if (hash === -1)
13
+ return true;
14
+ const key = entry.slice(hash + 1);
15
+ if (target.kind === "block")
16
+ return key === target.id;
17
+ if (target.kind === "yaml" || target.kind === "json") {
18
+ const dotted = target.keyPath.join(".");
19
+ return dotted === key || dotted.startsWith(`${key}.`);
20
+ }
21
+ return false;
22
+ });
23
+ }
package/dist/plan.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { outputId } from "./model.js";
2
2
  import { MODULES } from "./modules/index.js";
3
- const toPosix = (path) => path.replace(/\\/g, "/").replace(/^\.\//, "");
3
+ import { isOwned } from "./owned.js";
4
+ import { targetOf } from "./sync/lock.js";
4
5
  /** Pure: the outputs the standard asks for, given the config and resolved stacks. */
5
6
  export function planOutputs(ctx, modules = MODULES) {
6
- const owned = new Set(ctx.config.owned.map(toPosix));
7
7
  const outputs = modules
8
8
  .filter((module) => module.enabled(ctx.config))
9
9
  .flatMap((module) => module.outputs(ctx))
10
- .filter((output) => !owned.has(output.path));
10
+ .filter((output) => !isOwned(ctx.config.owned, targetOf(output)));
11
11
  const seen = new Set();
12
12
  for (const output of outputs) {
13
13
  const id = outputId(output);
@@ -1,4 +1,5 @@
1
1
  import { stringify } from "yaml";
2
+ import { defaultBranch } from "../config/types.js";
2
3
  import { MANAGED_HEADER } from "../model.js";
3
4
  import { REUSABLE_REPO, WORKFLOW_REF } from "../version.js";
4
5
  const yamlFile = (module, path, data) => ({
@@ -21,10 +22,6 @@ const workflowKey = (module, path, keyPath, value) => ({
21
22
  value,
22
23
  order: WORKFLOW_KEYS,
23
24
  });
24
- function defaultBranch(ctx) {
25
- const branch = ctx.config.github?.default_branch;
26
- return typeof branch === "string" && branch.length > 0 ? branch : "main";
27
- }
28
25
  const RELEASE_PLEASE_SCHEMA = "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json";
29
26
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
30
27
  export const githubPlatform = {
@@ -102,6 +99,10 @@ export const githubPlatform = {
102
99
  directory: "/",
103
100
  schedule: { interval: "weekly" },
104
101
  groups: { [`${ecosystem}-minor-and-patch`]: { "update-types": ["minor", "patch"] } },
102
+ // @types/node majors track the Node.js line a project runs on, which the project chooses
103
+ ...(ecosystem === "npm"
104
+ ? { ignore: [{ "dependency-name": "@types/node", "update-types": ["version-update:semver-major"] }] }
105
+ : {}),
105
106
  }));
106
107
  return [yamlFile("deps", ".github/dependabot.yml", { version: 2, updates })];
107
108
  },
@@ -123,7 +124,7 @@ export const githubPlatform = {
123
124
  return [];
124
125
  return [
125
126
  workflowKey("ci", path, ["name"], "ci"),
126
- workflowKey("ci", path, ["on"], { pull_request: null, push: { branches: [defaultBranch(ctx)] } }),
127
+ workflowKey("ci", path, ["on"], { pull_request: null, push: { branches: [defaultBranch(ctx.config)] } }),
127
128
  workflowKey("ci", path, ["permissions"], { contents: "read" }),
128
129
  ...jobs,
129
130
  ];
@@ -152,10 +153,10 @@ export const githubPlatform = {
152
153
  kind: "seed",
153
154
  module: "release",
154
155
  path: ".release-please-manifest.json",
155
- content: json({ ".": release.version ?? "0.0.0" }),
156
+ content: json({ ".": release.version ?? ctx.repo.releasedVersion ?? "0.0.0" }),
156
157
  },
157
158
  workflowKey("release", path, ["name"], "release"),
158
- workflowKey("release", path, ["on"], { push: { branches: [defaultBranch(ctx)] } }),
159
+ workflowKey("release", path, ["on"], { push: { branches: [defaultBranch(ctx.config)] } }),
159
160
  workflowKey("release", path, ["permissions"], { contents: "read" }),
160
161
  workflowKey("release", path, ["jobs", "release"], {
161
162
  uses: workflowRef(ctx, "release-please.yml"),
@@ -1,9 +1,23 @@
1
+ import { UsageError } from "../errors.js";
1
2
  import { checkKeys, filesMatching, readText, stringList } from "./support.js";
2
3
  const OPTION_KEYS = ["versions", "os"];
3
4
  const SOLUTIONS = /\.(sln|slnx)$/;
4
5
  const PROJECTS = /\.(csproj|fsproj|vbproj)$/;
5
6
  const VERSION_XPATH = "//Project/PropertyGroup/Version";
6
7
  const versionOf = (xml) => (xml ? (/<Version>\s*([^<\s]+)\s*<\/Version>/.exec(xml)?.[1] ?? null) : null);
8
+ /** Project files the root solutions list, plus projects at the root, as posix paths. */
9
+ async function projectPaths(root, solutions, projects) {
10
+ const paths = new Set(projects);
11
+ for (const solution of solutions) {
12
+ const text = (await readText(root, solution)) ?? "";
13
+ const pattern = solution.endsWith(".slnx") ? /Path="([^"]+\.[cfv][sb]proj)"/g : /"([^"]+\.[cfv][sb]proj)"/g;
14
+ for (const match of text.matchAll(pattern))
15
+ paths.add(match[1].replace(/\\/g, "/"));
16
+ }
17
+ return [...paths];
18
+ }
19
+ /** SDK-style projects name an SDK; .NET Framework projects don't, and the dotnet CLI can't build them. */
20
+ const isSdkStyle = (xml) => /<Project[^>]*\sSdk=/.test(xml) || /<Sdk\s+Name=/.test(xml);
7
21
  export const dotnetStack = {
8
22
  id: "dotnet",
9
23
  detect: (root) => filesMatching(root, ".", SOLUTIONS).length + filesMatching(root, ".", PROJECTS).length > 0,
@@ -13,6 +27,15 @@ export const dotnetStack = {
13
27
  const projects = filesMatching(root, ".", PROJECTS);
14
28
  // with several entries at the root, dotnet stops with MSB1011 unless one is named
15
29
  const target = solutions.length + projects.length > 1 ? ` ${solutions[0] ?? projects[0]}` : "";
30
+ const legacy = [];
31
+ for (const path of await projectPaths(root, solutions, projects)) {
32
+ const xml = await readText(root, path);
33
+ if (xml !== null && /<Project[\s>]/.test(xml) && !isSdkStyle(xml))
34
+ legacy.push(path);
35
+ }
36
+ if (legacy.length > 0) {
37
+ throw new UsageError(`the dotnet stack needs SDK-style projects, and ${legacy.join(", ")} ${legacy.length > 1 ? "are" : "is"} .NET Framework projects the dotnet CLI can't build; convert them (for example with the .NET Upgrade Assistant) or leave dotnet out of stacks`);
38
+ }
16
39
  let release = { type: "simple", version: null };
17
40
  for (const file of ["Directory.Build.props", ...(projects.length === 1 ? projects : [])]) {
18
41
  const version = versionOf(await readText(root, file));
@@ -4,8 +4,11 @@ import { checkKeys, filesMatching, optionalString, readText, stringList } from "
4
4
  const OPTION_KEYS = ["versions", "os", "test"];
5
5
  /** The `version = "…"` of the `[project]` table, if any. */
6
6
  function projectVersion(pyproject) {
7
- const table = pyproject.split(/^\[/m).find((section) => section.startsWith("project]"));
8
- return table ? (/^version\s*=\s*"([^"]+)"/m.exec(table)?.[1] ?? null) : null;
7
+ return /^version\s*=\s*"([^"]+)"/m.exec(section(pyproject, "project"))?.[1] ?? null;
8
+ }
9
+ /** The body of the `[name]` table of a TOML file, or "". */
10
+ function section(toml, name) {
11
+ return toml.split(/^\[/m).find((part) => part.startsWith(`${name}]`)) ?? "";
9
12
  }
10
13
  export const pythonStack = {
11
14
  id: "python",
@@ -15,7 +18,10 @@ export const pythonStack = {
15
18
  const has = (path) => existsSync(join(root, path));
16
19
  const pyproject = (await readText(root, "pyproject.toml")) ?? "";
17
20
  const uv = has("uv.lock");
21
+ const mypyIni = (await readText(root, "mypy.ini")) ?? "";
18
22
  const mypy = has("mypy.ini") || pyproject.includes("[tool.mypy]");
23
+ // a config that names its files decides what mypy checks; otherwise check the whole tree
24
+ const mypyFiles = /^files\s*=/m.test(mypyIni) || /^files\s*=/m.test(section(pyproject, "tool.mypy"));
19
25
  const hasTests = has("tests") || has("test") || filesMatching(root, ".", /^test_.*\.py$/).length > 0;
20
26
  const customTest = optionalString("python", options, "test");
21
27
  const pytest = customTest === undefined && hasTests;
@@ -37,7 +43,7 @@ export const pythonStack = {
37
43
  const commands = [
38
44
  `${run}ruff format --check .`,
39
45
  `${run}ruff check .`,
40
- ...(mypy ? [`${run}mypy .`] : []),
46
+ ...(mypy ? [`${run}mypy${mypyFiles ? "" : " ."}`] : []),
41
47
  ...(test ? [test] : []),
42
48
  ];
43
49
  const ruff = uv ? "uvx ruff" : "ruff";
@@ -0,0 +1,44 @@
1
+ import { endMarker, startMarker } from "./block.js";
2
+ import { normalizeEol } from "./hash.js";
3
+ /** `eol=lf` → ["eol", "lf"], `-text` → ["text", "unset"], `text` → ["text", "set"]. */
4
+ function attributes(fields) {
5
+ const map = new Map();
6
+ for (const field of fields) {
7
+ if (field.startsWith("-") || field.startsWith("!"))
8
+ map.set(field.slice(1), "unset");
9
+ else if (field.includes("="))
10
+ map.set(field.slice(0, field.indexOf("=")), field.slice(field.indexOf("=") + 1));
11
+ else
12
+ map.set(field, "set");
13
+ }
14
+ return map;
15
+ }
16
+ const parse = (line) => {
17
+ const [pattern, ...fields] = line.trim().split(/\s+/);
18
+ return { raw: line.trim(), pattern: pattern, attrs: attributes(fields) };
19
+ };
20
+ const isRule = (line) => line.trim() !== "" && !line.trim().startsWith("#");
21
+ /**
22
+ * Lines above repokeeper's block that set an attribute the block sets differently for the same
23
+ * pattern. gitattributes applies the last match, so those lines have no effect.
24
+ */
25
+ export function overriddenAttributes(text, blockId) {
26
+ const lines = normalizeEol(text).split("\n");
27
+ const start = lines.indexOf(startMarker(blockId, "hash"));
28
+ const end = lines.indexOf(endMarker(blockId, "hash"), start + 1);
29
+ if (start < 0 || end < 0)
30
+ return [];
31
+ const block = lines
32
+ .slice(start + 1, end)
33
+ .filter(isRule)
34
+ .map(parse);
35
+ const found = [];
36
+ for (const line of lines.slice(0, start).filter(isRule)) {
37
+ const mine = parse(line);
38
+ const theirs = block.find((rule) => rule.pattern === mine.pattern &&
39
+ [...mine.attrs].some(([key, value]) => rule.attrs.has(key) && rule.attrs.get(key) !== value));
40
+ if (theirs)
41
+ found.push({ line: mine.raw, by: theirs.raw });
42
+ }
43
+ return found;
44
+ }
@@ -13,9 +13,9 @@ export function decide(output, currentText, entry, adopt) {
13
13
  }
14
14
  /** For a lock entry the standard no longer produces. */
15
15
  export function decideRemoval(entry, currentText) {
16
- if (entry.target.kind === "seed")
17
- return "gone"; // other tools own a seed once it exists
18
16
  if (currentText === null)
19
17
  return "gone";
18
+ if (entry.target.kind === "seed")
19
+ return "left"; // other tools own a seed once it exists
20
20
  return hashText(currentText) === entry.hash ? "delete" : "orphan-edited";
21
21
  }
package/dist/sync/sync.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { outputId } from "../model.js";
2
+ import { isOwned } from "../owned.js";
2
3
  import { decide, decideRemoval } from "./decide.js";
3
4
  import { targetOf } from "./lock.js";
4
5
  import { readCurrent } from "./state.js";
@@ -15,8 +16,12 @@ export async function computeSync(root, outputs, lock, options) {
15
16
  const wanted = new Set(outputs.map(outputId));
16
17
  const removals = [];
17
18
  for (const entry of entries.values()) {
18
- if (!wanted.has(entry.id))
19
- removals.push({ entry, action: decideRemoval(entry, await readCurrent(root, entry.target)) });
19
+ if (wanted.has(entry.id))
20
+ continue;
21
+ const action = isOwned(options.owned ?? [], entry.target)
22
+ ? "release"
23
+ : decideRemoval(entry, await readCurrent(root, entry.target));
24
+ removals.push({ entry, action });
20
25
  }
21
26
  return { decisions, removals };
22
27
  }
package/dist/sync/yaml.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from "node:util";
1
2
  import { Document, isMap, isScalar, parseDocument } from "yaml";
2
3
  import { UsageError } from "../errors.js";
3
4
  import { getAtPath } from "./json.js";
@@ -8,18 +9,127 @@ function parse(text, file) {
8
9
  throw new UsageError(`${file} is not valid YAML: ${error.message.split("\n")[0]}`);
9
10
  return doc;
10
11
  }
11
- function render(doc, original) {
12
- const text = doc.toString({ lineWidth: 0, nullStr: "", flowCollectionPadding: false });
13
- return original?.includes("\r\n") ? text.replace(/\r?\n/g, "\r\n") : text;
14
- }
12
+ const renderLf = (doc) => doc.toString({ lineWidth: 0, nullStr: "", flowCollectionPadding: false });
13
+ const withEol = (text, original) => original?.includes("\r\n") ? text.replace(/\r?\n/g, "\r\n") : text;
15
14
  /** JSON text of the value at `keyPath`, or null when the key is absent. */
16
15
  export function readYamlKey(text, keyPath, file) {
17
16
  const value = getAtPath(parse(text, file).toJS() ?? {}, keyPath);
18
17
  return value === undefined ? null : JSON.stringify(value);
19
18
  }
19
+ const keyName = (key) => String(isScalar(key) ? key.value : key);
20
+ const pairIn = (map, key) => map.items.find((p) => keyName(p.key) === key);
21
+ /** The block map at `keyPath` (the root for []); null when absent or written in flow style. */
22
+ function blockMap(doc, keyPath) {
23
+ const node = keyPath.length === 0 ? doc.contents : doc.getIn(keyPath, true);
24
+ return isMap(node) && !node.flow ? node : null;
25
+ }
26
+ const lineStart = (text, offset) => text.lastIndexOf("\n", offset - 1) + 1;
27
+ function lineEnd(text, offset) {
28
+ const index = text.indexOf("\n", offset);
29
+ return index === -1 ? text.length : index + 1;
30
+ }
31
+ const rangeOf = (node) => node?.range;
32
+ /** [start, end) of the whole lines a block-map pair occupies, and the column of its key. */
33
+ function pairSpan(text, pair) {
34
+ const key = rangeOf(pair.key);
35
+ if (!key)
36
+ return null;
37
+ const last = Math.max((rangeOf(pair.value)?.[1] ?? key[1]) - 1, key[1]);
38
+ const start = lineStart(text, key[0]);
39
+ return { start, end: lineEnd(text, last), column: key[0] - start };
40
+ }
41
+ function reindent(lines, from, to) {
42
+ if (from === to)
43
+ return lines;
44
+ return lines
45
+ .split("\n")
46
+ .map((line) => {
47
+ if (line.trim() === "")
48
+ return line;
49
+ const strip = Math.min(from, line.length - line.trimStart().length);
50
+ return " ".repeat(to) + line.slice(strip);
51
+ })
52
+ .join("\n");
53
+ }
54
+ /** The lines of the pair at `keyPath` in the rendered `doc`, indented to `column`. */
55
+ function renderedPair(doc, keyPath, column) {
56
+ const text = renderLf(doc);
57
+ const parent = blockMap(parseDocument(text), keyPath.slice(0, -1));
58
+ const pair = parent && pairIn(parent, keyPath[keyPath.length - 1]);
59
+ const span = pair && pairSpan(text, pair);
60
+ if (!span)
61
+ return null;
62
+ const lines = text.slice(span.start, span.end);
63
+ return reindent(lines.endsWith("\n") ? lines : `${lines}\n`, span.column, column);
64
+ }
65
+ /**
66
+ * Writes the key into `text` by editing only its own lines, so the rest of the file keeps its
67
+ * formatting. Null when the file's shape doesn't allow it (flow collections, an empty map).
68
+ */
69
+ function spliceSet(text, keyPath, next, order) {
70
+ const doc = parseDocument(text);
71
+ let parent = blockMap(doc, []);
72
+ let depth = 0;
73
+ for (; parent && depth < keyPath.length; depth++) {
74
+ const pair = pairIn(parent, keyPath[depth]);
75
+ if (!pair)
76
+ break;
77
+ if (depth === keyPath.length - 1) {
78
+ const span = pairSpan(text, pair);
79
+ const lines = span && renderedPair(next, keyPath, span.column);
80
+ return span && lines ? text.slice(0, span.start) + lines + text.slice(span.end) : null;
81
+ }
82
+ parent = isMap(pair.value) && !pair.value.flow ? pair.value : null;
83
+ }
84
+ const first = parent?.items[0];
85
+ const firstSpan = first && pairSpan(text, first);
86
+ if (!parent || !firstSpan)
87
+ return null;
88
+ const unit = keyPath.slice(0, depth + 1);
89
+ const lines = renderedPair(next, unit, firstSpan.column);
90
+ if (!lines)
91
+ return null;
92
+ let at = null;
93
+ if (depth === 0 && order) {
94
+ const rank = (key) => (order.includes(key) ? order.indexOf(key) : order.length);
95
+ const after = parent.items.find((p) => rank(keyName(p.key)) > rank(unit[0]));
96
+ if (after)
97
+ at = pairSpan(text, after)?.start ?? null;
98
+ }
99
+ if (at === null) {
100
+ const lastSpan = pairSpan(text, parent.items[parent.items.length - 1]);
101
+ if (!lastSpan)
102
+ return null;
103
+ at = lastSpan.end;
104
+ }
105
+ const before = text.slice(0, at);
106
+ return (before === "" || before.endsWith("\n") ? before : `${before}\n`) + lines + text.slice(at);
107
+ }
108
+ /** Removes the pair at `keyPath` plus the parents it leaves empty by cutting their lines. */
109
+ function spliceDelete(text, keyPath) {
110
+ const doc = parseDocument(text);
111
+ let cut = keyPath.length;
112
+ while (cut > 1 && blockMap(doc, keyPath.slice(0, cut - 1))?.items.length === 1)
113
+ cut--;
114
+ const parent = blockMap(doc, keyPath.slice(0, cut - 1));
115
+ const pair = parent && pairIn(parent, keyPath[cut - 1]);
116
+ const span = pair && pairSpan(text, pair);
117
+ return span ? text.slice(0, span.start) + text.slice(span.end) : null;
118
+ }
119
+ /** The spliced text when it parses to exactly `expected`, otherwise a full re-render. */
120
+ function preferSplice(original, spliced, expected) {
121
+ if (spliced !== null) {
122
+ const check = parseDocument(spliced);
123
+ if (check.errors.length === 0 && isDeepStrictEqual(check.toJS(), expected.toJS())) {
124
+ return withEol(spliced, original);
125
+ }
126
+ }
127
+ return withEol(renderLf(expected), original);
128
+ }
20
129
  export function setYamlKey(text, file, keyPath, value, options) {
21
130
  const fresh = text === null || text.trim() === "";
22
- const doc = text !== null && !fresh ? parse(text, file) : new Document({});
131
+ const lf = fresh ? "" : text.replace(/\r\n/g, "\n");
132
+ const doc = fresh ? new Document({}) : parse(lf, file);
23
133
  if (fresh)
24
134
  doc.commentBefore = ` ${options.header}`;
25
135
  doc.setIn(keyPath, doc.createNode(value));
@@ -31,11 +141,14 @@ export function setYamlKey(text, file, keyPath, value, options) {
31
141
  };
32
142
  doc.contents.items.sort((a, b) => rank(a.key) - rank(b.key));
33
143
  }
34
- return render(doc, text);
144
+ if (fresh)
145
+ return withEol(renderLf(doc), text);
146
+ return preferSplice(text, spliceSet(lf, keyPath, doc, order), doc);
35
147
  }
36
148
  /** Removes the key and any parents it leaves empty; null when nothing is left. */
37
149
  export function deleteYamlKey(text, file, keyPath) {
38
- const doc = parse(text, file);
150
+ const lf = text.replace(/\r\n/g, "\n");
151
+ const doc = parse(lf, file);
39
152
  doc.deleteIn(keyPath);
40
153
  for (let depth = keyPath.length - 1; depth > 0; depth--) {
41
154
  const parent = doc.getIn(keyPath.slice(0, depth));
@@ -45,5 +158,5 @@ export function deleteYamlKey(text, file, keyPath) {
45
158
  }
46
159
  if (!isMap(doc.contents) || doc.contents.items.length === 0)
47
160
  return null;
48
- return render(doc, text);
161
+ return preferSplice(text, spliceDelete(lf, keyPath), doc);
49
162
  }
package/dist/version.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  export const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
3
3
  /** The standard this build of repokeeper applies. Bump it whenever generated output changes. */
4
- export const STANDARD_VERSION = "1.2.0";
4
+ export const STANDARD_VERSION = "1.2.2";
5
5
  export const TOOL_VERSIONS = {
6
6
  lefthook: "2.1.14",
7
7
  commitlintCli: "21.2.3",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repokeeper",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Keep every repository on one maintained standard: commits, git hooks, CI, releases, dependency updates and repo settings.",
5
5
  "type": "module",
6
6
  "bin": {