@rightkit/git 0.1.0
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 +35 -0
- package/adopt.mjs +96 -0
- package/cli/right-git.mjs +207 -0
- package/cli.test.mjs +68 -0
- package/drift.mjs +133 -0
- package/drift.test.mjs +98 -0
- package/guard-coverage.test.mjs +220 -0
- package/install.mjs +72 -0
- package/manifest.mjs +278 -0
- package/manifest.schema.json +85 -0
- package/manifest.test.mjs +196 -0
- package/pack-smoke.test.mjs +55 -0
- package/package.json +30 -0
- package/policy/visibility-gate.mjs +168 -0
- package/render.mjs +35 -0
- package/render.test.mjs +117 -0
- package/sync.mjs +139 -0
- package/sync.test.mjs +84 -0
- package/templates/ci.yml +47 -0
- package/templates/package-smoke.yml +37 -0
- package/templates/publish-npm.yml +52 -0
- package/templates/publish-package-managers.yml +157 -0
- package/templates/qualification.yml +82 -0
- package/templates/release-candidate.yml +48 -0
- package/test-support.mjs +33 -0
- package/uninstall.mjs +36 -0
- package/uninstall.test.mjs +77 -0
- package/visibility-gate.test.mjs +141 -0
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @rightkit/git
|
|
2
|
+
|
|
3
|
+
Public-repo-only GitHub Actions workflow templates for the Right/Orthic product suite. One CLI, `right-git`, that renders and installs CI lanes into a repository — and refuses to touch a private one.
|
|
4
|
+
|
|
5
|
+
## Why public-only
|
|
6
|
+
|
|
7
|
+
GitHub Actions minutes are free and unlimited on public repos, and billed on private ones. `@rightkit/git` enforces that boundary in code, not by convention:
|
|
8
|
+
|
|
9
|
+
1. **Install-time refusal** — `right-git install`/`sync` resolves repo visibility and refuses any private or internal repo, with no override flag. Visibility fails closed: an unconfirmed repo is treated as not-public.
|
|
10
|
+
2. **Run-time guard** — every rendered workflow carries an unconditional `private-repo-guard` job that fails loudly (`exit 1`) if the repo is ever flipped private after install; every other job depends on it.
|
|
11
|
+
3. **Drift audit** — `right-git drift --all` re-checks visibility across managed repos.
|
|
12
|
+
|
|
13
|
+
## What it does
|
|
14
|
+
|
|
15
|
+
- **`right-git install|sync`** — render CI lanes (ci, qualification, package-smoke, release-candidate, publish-npm, publish-package-managers) from a `.rightgit.json` manifest into `.github/workflows/`.
|
|
16
|
+
- **`right-git drift`** — detect divergence between installed workflows and templates, and orphaned managed workflows.
|
|
17
|
+
- **`right-git adopt`** — import existing workflows into the template library.
|
|
18
|
+
- **`right-git uninstall`** — remove every managed file, leaving zero behind.
|
|
19
|
+
|
|
20
|
+
Workflows are thin callers over the repo's own scripts, so a private repo running the same gates locally executes identical checks.
|
|
21
|
+
|
|
22
|
+
## Credential posture (read before enabling package-manager publishing)
|
|
23
|
+
|
|
24
|
+
- **No code-signing credential ever reaches CI.** Signing and notarization stay local in `@rightkit/release`. The only publish credential in the npm lane is short-lived OIDC (trusted publishing, no long-lived token).
|
|
25
|
+
- **Exception, disclosed:** the optional, opt-in `publish-package-managers` lane *does* require two owner-provisioned, long-lived tokens in GitHub Actions `secrets.*` (`HOMEBREW_TAP_TOKEN`, `WINGET_CREATE_GITHUB_TOKEN`) to open tap/manifest PRs. These are real bearer tokens, public-repo-scoped by the visibility gate but not zero-exposure. The "no signing credential in CI" invariant is specifically about code-signing material; it does not mean "no credential of any kind."
|
|
26
|
+
|
|
27
|
+
Manifest input is treated as untrusted: every field flowing into rendered YAML is charset-allowlisted, so a manifest cannot inject `${{ secrets.* }}` or shell/expression syntax.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npm install -g @rightkit/git # or: npx @rightkit/git --help
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Node builtins only; no runtime dependencies.
|
package/adopt.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Pull an existing repo's hand-written workflows into review-ready template
|
|
2
|
+
// candidates (DESIGN.md §4, "Cortex bootstrap"). Read-only against the
|
|
3
|
+
// source repo. Never writes into templates/ directly — adopt's output is
|
|
4
|
+
// mapped lane content for a human (or a follow-up Sage-authorized change)
|
|
5
|
+
// to review and fold in; silently overwriting the curated, parameterized
|
|
6
|
+
// template library from an arbitrary repo's raw workflow text is exactly
|
|
7
|
+
// the kind of unreviewed mutation this package must not perform on its own.
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { LANE_FILENAMES } from "./manifest.mjs";
|
|
12
|
+
|
|
13
|
+
// filename (in the source repo's .github/workflows/) -> lane, for the
|
|
14
|
+
// lanes that map 1:1 onto an existing file.
|
|
15
|
+
const FILENAME_TO_LANE = {
|
|
16
|
+
"ci.yml": "ci",
|
|
17
|
+
"qualification.yml": "qualification",
|
|
18
|
+
"package.yml": "package-smoke",
|
|
19
|
+
"package-smoke.yml": "package-smoke",
|
|
20
|
+
"release-candidate.yml": "release-candidate",
|
|
21
|
+
"publish-npm.yml": "publish-npm",
|
|
22
|
+
"publish-package-managers.yml": "publish-package-managers",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Slice out one top-level job block from a workflow's `jobs:` map by name or predicate. */
|
|
26
|
+
export function extractJobBlock(yamlText, jobNameOrPredicate) {
|
|
27
|
+
const lines = yamlText.split("\n");
|
|
28
|
+
const jobsIdx = lines.findIndex((l) => /^jobs:\s*$/.test(l));
|
|
29
|
+
if (jobsIdx === -1) return null;
|
|
30
|
+
|
|
31
|
+
const jobStarts = [];
|
|
32
|
+
for (let i = jobsIdx + 1; i < lines.length; i++) {
|
|
33
|
+
if (/^ {2}\S.*:\s*$/.test(lines[i])) jobStarts.push(i);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (let idx = 0; idx < jobStarts.length; idx++) {
|
|
37
|
+
const start = jobStarts[idx];
|
|
38
|
+
const end = idx + 1 < jobStarts.length ? jobStarts[idx + 1] : lines.length;
|
|
39
|
+
const nameMatch = /^ {2}(\S+):/.exec(lines[start]);
|
|
40
|
+
const name = nameMatch ? nameMatch[1] : null;
|
|
41
|
+
const block = lines.slice(start, end).join("\n");
|
|
42
|
+
const matches = typeof jobNameOrPredicate === "function" ? jobNameOrPredicate(name, block) : name === jobNameOrPredicate;
|
|
43
|
+
if (matches) return { name, block };
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Inspect <repoPath>/.github/workflows/*.yml and map filenames onto known
|
|
50
|
+
* right-git lanes. Never throws, never writes.
|
|
51
|
+
* Returns { ok, mapped: [{lane, sourceFile, content}], unmapped: string[], errors }
|
|
52
|
+
*/
|
|
53
|
+
export function adoptCommand(repoPath) {
|
|
54
|
+
const dir = path.join(repoPath, ".github", "workflows");
|
|
55
|
+
let entries;
|
|
56
|
+
try {
|
|
57
|
+
entries = fs.readdirSync(dir).filter((n) => n.endsWith(".yml") || n.endsWith(".yaml"));
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return { ok: false, mapped: [], unmapped: [], errors: [`cannot read ${dir}: ${error.message}`] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const mapped = [];
|
|
63
|
+
const unmapped = [];
|
|
64
|
+
const foundLanes = new Set();
|
|
65
|
+
|
|
66
|
+
for (const filename of entries) {
|
|
67
|
+
const lane = FILENAME_TO_LANE[filename];
|
|
68
|
+
if (lane) {
|
|
69
|
+
const content = fs.readFileSync(path.join(dir, filename), "utf8");
|
|
70
|
+
mapped.push({ lane, sourceFile: filename, content });
|
|
71
|
+
foundLanes.add(lane);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
unmapped.push(filename);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// publish-npm has no 1:1 file in the Cortex-shaped source layout — it is a
|
|
78
|
+
// single job inside a larger release workflow (DESIGN.md §6). Best-effort
|
|
79
|
+
// extraction by scanning any remaining file for a job whose steps run
|
|
80
|
+
// `npm publish`.
|
|
81
|
+
if (!foundLanes.has("publish-npm")) {
|
|
82
|
+
for (const filename of unmapped.slice()) {
|
|
83
|
+
const content = fs.readFileSync(path.join(dir, filename), "utf8");
|
|
84
|
+
const job = extractJobBlock(content, (_name, block) => /npm publish/.test(block));
|
|
85
|
+
if (job) {
|
|
86
|
+
mapped.push({ lane: "publish-npm", sourceFile: `${filename}#${job.name}`, content: job.block });
|
|
87
|
+
foundLanes.add("publish-npm");
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { ok: true, mapped, unmapped, errors: [] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export { LANE_FILENAMES };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { installCommand } from "../install.mjs";
|
|
5
|
+
import { syncCommand } from "../sync.mjs";
|
|
6
|
+
import { driftCommand, driftAll } from "../drift.mjs";
|
|
7
|
+
import { adoptCommand } from "../adopt.mjs";
|
|
8
|
+
import { uninstallCommand } from "../uninstall.mjs";
|
|
9
|
+
|
|
10
|
+
const commands = new Map([
|
|
11
|
+
["install", runInstall],
|
|
12
|
+
["sync", runSync],
|
|
13
|
+
["drift", runDrift],
|
|
14
|
+
["adopt", runAdopt],
|
|
15
|
+
["uninstall", runUninstall],
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
// F7 fix: flags that are always boolean and must never consume the next
|
|
19
|
+
// argv token as a value. Without this, `drift --all <root>` (the documented
|
|
20
|
+
// syntax — see printHelp below) fed `<root>` to parseFlags' generic
|
|
21
|
+
// "--flag value" branch as the *value* of --all, leaving `positional` empty;
|
|
22
|
+
// runDrift then fell back to `.` and silently scanned the current working
|
|
23
|
+
// directory instead of the supplied workspace root. `--all=true` happened to
|
|
24
|
+
// work because the `=` form never consumes a following token. Declared here
|
|
25
|
+
// (above the dispatch below, which calls parseFlags synchronously at module
|
|
26
|
+
// load) rather than next to parseFlags itself, to avoid a temporal-dead-zone
|
|
27
|
+
// ReferenceError on a const declared after its first use point at runtime.
|
|
28
|
+
const BOOLEAN_ONLY_FLAGS = new Set(["all"]);
|
|
29
|
+
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
const first = args[0];
|
|
32
|
+
|
|
33
|
+
if (!first || first === "-h" || first === "--help") {
|
|
34
|
+
printHelp();
|
|
35
|
+
process.exit(first ? 0 : 1);
|
|
36
|
+
} else if (first === "--version" || first === "-v") {
|
|
37
|
+
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
38
|
+
console.log(pkg.version);
|
|
39
|
+
process.exit(0);
|
|
40
|
+
} else if (commands.has(first)) {
|
|
41
|
+
const exitCode = await commands.get(first)(args.slice(1));
|
|
42
|
+
process.exit(exitCode);
|
|
43
|
+
} else {
|
|
44
|
+
console.error(`right-git: unknown command: ${first}\n`);
|
|
45
|
+
printHelp();
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Split argv into positional args and --flag / --flag=value pairs. */
|
|
50
|
+
function parseFlags(argv) {
|
|
51
|
+
const positional = [];
|
|
52
|
+
const flags = {};
|
|
53
|
+
for (let i = 0; i < argv.length; i++) {
|
|
54
|
+
const arg = argv[i];
|
|
55
|
+
if (arg.startsWith("--")) {
|
|
56
|
+
const eq = arg.indexOf("=");
|
|
57
|
+
if (eq !== -1) {
|
|
58
|
+
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
59
|
+
} else {
|
|
60
|
+
const key = arg.slice(2);
|
|
61
|
+
if (!BOOLEAN_ONLY_FLAGS.has(key) && i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
|
|
62
|
+
flags[key] = argv[++i];
|
|
63
|
+
} else {
|
|
64
|
+
flags[key] = true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
positional.push(arg);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { positional, flags };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function printErrors(errors) {
|
|
75
|
+
for (const error of errors || []) console.error(` ${error}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function runInstall(argv) {
|
|
79
|
+
const { positional, flags } = parseFlags(argv);
|
|
80
|
+
const repoPath = positional[0];
|
|
81
|
+
if (!repoPath) {
|
|
82
|
+
console.error("right-git install <repo-path> --lanes ci,qualification,...");
|
|
83
|
+
return 2;
|
|
84
|
+
}
|
|
85
|
+
const opts = {
|
|
86
|
+
lanes: typeof flags.lanes === "string" ? flags.lanes.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
|
|
87
|
+
homebrewTap: flags["homebrew-tap"],
|
|
88
|
+
homebrewFormula: flags["homebrew-formula"],
|
|
89
|
+
wingetId: flags["winget-id"],
|
|
90
|
+
provenance: flags["no-provenance"] ? false : undefined,
|
|
91
|
+
};
|
|
92
|
+
const result = await installCommand(path.resolve(repoPath), opts);
|
|
93
|
+
if (!result.ok) {
|
|
94
|
+
console.error(`right-git install: failed at stage '${result.stage}'`);
|
|
95
|
+
printErrors(result.errors);
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
console.log(`right-git install: wrote ${result.manifestPath} and synced ${result.plan.changes.length} workflow file(s).`);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function runSync(argv) {
|
|
103
|
+
const { positional, flags } = parseFlags(argv);
|
|
104
|
+
const repoPath = positional[0];
|
|
105
|
+
if (!repoPath) {
|
|
106
|
+
console.error("right-git sync <repo-path> [--dry-run]");
|
|
107
|
+
return 2;
|
|
108
|
+
}
|
|
109
|
+
const result = await syncCommand(path.resolve(repoPath), { dryRun: Boolean(flags["dry-run"]) });
|
|
110
|
+
if (!result.ok) {
|
|
111
|
+
console.error(`right-git sync: failed at stage '${result.stage}'`);
|
|
112
|
+
printErrors(result.errors);
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
const changed = result.changes.filter((c) => c.action !== "unchanged");
|
|
116
|
+
console.log(changed.length === 0 ? "right-git sync: already up to date." : `right-git sync: ${changed.length} file(s) ${flags["dry-run"] ? "would change" : "changed"}.`);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function runDrift(argv) {
|
|
121
|
+
const { positional, flags } = parseFlags(argv);
|
|
122
|
+
if (flags.all) {
|
|
123
|
+
const root = path.resolve(positional[0] || ".");
|
|
124
|
+
const results = await driftAll(root);
|
|
125
|
+
let anyDrift = false;
|
|
126
|
+
for (const { repoPath, result } of results) {
|
|
127
|
+
if (!result.ok) {
|
|
128
|
+
console.error(`${repoPath}: error`);
|
|
129
|
+
printErrors(result.errors);
|
|
130
|
+
anyDrift = true;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
console.log(`${repoPath}: visibility=${result.visibility.visibility} drift=${result.driftDetected}`);
|
|
134
|
+
for (const finding of result.findings) {
|
|
135
|
+
if (finding.status !== "match") console.log(` ${finding.status}: ${finding.filename}`);
|
|
136
|
+
}
|
|
137
|
+
if (result.driftDetected) anyDrift = true;
|
|
138
|
+
}
|
|
139
|
+
return anyDrift ? 1 : 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const repoPath = positional[0];
|
|
143
|
+
if (!repoPath) {
|
|
144
|
+
console.error("right-git drift <repo-path> | right-git drift --all [workspace-root]");
|
|
145
|
+
return 2;
|
|
146
|
+
}
|
|
147
|
+
const result = await driftCommand(path.resolve(repoPath));
|
|
148
|
+
if (!result.ok) {
|
|
149
|
+
console.error("right-git drift: failed");
|
|
150
|
+
printErrors(result.errors);
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
console.log(`visibility=${result.visibility.visibility} drift=${result.driftDetected}`);
|
|
154
|
+
for (const finding of result.findings) console.log(` ${finding.status}: ${finding.filename}`);
|
|
155
|
+
return result.driftDetected ? 1 : 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function runAdopt(argv) {
|
|
159
|
+
const { positional, flags } = parseFlags(argv);
|
|
160
|
+
const repoPath = positional[0];
|
|
161
|
+
if (!repoPath) {
|
|
162
|
+
console.error("right-git adopt <repo-path> [--out <dir>]");
|
|
163
|
+
return 2;
|
|
164
|
+
}
|
|
165
|
+
const result = adoptCommand(path.resolve(repoPath));
|
|
166
|
+
if (!result.ok) {
|
|
167
|
+
console.error("right-git adopt: failed");
|
|
168
|
+
printErrors(result.errors);
|
|
169
|
+
return 1;
|
|
170
|
+
}
|
|
171
|
+
for (const { lane, sourceFile } of result.mapped) console.log(`mapped ${lane} <- ${sourceFile}`);
|
|
172
|
+
for (const filename of result.unmapped) console.log(`unmapped: ${filename}`);
|
|
173
|
+
if (typeof flags.out === "string") {
|
|
174
|
+
fs.mkdirSync(flags.out, { recursive: true });
|
|
175
|
+
for (const { lane, content } of result.mapped) {
|
|
176
|
+
fs.writeFileSync(path.join(flags.out, `${lane}.yml`), content, "utf8");
|
|
177
|
+
}
|
|
178
|
+
console.log(`right-git adopt: wrote ${result.mapped.length} candidate template(s) to ${flags.out} for review.`);
|
|
179
|
+
}
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function runUninstall(argv) {
|
|
184
|
+
const { positional } = parseFlags(argv);
|
|
185
|
+
const repoPath = positional[0];
|
|
186
|
+
if (!repoPath) {
|
|
187
|
+
console.error("right-git uninstall <repo-path>");
|
|
188
|
+
return 2;
|
|
189
|
+
}
|
|
190
|
+
const result = uninstallCommand(path.resolve(repoPath));
|
|
191
|
+
console.log(result.removed.length === 0 ? "right-git uninstall: no managed files found." : `right-git uninstall: removed ${result.removed.length} file(s): ${result.removed.join(", ")}`);
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function printHelp() {
|
|
196
|
+
console.log(`right-git <command> [options]
|
|
197
|
+
|
|
198
|
+
Commands:
|
|
199
|
+
install <repo-path> --lanes ci,qualification,... Write .rightgit.json and render workflows; refuses private repos
|
|
200
|
+
sync <repo-path> [--dry-run] Re-render templates from .rightgit.json, show diff, apply
|
|
201
|
+
drift <repo-path> CI-safe check: installed workflows == rendered templates?
|
|
202
|
+
drift --all [workspace-root] Drift-check + re-check visibility for every managed repo found
|
|
203
|
+
adopt <repo-path> [--out <dir>] Map an existing repo's workflows onto right-git lanes for review
|
|
204
|
+
uninstall <repo-path> Remove every right-git-managed workflow file
|
|
205
|
+
|
|
206
|
+
Public-repo only: install/sync refuse private, internal, or unconfirmed-visibility repos. No override flag (invariant I1).`);
|
|
207
|
+
}
|
package/cli.test.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// CLI-level regression coverage for right-git.mjs. cli/right-git.mjs is a
|
|
2
|
+
// true entrypoint (it dispatches on process.argv at module-load time), not a
|
|
3
|
+
// library module, so its argument parsing can only be exercised honestly by
|
|
4
|
+
// spawning it as a real subprocess with real argv — the same way the QA
|
|
5
|
+
// report reproduced F7.
|
|
6
|
+
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { SAMPLE_MANIFEST } from "./test-support.mjs";
|
|
15
|
+
|
|
16
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
|
|
17
|
+
const cliPath = path.join(packageRoot, "cli", "right-git.mjs");
|
|
18
|
+
|
|
19
|
+
function runCli(args, cwd) {
|
|
20
|
+
return spawnSync(process.execPath, [cliPath, ...args], { cwd, encoding: "utf8" });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// F7 regression: `right-git drift --all <root>` is the documented syntax
|
|
24
|
+
// (see printHelp: "drift --all [workspace-root]"). The old parseFlags
|
|
25
|
+
// treated `--all` like any other "--flag value" pair, so it consumed the
|
|
26
|
+
// root path as --all's *value* instead of leaving it positional; runDrift
|
|
27
|
+
// then fell back to `path.resolve(positional[0] || ".")`, silently scanning
|
|
28
|
+
// the current working directory instead of the supplied root. Only the
|
|
29
|
+
// undocumented `--all=true <root>` form worked. Prove the fix by running the
|
|
30
|
+
// CLI from a cwd that has no managed repos, with the real fixture root
|
|
31
|
+
// passed as a plain trailing argument, and asserting the fixture repo is the
|
|
32
|
+
// one actually discovered.
|
|
33
|
+
test("F7: `right-git drift --all <root>` (space-separated) scopes the scan to the given root, not process.cwd()", () => {
|
|
34
|
+
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "right-git-cli-root-"));
|
|
35
|
+
const repoDir = path.join(fixtureRoot, "managed-repo");
|
|
36
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
37
|
+
fs.writeFileSync(path.join(repoDir, ".rightgit.json"), `${JSON.stringify(SAMPLE_MANIFEST, null, 2)}\n`, "utf8");
|
|
38
|
+
fs.writeFileSync(path.join(repoDir, "package.json"), JSON.stringify({ name: "widgets" }), "utf8");
|
|
39
|
+
|
|
40
|
+
// Deliberately not fixtureRoot and carries no managed repo of its own —
|
|
41
|
+
// if the bug is present, the CLI scans this directory instead and the
|
|
42
|
+
// fixture repo is never mentioned in the output.
|
|
43
|
+
const emptyCwd = fs.mkdtempSync(path.join(os.tmpdir(), "right-git-cli-cwd-"));
|
|
44
|
+
|
|
45
|
+
const result = runCli(["drift", "--all", fixtureRoot], emptyCwd);
|
|
46
|
+
|
|
47
|
+
const output = `${result.stdout}${result.stderr}`;
|
|
48
|
+
assert.ok(
|
|
49
|
+
output.includes(repoDir),
|
|
50
|
+
`expected 'drift --all ${fixtureRoot}' (run from an unrelated cwd) to discover ${repoDir}; got stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`,
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Non-regression: the workaround documented by the QA report, `--all=true`
|
|
55
|
+
// followed by the root, must keep working exactly as before.
|
|
56
|
+
test("`right-git drift --all=true <root>` still scopes the scan to the given root", () => {
|
|
57
|
+
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "right-git-cli-root-eq-"));
|
|
58
|
+
const repoDir = path.join(fixtureRoot, "managed-repo");
|
|
59
|
+
fs.mkdirSync(repoDir, { recursive: true });
|
|
60
|
+
fs.writeFileSync(path.join(repoDir, ".rightgit.json"), `${JSON.stringify(SAMPLE_MANIFEST, null, 2)}\n`, "utf8");
|
|
61
|
+
fs.writeFileSync(path.join(repoDir, "package.json"), JSON.stringify({ name: "widgets" }), "utf8");
|
|
62
|
+
|
|
63
|
+
const emptyCwd = fs.mkdtempSync(path.join(os.tmpdir(), "right-git-cli-cwd-eq-"));
|
|
64
|
+
const result = runCli(["drift", `--all=true`, fixtureRoot], emptyCwd);
|
|
65
|
+
|
|
66
|
+
const output = `${result.stdout}${result.stderr}`;
|
|
67
|
+
assert.ok(output.includes(repoDir), `expected discovery of ${repoDir}; got stdout=${JSON.stringify(result.stdout)} stderr=${JSON.stringify(result.stderr)}`);
|
|
68
|
+
});
|
package/drift.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Compare installed workflows against what the manifest would render today
|
|
2
|
+
// (DESIGN.md §4, invariant I4: no silent forks). `--all` enumerates every
|
|
3
|
+
// repo carrying a `.rightgit.json` under a workspace root and re-checks
|
|
4
|
+
// visibility, flagging public->private flips (DESIGN.md §5 layer 3).
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { assertPublic } from "./policy/visibility-gate.mjs";
|
|
9
|
+
import { loadManifest } from "./manifest.mjs";
|
|
10
|
+
import { renderLanes, listManagedFiles } from "./sync.mjs";
|
|
11
|
+
import { MANAGED_MARKER } from "./render.mjs";
|
|
12
|
+
|
|
13
|
+
const IGNORED_DIRS = new Set(["node_modules", ".git", ".pnpm-store"]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Drift-check one repo. Never throws. Returns:
|
|
17
|
+
* { ok, driftDetected, visibility, findings: [{filename, status}], errors }
|
|
18
|
+
* status is one of: match | mismatch | missing | unmanaged-extra | orphan
|
|
19
|
+
*/
|
|
20
|
+
export async function driftCommand(repoPath, deps = {}) {
|
|
21
|
+
const visibility = await assertPublic(repoPath, deps);
|
|
22
|
+
|
|
23
|
+
const loaded = loadManifest(repoPath);
|
|
24
|
+
if (!loaded.ok) {
|
|
25
|
+
// F7: a repo can carry right-git-managed workflow files (identified by
|
|
26
|
+
// MANAGED_MARKER) with no readable .rightgit.json — e.g. the manifest
|
|
27
|
+
// was deleted, never committed, or lost in an interrupted install. That
|
|
28
|
+
// is drift to report, not a tool failure to hide behind: the old
|
|
29
|
+
// behavior returned a hard `ok:false` here with no findings, which made
|
|
30
|
+
// `findManagedRepos` (below) — and therefore `drift --all` — blind to
|
|
31
|
+
// exactly this repo.
|
|
32
|
+
const orphanManaged = listManagedFiles(repoPath);
|
|
33
|
+
if (orphanManaged.length > 0) {
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
driftDetected: true,
|
|
37
|
+
visibility,
|
|
38
|
+
findings: orphanManaged.map((filename) => ({ filename, status: "orphan" })),
|
|
39
|
+
errors: [],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return { ok: false, driftDetected: null, visibility, findings: [], errors: loaded.errors };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const rendered = renderLanes(loaded.manifest, repoPath);
|
|
46
|
+
const wantedFilenames = new Set(rendered.map((r) => r.filename));
|
|
47
|
+
const findings = [];
|
|
48
|
+
|
|
49
|
+
for (const { filename, content } of rendered) {
|
|
50
|
+
const target = path.join(repoPath, ".github", "workflows", filename);
|
|
51
|
+
let existing = null;
|
|
52
|
+
try {
|
|
53
|
+
existing = fs.readFileSync(target, "utf8");
|
|
54
|
+
} catch {
|
|
55
|
+
// not installed
|
|
56
|
+
}
|
|
57
|
+
if (existing === null) {
|
|
58
|
+
findings.push({ filename, status: "missing" });
|
|
59
|
+
} else if (existing !== content) {
|
|
60
|
+
findings.push({ filename, status: "mismatch" });
|
|
61
|
+
} else {
|
|
62
|
+
findings.push({ filename, status: "match" });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const managedFilename of listManagedFiles(repoPath)) {
|
|
67
|
+
if (!wantedFilenames.has(managedFilename)) {
|
|
68
|
+
findings.push({ filename: managedFilename, status: "unmanaged-extra" });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const driftDetected = findings.some((f) => f.status !== "match") || !visibility.allowed;
|
|
73
|
+
return { ok: true, driftDetected, visibility, findings, errors: [] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Recursively find every repo under root carrying right-git-managed state:
|
|
78
|
+
* either a `.rightgit.json` manifest, or (F7) a `.github/workflows/*.yml`
|
|
79
|
+
* file that starts with MANAGED_MARKER with no manifest present — an
|
|
80
|
+
* "orphan" repo the old implementation never discovered because it looked
|
|
81
|
+
* only for the manifest filename.
|
|
82
|
+
*/
|
|
83
|
+
export function findManagedRepos(root) {
|
|
84
|
+
const found = new Set();
|
|
85
|
+
const stack = [root];
|
|
86
|
+
while (stack.length > 0) {
|
|
87
|
+
const dir = stack.pop();
|
|
88
|
+
let entries;
|
|
89
|
+
try {
|
|
90
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
91
|
+
} catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (entries.some((e) => e.isFile() && e.name === ".rightgit.json")) {
|
|
96
|
+
found.add(dir);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (path.basename(dir) === "workflows" && path.basename(path.dirname(dir)) === ".github") {
|
|
100
|
+
const hasManagedWorkflow = entries.some(
|
|
101
|
+
(e) => e.isFile() && (e.name.endsWith(".yml") || e.name.endsWith(".yaml")) && startsWithManagedMarker(path.join(dir, e.name)),
|
|
102
|
+
);
|
|
103
|
+
if (hasManagedWorkflow) {
|
|
104
|
+
found.add(path.dirname(path.dirname(dir)));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (entry.isDirectory() && !IGNORED_DIRS.has(entry.name)) {
|
|
110
|
+
stack.push(path.join(dir, entry.name));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [...found];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function startsWithManagedMarker(filePath) {
|
|
118
|
+
try {
|
|
119
|
+
return fs.readFileSync(filePath, "utf8").startsWith(MANAGED_MARKER);
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `right-git drift --all <workspace-root>`: drift-check every managed repo found under root. */
|
|
126
|
+
export async function driftAll(workspaceRoot, deps = {}) {
|
|
127
|
+
const repos = findManagedRepos(workspaceRoot);
|
|
128
|
+
const results = [];
|
|
129
|
+
for (const repoPath of repos) {
|
|
130
|
+
results.push({ repoPath, result: await driftCommand(repoPath, deps) });
|
|
131
|
+
}
|
|
132
|
+
return results;
|
|
133
|
+
}
|
package/drift.test.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { syncCommand } from "./sync.mjs";
|
|
7
|
+
import { driftCommand, findManagedRepos } from "./drift.mjs";
|
|
8
|
+
import { makeTempRepo, PUBLIC_DEPS, PRIVATE_DEPS, SAMPLE_MANIFEST } from "./test-support.mjs";
|
|
9
|
+
|
|
10
|
+
test("drift reports no drift right after a clean sync", async () => {
|
|
11
|
+
const dir = makeTempRepo(SAMPLE_MANIFEST);
|
|
12
|
+
await syncCommand(dir, { deps: PUBLIC_DEPS });
|
|
13
|
+
const result = await driftCommand(dir, PUBLIC_DEPS);
|
|
14
|
+
assert.equal(result.ok, true);
|
|
15
|
+
assert.equal(result.driftDetected, false);
|
|
16
|
+
assert.ok(result.findings.every((f) => f.status === "match"));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("drift detects a hand-mutated managed file", async () => {
|
|
20
|
+
const dir = makeTempRepo(SAMPLE_MANIFEST);
|
|
21
|
+
await syncCommand(dir, { deps: PUBLIC_DEPS });
|
|
22
|
+
const ciPath = path.join(dir, ".github", "workflows", "ci.yml");
|
|
23
|
+
fs.appendFileSync(ciPath, "\n# hand edit\n");
|
|
24
|
+
|
|
25
|
+
const result = await driftCommand(dir, PUBLIC_DEPS);
|
|
26
|
+
assert.equal(result.ok, true);
|
|
27
|
+
assert.equal(result.driftDetected, true);
|
|
28
|
+
const ciFinding = result.findings.find((f) => f.filename === "ci.yml");
|
|
29
|
+
assert.equal(ciFinding.status, "mismatch");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("drift flags driftDetected when a lane's file is missing", async () => {
|
|
33
|
+
const dir = makeTempRepo(SAMPLE_MANIFEST);
|
|
34
|
+
await syncCommand(dir, { deps: PUBLIC_DEPS });
|
|
35
|
+
fs.rmSync(path.join(dir, ".github", "workflows", "ci.yml"));
|
|
36
|
+
|
|
37
|
+
const result = await driftCommand(dir, PUBLIC_DEPS);
|
|
38
|
+
assert.equal(result.driftDetected, true);
|
|
39
|
+
assert.ok(result.findings.some((f) => f.filename === "ci.yml" && f.status === "missing"));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("drift flags a public->private visibility flip even with no file drift", async () => {
|
|
43
|
+
const dir = makeTempRepo(SAMPLE_MANIFEST);
|
|
44
|
+
await syncCommand(dir, { deps: PUBLIC_DEPS });
|
|
45
|
+
const result = await driftCommand(dir, PRIVATE_DEPS);
|
|
46
|
+
assert.equal(result.driftDetected, true);
|
|
47
|
+
assert.equal(result.visibility.allowed, false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("findManagedRepos finds every directory carrying a .rightgit.json under a root", () => {
|
|
51
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "right-git-root-"));
|
|
52
|
+
const repoA = makeTempRepo(SAMPLE_MANIFEST);
|
|
53
|
+
const repoB = makeTempRepo(SAMPLE_MANIFEST);
|
|
54
|
+
fs.cpSync(repoA, path.join(root, "repo-a"), { recursive: true });
|
|
55
|
+
fs.cpSync(repoB, path.join(root, "repo-b"), { recursive: true });
|
|
56
|
+
fs.mkdirSync(path.join(root, "not-managed"));
|
|
57
|
+
|
|
58
|
+
const found = findManagedRepos(root).map((p) => path.basename(p)).sort();
|
|
59
|
+
assert.deepEqual(found, ["repo-a", "repo-b"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// F7 regression: a repo whose .rightgit.json was deleted (or never
|
|
63
|
+
// committed) but whose .github/workflows/ still carries MANAGED_MARKER
|
|
64
|
+
// files is an "orphan" — right-git put those workflows there, but the
|
|
65
|
+
// manifest-only scan in the old findManagedRepos could never see it, so
|
|
66
|
+
// `drift --all` silently skipped exactly the repos most likely to be
|
|
67
|
+
// drifted (missing manifest is itself drift).
|
|
68
|
+
test("findManagedRepos finds an orphan repo: managed workflow files present, .rightgit.json missing", () => {
|
|
69
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "right-git-orphan-root-"));
|
|
70
|
+
const dir = makeTempRepo(SAMPLE_MANIFEST);
|
|
71
|
+
fs.cpSync(dir, path.join(root, "orphan-repo"), { recursive: true });
|
|
72
|
+
|
|
73
|
+
const orphanDir = path.join(root, "orphan-repo");
|
|
74
|
+
// A synced install would leave workflow files; simulate that state
|
|
75
|
+
// directly, then remove the manifest to reproduce "manifest lost, workflow
|
|
76
|
+
// files remain".
|
|
77
|
+
fs.mkdirSync(path.join(orphanDir, ".github", "workflows"), { recursive: true });
|
|
78
|
+
fs.writeFileSync(
|
|
79
|
+
path.join(orphanDir, ".github", "workflows", "ci.yml"),
|
|
80
|
+
"# Managed by right-git — do not hand-edit. Run `right-git sync` to update.\nname: ci\n",
|
|
81
|
+
"utf8",
|
|
82
|
+
);
|
|
83
|
+
fs.rmSync(path.join(orphanDir, ".rightgit.json"));
|
|
84
|
+
|
|
85
|
+
const found = findManagedRepos(root).map((p) => path.basename(p)).sort();
|
|
86
|
+
assert.deepEqual(found, ["orphan-repo"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("driftCommand reports an orphan repo as drift instead of a hard failure", async () => {
|
|
90
|
+
const dir = makeTempRepo(SAMPLE_MANIFEST);
|
|
91
|
+
await syncCommand(dir, { deps: PUBLIC_DEPS });
|
|
92
|
+
fs.rmSync(path.join(dir, ".rightgit.json"));
|
|
93
|
+
|
|
94
|
+
const result = await driftCommand(dir, PUBLIC_DEPS);
|
|
95
|
+
assert.equal(result.ok, true);
|
|
96
|
+
assert.equal(result.driftDetected, true);
|
|
97
|
+
assert.ok(result.findings.some((f) => f.status === "orphan" && f.filename === "ci.yml"));
|
|
98
|
+
});
|