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 +28 -2
- package/dist/commands/check.js +7 -2
- package/dist/commands/init.js +27 -5
- package/dist/commands/report.js +58 -7
- package/dist/commands/update.js +11 -3
- package/dist/config/types.js +5 -0
- package/dist/duplicates.js +36 -0
- package/dist/git.js +21 -1
- package/dist/modules/editorconfig.js +5 -2
- package/dist/modules/health.js +3 -2
- package/dist/owned.js +23 -0
- package/dist/plan.js +3 -3
- package/dist/platforms/github.js +8 -7
- package/dist/stacks/dotnet.js +23 -0
- package/dist/stacks/python.js +9 -3
- package/dist/sync/attributes.js +44 -0
- package/dist/sync/decide.js +2 -2
- package/dist/sync/sync.js +7 -2
- package/dist/sync/yaml.js +121 -8
- package/dist/version.js +1 -1
- package/package.json +1 -1
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.
|
|
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
|
package/dist/commands/check.js
CHANGED
|
@@ -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, {
|
|
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");
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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. */
|
package/dist/commands/report.js
CHANGED
|
@@ -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: (
|
|
4
|
-
unmanaged: (
|
|
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
|
|
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 === "
|
|
20
|
-
const note = action
|
|
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.
|
|
77
|
+
return result.decisions.some((d) => d.action !== "unchanged") || result.removals.some((r) => r.action !== "release");
|
|
27
78
|
}
|
package/dist/commands/update.js
CHANGED
|
@@ -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, {
|
|
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
|
}
|
package/dist/config/types.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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",
|
package/dist/modules/health.js
CHANGED
|
@@ -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
|
-
:
|
|
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
|
|
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
|
-
|
|
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
|
|
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);
|
package/dist/platforms/github.js
CHANGED
|
@@ -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"),
|
package/dist/stacks/dotnet.js
CHANGED
|
@@ -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));
|
package/dist/stacks/python.js
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
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/sync/decide.js
CHANGED
|
@@ -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 (
|
|
19
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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