repokeeper 0.1.0 → 0.2.2

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,13 @@ 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.
32
38
 
33
39
  Requires Node.js 22.12 or newer.
34
40
 
@@ -37,7 +43,18 @@ Requires Node.js 22.12 or newer.
37
43
  `ci.yml` and `release.yml` call reusable workflows from this repository (`stack-node.yml`,
38
44
  `commitlint.yml`, `release-please.yml`) at the moving major tag, so fixes reach every repository
39
45
  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.
46
+ adds; jobs you add yourself are left alone, and so is the formatting of the rest of the file.
47
+
48
+ 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:
51
+
52
+ ```powershell
53
+ @{
54
+ # installers print for the person running them
55
+ ExcludeRules = @('PSAvoidUsingWriteHost')
56
+ }
57
+ ```
41
58
 
42
59
  `release.yml` runs [release-please](https://github.com/googleapis/release-please): it keeps a release
43
60
  pull request open, and merging it tags the release and updates `CHANGELOG.md`. Two settings make this
@@ -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) {
@@ -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";
@@ -46,6 +46,9 @@ export async function initCommand(root, options, io) {
46
46
  contact: repo.owner ? `https://github.com/${repo.owner}` : "the repository maintainers",
47
47
  codeowners: repo.owner ? [`@${repo.owner}`] : [],
48
48
  });
49
+ const branch = await remoteDefaultBranch(root);
50
+ if (branch && branch !== "main")
51
+ config.github = { default_branch: branch };
49
52
  const ctx = await buildContext(root, config, repo);
50
53
  const adopt = options.adoptAll ? "all" : new Set(options.adopt);
51
54
  const result = await computeSync(root, planOutputs(ctx), null, { adopt, accept: new Set() });
@@ -1,7 +1,19 @@
1
1
  import { describeOutput } from "../model.js";
2
+ /** How to name the output under `owned`: the path, or `path#key` for one key of a shared file. */
3
+ function ownedName(output) {
4
+ if (output.kind === "yaml" || output.kind === "json")
5
+ return `"${output.path}#${output.keyPath.join(".")}"`;
6
+ if (output.kind === "block")
7
+ return `"${output.path}#${output.id}"`;
8
+ return "it";
9
+ }
2
10
  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`,
11
+ conflict: (o) => `edited locally; take repokeeper's version with --accept ${o.path}, or add ${ownedName(o)} to owned in .repokeeper.yml`,
12
+ unmanaged: (o) => `exists and is not managed; let repokeeper manage it with --adopt ${o.path}, or add ${ownedName(o)} to owned`,
13
+ };
14
+ const REMOVAL_NOTES = {
15
+ "orphan-edited": " (no longer generated, but edited locally; left in place)",
16
+ left: " (no longer generated; left for the tools that use it, delete it if none do)",
5
17
  };
6
18
  export function printResult(io, result) {
7
19
  let unchanged = 0;
@@ -10,18 +22,18 @@ export function printResult(io, result) {
10
22
  unchanged++;
11
23
  continue;
12
24
  }
13
- const hint = HINTS[action]?.(output.path);
25
+ const hint = HINTS[action]?.(output);
14
26
  io.out(`${action.padEnd(10)} ${describeOutput(output)}${hint ? ` (${hint})` : ""}`);
15
27
  }
16
28
  for (const { entry, action } of result.removals) {
17
- if (action === "gone")
29
+ if (action === "gone" || action === "release")
18
30
  continue;
19
- const label = action === "delete" ? "delete" : "kept";
20
- const note = action === "delete" ? "" : " (no longer generated, but edited locally; left in place)";
31
+ const label = action === "orphan-edited" ? "kept" : action;
32
+ const note = REMOVAL_NOTES[action] ?? "";
21
33
  io.out(`${label.padEnd(10)} ${entry.target.path}${note}`);
22
34
  }
23
35
  io.out(`${unchanged} output(s) already match the standard`);
24
36
  }
25
37
  export function hasDrift(result) {
26
- return result.decisions.some((d) => d.action !== "unchanged") || result.removals.length > 0;
38
+ return result.decisions.some((d) => d.action !== "unchanged") || result.removals.some((r) => r.action !== "release");
27
39
  }
@@ -19,7 +19,11 @@ 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
28
  await guardUncommitted(root, result, options.force);
25
29
  printResult(io, result);
@@ -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,
package/dist/git.js CHANGED
@@ -20,7 +20,19 @@ 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;
24
36
  }
25
37
  export async function gitUserName(root) {
26
38
  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 = {
@@ -123,7 +120,7 @@ export const githubPlatform = {
123
120
  return [];
124
121
  return [
125
122
  workflowKey("ci", path, ["name"], "ci"),
126
- workflowKey("ci", path, ["on"], { pull_request: null, push: { branches: [defaultBranch(ctx)] } }),
123
+ workflowKey("ci", path, ["on"], { pull_request: null, push: { branches: [defaultBranch(ctx.config)] } }),
127
124
  workflowKey("ci", path, ["permissions"], { contents: "read" }),
128
125
  ...jobs,
129
126
  ];
@@ -152,10 +149,10 @@ export const githubPlatform = {
152
149
  kind: "seed",
153
150
  module: "release",
154
151
  path: ".release-please-manifest.json",
155
- content: json({ ".": release.version ?? "0.0.0" }),
152
+ content: json({ ".": release.version ?? ctx.repo.releasedVersion ?? "0.0.0" }),
156
153
  },
157
154
  workflowKey("release", path, ["name"], "release"),
158
- workflowKey("release", path, ["on"], { push: { branches: [defaultBranch(ctx)] } }),
155
+ workflowKey("release", path, ["on"], { push: { branches: [defaultBranch(ctx.config)] } }),
159
156
  workflowKey("release", path, ["permissions"], { contents: "read" }),
160
157
  workflowKey("release", path, ["jobs", "release"], {
161
158
  uses: workflowRef(ctx, "release-please.yml"),
@@ -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.1";
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.1.0",
3
+ "version": "0.2.2",
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": {