@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.
@@ -0,0 +1,33 @@
1
+ // Shared fixtures for sync/drift/uninstall tests. Not a *.test.mjs file, so
2
+ // `node --test *.test.mjs` never picks it up as a test suite on its own.
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+
8
+ export function makeTempRepo(manifest, { packageJson } = {}) {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "right-git-test-"));
10
+ if (manifest) fs.writeFileSync(path.join(dir, ".rightgit.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
11
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(packageJson || { name: "widgets" }), "utf8");
12
+ return dir;
13
+ }
14
+
15
+ function ghDeps(visibility) {
16
+ return {
17
+ execFile: async (cmd) => {
18
+ if (cmd === "gh") return { stdout: JSON.stringify({ visibility }) };
19
+ throw new Error(`unexpected exec: ${cmd}`);
20
+ },
21
+ };
22
+ }
23
+
24
+ export const PUBLIC_DEPS = ghDeps("PUBLIC");
25
+ export const PRIVATE_DEPS = ghDeps("PRIVATE");
26
+
27
+ export const SAMPLE_MANIFEST = {
28
+ schemaVersion: 1,
29
+ lanes: ["ci", "package-smoke"],
30
+ matrix: { os: ["ubuntu-latest"], node: ["22.13"] },
31
+ packageManager: "pnpm@11.18.0",
32
+ templateVersion: "1.0.0",
33
+ };
package/uninstall.mjs ADDED
@@ -0,0 +1,36 @@
1
+ // Remove every right-git-managed file from a repo, leaving zero managed
2
+ // files (invariant I5). No visibility check — uninstall must always be
3
+ // possible, including from a repo that flipped private after install.
4
+ //
5
+ // F6 fix: `.rightgit.json` is itself a file `install` writes, so it counts
6
+ // as a managed file. The old implementation removed only marker-bearing
7
+ // workflow files and never the manifest, so it leaked `.rightgit.json` —
8
+ // including after an install that failed partway (install writes the
9
+ // manifest *before* running sync, so a sync failure still leaves the
10
+ // manifest on disk with zero workflow files to key off of). Uninstall must
11
+ // remove the manifest unconditionally on presence, independent of whether
12
+ // any workflow files exist.
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { listManagedFiles } from "./sync.mjs";
17
+ import { MANIFEST_FILENAME } from "./manifest.mjs";
18
+
19
+ /** Returns { ok, removed: string[] }. Never throws. */
20
+ export function uninstallCommand(repoPath) {
21
+ const dir = path.join(repoPath, ".github", "workflows");
22
+ const managed = listManagedFiles(repoPath);
23
+ const removed = [];
24
+ for (const filename of managed) {
25
+ fs.rmSync(path.join(dir, filename), { force: true });
26
+ removed.push(filename);
27
+ }
28
+
29
+ const manifestPath = path.join(repoPath, MANIFEST_FILENAME);
30
+ if (fs.existsSync(manifestPath)) {
31
+ fs.rmSync(manifestPath, { force: true });
32
+ removed.push(MANIFEST_FILENAME);
33
+ }
34
+
35
+ return { ok: true, removed };
36
+ }
@@ -0,0 +1,77 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { syncCommand } from "./sync.mjs";
6
+ import { installCommand } from "./install.mjs";
7
+ import { uninstallCommand } from "./uninstall.mjs";
8
+ import { listManagedFiles } from "./sync.mjs";
9
+ import { MANIFEST_FILENAME } from "./manifest.mjs";
10
+ import { makeTempRepo, PUBLIC_DEPS, SAMPLE_MANIFEST } from "./test-support.mjs";
11
+
12
+ test("uninstall removes every managed file, leaving zero behind — including the manifest (F6/I5)", async () => {
13
+ const dir = makeTempRepo(SAMPLE_MANIFEST);
14
+ await syncCommand(dir, { deps: PUBLIC_DEPS });
15
+ assert.equal(listManagedFiles(dir).length, 2);
16
+
17
+ const result = uninstallCommand(dir);
18
+ assert.equal(result.ok, true);
19
+ // 2 workflow files + the manifest itself: I5 requires *zero* managed
20
+ // files left behind, and .rightgit.json is a file `install` wrote.
21
+ assert.equal(result.removed.length, 3);
22
+ assert.ok(result.removed.includes(MANIFEST_FILENAME));
23
+ assert.deepEqual(listManagedFiles(dir), []);
24
+ assert.equal(fs.existsSync(path.join(dir, ".github", "workflows", "ci.yml")), false);
25
+ assert.equal(fs.existsSync(path.join(dir, ".github", "workflows", "package-smoke.yml")), false);
26
+ assert.equal(fs.existsSync(path.join(dir, MANIFEST_FILENAME)), false);
27
+ });
28
+
29
+ // F6 regression: install writes .rightgit.json *before* running sync (see
30
+ // install.mjs), so a repo whose visibility check flips or errors between
31
+ // that write and sync's own visibility re-check is left with an orphaned
32
+ // manifest and zero (or partial) workflow files. The old uninstallCommand
33
+ // only ever looked at marker-bearing workflow files, so it reported success
34
+ // while leaving the manifest behind. Reproduces the QA report's
35
+ // "interruptedInstall" probe.
36
+ test("F6: uninstall removes the manifest left behind by an install interrupted after the visibility check", async () => {
37
+ const dir = makeTempRepo(null);
38
+ let calls = 0;
39
+ const flakyDeps = {
40
+ execFile: async (cmd) => {
41
+ calls += 1;
42
+ if (cmd === "gh" && calls === 1) return { stdout: JSON.stringify({ visibility: "PUBLIC" }) };
43
+ if (cmd === "gh") throw new Error("gh: rate limited");
44
+ throw new Error(`unexpected exec: ${cmd}`);
45
+ },
46
+ };
47
+
48
+ const installResult = await installCommand(dir, { lanes: ["ci"] }, flakyDeps);
49
+ assert.equal(installResult.ok, false);
50
+ assert.equal(installResult.stage, "synced");
51
+ assert.ok(fs.existsSync(path.join(dir, MANIFEST_FILENAME)), "setup: interrupted install must still leave the manifest on disk");
52
+
53
+ const uninstallResult = uninstallCommand(dir);
54
+ assert.equal(uninstallResult.ok, true);
55
+ assert.ok(uninstallResult.removed.includes(MANIFEST_FILENAME));
56
+ assert.equal(fs.existsSync(path.join(dir, MANIFEST_FILENAME)), false, "F6: manifest must not survive uninstall after an interrupted install");
57
+ });
58
+
59
+ test("uninstall never touches an unmanaged file in the same directory", async () => {
60
+ const dir = makeTempRepo(SAMPLE_MANIFEST);
61
+ await syncCommand(dir, { deps: PUBLIC_DEPS });
62
+ const handWritten = path.join(dir, ".github", "workflows", "hand-written.yml");
63
+ fs.writeFileSync(handWritten, "name: hand-written\n", "utf8");
64
+
65
+ uninstallCommand(dir);
66
+ assert.ok(fs.existsSync(handWritten), "uninstall deleted a file it does not manage");
67
+ });
68
+
69
+ test("uninstall on a repo with no managed files is a no-op, not an error", () => {
70
+ // makeTempRepo(null): no manifest, no workflow files — genuinely zero
71
+ // managed state (makeTempRepo(SAMPLE_MANIFEST) would itself write
72
+ // .rightgit.json, which is now correctly treated as managed per F6).
73
+ const dir = makeTempRepo(null);
74
+ const result = uninstallCommand(dir);
75
+ assert.equal(result.ok, true);
76
+ assert.deepEqual(result.removed, []);
77
+ });
@@ -0,0 +1,141 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { assertPublic, resolveVisibility, parseOwnerRepo } from "./policy/visibility-gate.mjs";
4
+
5
+ function ghExecFile(visibility) {
6
+ return async (cmd, cmdArgs) => {
7
+ if (cmd === "gh") return { stdout: JSON.stringify({ visibility }) };
8
+ throw new Error(`unexpected exec: ${cmd} ${cmdArgs.join(" ")}`);
9
+ };
10
+ }
11
+
12
+ function failingGhExecFile(remoteUrl) {
13
+ return async (cmd, cmdArgs) => {
14
+ if (cmd === "gh") throw new Error("gh: command not found");
15
+ if (cmd === "git" && cmdArgs[0] === "remote") return { stdout: `${remoteUrl}\n` };
16
+ throw new Error(`unexpected exec: ${cmd} ${cmdArgs.join(" ")}`);
17
+ };
18
+ }
19
+
20
+ test("assertPublic allows a repo gh reports as public", async () => {
21
+ const result = await assertPublic("/repo", { execFile: ghExecFile("PUBLIC") });
22
+ assert.equal(result.allowed, true);
23
+ assert.equal(result.visibility, "public");
24
+ });
25
+
26
+ test("assertPublic refuses a repo gh reports as private — no override", async () => {
27
+ const result = await assertPublic("/repo", { execFile: ghExecFile("PRIVATE") });
28
+ assert.equal(result.allowed, false);
29
+ assert.equal(result.visibility, "private");
30
+ assert.match(result.reason, /public repos only/);
31
+ });
32
+
33
+ test("assertPublic refuses a repo gh reports as internal", async () => {
34
+ const result = await assertPublic("/repo", { execFile: ghExecFile("INTERNAL") });
35
+ assert.equal(result.allowed, false);
36
+ assert.equal(result.visibility, "internal");
37
+ });
38
+
39
+ test("assertPublic falls back to git-remote + API when gh is unavailable, and allows a confirmed-public repo", async () => {
40
+ const execFile = failingGhExecFile("https://github.com/acme/widgets.git");
41
+ const fetchFn = async () => ({ ok: true, json: async () => ({ private: false }) });
42
+ const result = await assertPublic("/repo", { execFile, fetch: fetchFn });
43
+ assert.equal(result.allowed, true);
44
+ assert.equal(result.source, "git-remote-api");
45
+ });
46
+
47
+ test("assertPublic fails closed to 'unknown' when neither gh nor the API can confirm public", async () => {
48
+ const execFile = failingGhExecFile("https://github.com/acme/widgets.git");
49
+ const fetchFn = async () => ({ ok: false, status: 404 });
50
+ const result = await assertPublic("/repo", { execFile, fetch: fetchFn });
51
+ assert.equal(result.allowed, false);
52
+ assert.equal(result.visibility, "unknown");
53
+ assert.match(result.reason, /fail closed/);
54
+ });
55
+
56
+ test("assertPublic never throws even when every network path errors", async () => {
57
+ const execFile = async () => {
58
+ throw new Error("no network");
59
+ };
60
+ const result = await assertPublic("/repo", { execFile, fetch: async () => { throw new Error("no network"); } });
61
+ assert.equal(result.allowed, false);
62
+ assert.equal(result.visibility, "unknown");
63
+ });
64
+
65
+ test("resolveVisibility never throws and returns a typed result on total failure", async () => {
66
+ const execFile = async () => {
67
+ throw new Error("boom");
68
+ };
69
+ const result = await resolveVisibility("/repo", { execFile });
70
+ assert.equal(result.source, "error");
71
+ assert.equal(result.visibility, "unknown");
72
+ assert.ok(typeof result.error === "string");
73
+ });
74
+
75
+ test("parseOwnerRepo handles https and ssh remote forms", () => {
76
+ assert.deepEqual(parseOwnerRepo("https://github.com/acme/widgets.git"), { owner: "acme", repo: "widgets" });
77
+ assert.deepEqual(parseOwnerRepo("git@github.com:acme/widgets.git"), { owner: "acme", repo: "widgets" });
78
+ assert.equal(parseOwnerRepo("not-a-github-url"), null);
79
+ });
80
+
81
+ // F16 regression: the old repo-name group excluded '.' from its character
82
+ // class, so it could never reach the end-of-string anchor for a dotted repo
83
+ // name — the whole match failed and a legitimate public repo could not be
84
+ // identified through the gh-unavailable fallback. Reproduces the QA report's
85
+ // exact probe input.
86
+ test("F16: parseOwnerRepo identifies a valid GitHub repo whose name contains dots", () => {
87
+ assert.deepEqual(parseOwnerRepo("https://github.com/owner/repo.with.dot.git"), {
88
+ owner: "owner",
89
+ repo: "repo.with.dot",
90
+ });
91
+ assert.deepEqual(parseOwnerRepo("git@github.com:owner/repo.with.dot.git"), {
92
+ owner: "owner",
93
+ repo: "repo.with.dot",
94
+ });
95
+ });
96
+
97
+ // F16 non-regression: an unconfigured enterprise GitHub host must still fail
98
+ // closed to null (the unauthenticated-API fallback only ever targets
99
+ // api.github.com, so this parse never reaches an enterprise instance).
100
+ test("F16: parseOwnerRepo still fails closed for an unconfigured enterprise GitHub host", () => {
101
+ assert.equal(parseOwnerRepo("https://github.example.com/owner/repo.git"), null);
102
+ });
103
+
104
+ // Canonicalization hygiene regression: uppercase host, embedded userinfo
105
+ // (credentials), and a non-standard port are all *genuinely* github.com —
106
+ // DNS is case-insensitive, userinfo is just embedded credentials, and the
107
+ // API query ignores the port. These are not lookalike hosts, so they must
108
+ // still resolve to the real owner/repo rather than fail closed. The WHATWG
109
+ // URL parser already lowercases hostnames and separates userinfo/port from
110
+ // host — parseOwnerRepo's `url.hostname` comparison relies on exactly that,
111
+ // so this locks the behavior in against any future regression that starts
112
+ // comparing against the raw authority string instead of `url.hostname`.
113
+ test("canonicalization: parseOwnerRepo accepts uppercase github.com host", () => {
114
+ assert.deepEqual(parseOwnerRepo("https://GITHUB.COM/owner/repo"), { owner: "owner", repo: "repo" });
115
+ assert.deepEqual(parseOwnerRepo("https://GitHub.com/owner/repo.git"), { owner: "owner", repo: "repo" });
116
+ });
117
+
118
+ test("canonicalization: parseOwnerRepo accepts a github.com remote with embedded userinfo", () => {
119
+ assert.deepEqual(parseOwnerRepo("https://attacker@github.com/victim/repo"), {
120
+ owner: "victim",
121
+ repo: "repo",
122
+ });
123
+ });
124
+
125
+ test("canonicalization: parseOwnerRepo accepts a github.com remote on a non-standard port", () => {
126
+ assert.deepEqual(parseOwnerRepo("https://github.com:8443/owner/repo"), { owner: "owner", repo: "repo" });
127
+ });
128
+
129
+ // Lookalike-family non-regression (N1): every one of these has a host that
130
+ // is NOT github.com — they must keep failing closed to null. Re-asserted
131
+ // here alongside the canonicalization-hygiene cases above so the two
132
+ // concerns (accept real github.com in any authority shape, reject anything
133
+ // that merely contains the substring "github.com") are tested side by side.
134
+ test("N1 lookalikes: parseOwnerRepo still rejects every non-github.com host", () => {
135
+ assert.equal(parseOwnerRepo("https://notgithub.com/owner/repo"), null);
136
+ assert.equal(parseOwnerRepo("https://github.com.evil.io/owner/repo"), null);
137
+ assert.equal(parseOwnerRepo("https://mygithub.com/owner/repo"), null);
138
+ assert.equal(parseOwnerRepo("https://github.com@evil.io/owner/repo"), null);
139
+ assert.equal(parseOwnerRepo("https://github.como/owner/repo"), null);
140
+ assert.equal(parseOwnerRepo("https://github.example.com/owner/repo"), null);
141
+ });