repokeeper 0.2.2 → 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
@@ -36,6 +36,15 @@ alone. Every write command accepts `--dry-run`.
36
36
  isn't `main`. The release manifest starts from the latest `vX.Y.Z` tag when the stack has no version
37
37
  of its own.
38
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.
47
+
39
48
  Requires Node.js 22.12 or newer.
40
49
 
41
50
  ## CI and releases
@@ -46,8 +55,8 @@ without a pull request. repokeeper owns the `name`, `on` and `permissions` keys
46
55
  adds; jobs you add yourself are left alone, and so is the formatting of the rest of the file.
47
56
 
48
57
  The script stack runs ShellCheck and `shfmt -d` on `*.sh` (format with `shfmt -w` before pushing)
49
- and PSScriptAnalyzer on `*.ps1`, which fails on warnings too. To relax a rule, add a
50
- `PSScriptAnalyzerSettings.psd1` at the repository root; PSScriptAnalyzer picks it up on its own:
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:
51
60
 
52
61
  ```powershell
53
62
  @{
@@ -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`);
@@ -42,6 +42,7 @@ export async function checkCommand(root, options, io) {
42
42
  return clean ? 0 : 1;
43
43
  }
44
44
  printResult(io, result);
45
+ await printWarnings(root, ctx, io);
45
46
  if (behind)
46
47
  io.out(`standard ${config.standard} applied, ${STANDARD_VERSION} available (run \`repokeeper update\`)`);
47
48
  io.out(clean ? "repository matches the standard" : "repository has drifted from the standard");
@@ -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(", ");
@@ -62,7 +78,10 @@ export async function initCommand(root, options, io) {
62
78
  await writeFile(join(root, CONFIG_FILE), renderConfig(config));
63
79
  await applySync(root, result, null, STANDARD_VERSION);
64
80
  io.out(`applied standard ${STANDARD_VERSION}; wrote ${CONFIG_FILE}`);
65
- 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}"`);
66
85
  return 0;
67
86
  }
68
87
  /** Records the current content of every planned output as repokeeper's own, rebuilding a lost or corrupt lock. */
@@ -1,4 +1,10 @@
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";
2
8
  /** How to name the output under `owned`: the path, or `path#key` for one key of a shared file. */
3
9
  function ownedName(output) {
4
10
  if (output.kind === "yaml" || output.kind === "json")
@@ -34,6 +40,39 @@ export function printResult(io, result) {
34
40
  }
35
41
  io.out(`${unchanged} output(s) already match the standard`);
36
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
+ }
37
76
  export function hasDrift(result) {
38
77
  return result.decisions.some((d) => d.action !== "unchanged") || result.removals.some((r) => r.action !== "release");
39
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);
@@ -25,13 +25,15 @@ export async function updateCommand(root, options, io) {
25
25
  owned: config.owned,
26
26
  });
27
27
  if (!options.dryRun)
28
- await guardUncommitted(root, result, options.force);
28
+ await guardUncommitted(root, result, options.force, lock);
29
29
  printResult(io, result);
30
30
  if (options.dryRun) {
31
31
  io.out("dry run: nothing written");
32
32
  return 0;
33
33
  }
34
34
  await applySync(root, result, lock, STANDARD_VERSION);
35
+ const steps = await nextSteps(root, ctx, result);
36
+ await printWarnings(root, ctx, io);
35
37
  if (config.standard !== STANDARD_VERSION) {
36
38
  const path = join(root, CONFIG_FILE);
37
39
  await writeFile(path, setStandard(await readFile(path, "utf8"), STANDARD_VERSION));
@@ -41,6 +43,8 @@ export async function updateCommand(root, options, io) {
41
43
  io.out(`${conflicts.length} file(s) kept because they were edited locally; new versions are beside them as *.repokeeper-new`);
42
44
  return 1;
43
45
  }
46
+ for (const step of steps)
47
+ io.out(`next: ${step}`);
44
48
  io.out(`repository is on standard ${STANDARD_VERSION}; commit with "chore(repokeeper): update standard to ${STANDARD_VERSION}"`);
45
49
  return 0;
46
50
  }
@@ -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
@@ -34,6 +34,14 @@ export async function latestReleaseVersion(root) {
34
34
  const tag = tags.split("\n").find((t) => /^v\d+\.\d+\.\d+$/.test(t.trim()));
35
35
  return tag ? tag.trim().slice(1) : null;
36
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());
44
+ }
37
45
  export async function gitUserName(root) {
38
46
  return (await git(root, ["config", "user.name"]))?.trim() || null;
39
47
  }
@@ -99,6 +99,10 @@ export const githubPlatform = {
99
99
  directory: "/",
100
100
  schedule: { interval: "weekly" },
101
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
+ : {}),
102
106
  }));
103
107
  return [yamlFile("deps", ".github/dependabot.yml", { version: 2, updates })];
104
108
  },
@@ -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
+ }
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.1";
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.2",
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": {