@cldmv/git-embedded 1.0.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -0
- package/bin/git-embedded.mjs +0 -0
- package/docs/design.md +102 -31
- package/hooks/pre-push +162 -0
- package/hooks/reference-transaction +136 -35
- package/hooks/update-embedded-repos +0 -0
- package/package.json +8 -5
- package/src/api/cli/export.mjs +71 -0
- package/src/api/cli/init.mjs +1 -0
- package/src/api/cli/install-hooks.mjs +1 -0
- package/src/api/cli/link.mjs +55 -8
- package/src/api/cli/print-hook-script.mjs +1 -0
- package/src/api/cli/record.mjs +36 -0
- package/src/api/cli/restore.mjs +74 -0
- package/src/api/cli/sync.mjs +70 -0
- package/src/api/commander/custom-help.mjs +7 -1
- package/src/api/embedded/branch.mjs +62 -0
- package/src/api/embedded/gitlinks.mjs +45 -0
- package/src/api/embedded/manifest.mjs +71 -0
- package/src/api/embedded/record.mjs +46 -0
- package/src/api/embedded/registry.mjs +119 -0
- package/src/api/embedded/resolve.mjs +87 -0
- package/src/api/embedded/restore.mjs +242 -0
- package/src/api/embedded/sync.mjs +255 -0
- package/src/api/git.mjs +3 -0
- package/src/api/install/hooks.mjs +3 -2
- package/src/api/report.mjs +3 -0
- package/src/lib/elevate-windows-child.mjs +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
function git(args, opts = {}) {
|
|
4
|
+
const res = context.spawnSync("git", args, { encoding: "utf8", ...opts });
|
|
5
|
+
return { code: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Branch helpers for embedded children — inference of the branch a pin lives
|
|
10
|
+
* on, and attaching a child to a branch at a pin. Used by `restore` (initial
|
|
11
|
+
* branch-aware checkout) and `sync` (day-2 fast-forward of the registered
|
|
12
|
+
* branch).
|
|
13
|
+
*
|
|
14
|
+
* @namespace api.embedded.branch
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Infer the branch a pinned commit lives on: exactly ONE `origin` remote
|
|
19
|
+
* branch must contain the pin, otherwise inference declines (returns null) and
|
|
20
|
+
* the caller keeps detached-HEAD behavior.
|
|
21
|
+
*
|
|
22
|
+
* Full refnames (`%(refname)`) are load-bearing: `origin/HEAD` short-forms to
|
|
23
|
+
* bare `origin`, which would enter the candidate set as a phantom "branch" and
|
|
24
|
+
* poison the uniqueness check. Matching `refs/remotes/origin/<name>` and
|
|
25
|
+
* excluding `HEAD` explicitly keeps the symref out.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} childDir absolute path of the child working tree
|
|
28
|
+
* @param {string} sha the pinned commit
|
|
29
|
+
* @returns {string|null} the single containing branch name, or null when the
|
|
30
|
+
* pin is on no remote branch or more than one (ambiguous)
|
|
31
|
+
*/
|
|
32
|
+
export function infer(childDir, sha) {
|
|
33
|
+
const res = git(["-C", childDir, "branch", "-r", "--contains", sha, "--format=%(refname)"]);
|
|
34
|
+
if (res.code !== 0) return null;
|
|
35
|
+
const names = res.stdout
|
|
36
|
+
.split(/\r?\n/)
|
|
37
|
+
.map((line) => (line.match(/^refs\/remotes\/origin\/(?!HEAD$)(.+)$/) || [])[1])
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
const unique = new Set(names);
|
|
40
|
+
return unique.size === 1 ? names[0] : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Put a child ON `branch` at `sha`: `checkout -B` (create or reset the local
|
|
45
|
+
* branch at the pin) plus a soft `--set-upstream-to=origin/<branch>` — soft
|
|
46
|
+
* because a registered branch need not exist on the remote (a local working
|
|
47
|
+
* branch is legitimate), and tracking is a convenience, not a correctness
|
|
48
|
+
* requirement.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} childDir absolute path of the child working tree
|
|
51
|
+
* @param {string} branch branch name to attach
|
|
52
|
+
* @param {string} sha the pinned commit the branch should point at
|
|
53
|
+
* @returns {boolean} true when the checkout succeeded (upstream is best-effort)
|
|
54
|
+
*/
|
|
55
|
+
export function attach(childDir, branch, sha) {
|
|
56
|
+
const checkout = git(["-C", childDir, "checkout", "--quiet", "-B", branch, sha]);
|
|
57
|
+
if (checkout.code !== 0) return false;
|
|
58
|
+
git(["-C", childDir, "branch", `--set-upstream-to=origin/${branch}`, "--", branch]);
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default { infer, attach };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
function git(args, opts = {}) {
|
|
4
|
+
const res = context.spawnSync("git", args, { encoding: "utf8", ...opts });
|
|
5
|
+
return { code: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Enumerate the anonymous gitlinks recorded in the parent's HEAD tree.
|
|
10
|
+
*
|
|
11
|
+
* Reads `git ls-tree -r HEAD` and keeps only mode-`160000` / type-`commit`
|
|
12
|
+
* entries — the same detection the `update-embedded-repos` and
|
|
13
|
+
* `reference-transaction` hooks use. No `.gitmodules` is consulted; the pinned
|
|
14
|
+
* SHA in the parent tree is the only committed information about a child.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} [cwd] working directory inside the parent repo (default: cwd)
|
|
17
|
+
* @returns {Array<{ path: string, sha: string }>} gitlink path + pinned SHA,
|
|
18
|
+
* in tree order. Empty when HEAD has no gitlinks or `cwd` is not a repo.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const links = self.embedded.gitlinks();
|
|
22
|
+
* // → [{ path: "tests", sha: "a1b2c3…" }, { path: "vendor/foo", sha: "d4e5…" }]
|
|
23
|
+
*/
|
|
24
|
+
export default function gitlinks(cwd = process.cwd()) {
|
|
25
|
+
const res = git(["ls-tree", "-r", "HEAD"], { cwd });
|
|
26
|
+
if (res.code !== 0) return [];
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const line of res.stdout.split(/\r?\n/)) {
|
|
29
|
+
if (!line) continue;
|
|
30
|
+
// <mode> SP <type> SP <sha> TAB <path>
|
|
31
|
+
const tab = line.indexOf("\t");
|
|
32
|
+
/* v8 ignore next -- defensive: `git ls-tree -r HEAD` always emits
|
|
33
|
+
`<mode> SP <type> SP <sha> TAB <path>`, so a non-empty line with no tab is
|
|
34
|
+
unreachable (the empty-line case is already handled above). */
|
|
35
|
+
if (tab < 0) continue;
|
|
36
|
+
const meta = line.slice(0, tab).split(/\s+/);
|
|
37
|
+
/* v8 ignore next -- defensive: the pre-tab field is always exactly
|
|
38
|
+
`<mode> <type> <sha>` (3 tokens) for `-r HEAD`, so fewer than 3 is unreachable. */
|
|
39
|
+
if (meta.length < 3) continue;
|
|
40
|
+
const [mode, type, sha] = meta;
|
|
41
|
+
if (mode !== "160000" || type !== "commit") continue;
|
|
42
|
+
out.push({ path: line.slice(tab + 1), sha });
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The manifest is a TRANSFER FORMAT only — a JSON document that carries child
|
|
5
|
+
* URLs between machines by hand. It is never committed to any repo (that would
|
|
6
|
+
* defeat the whole point of anonymous gitlinks); it lives outside the tree, in
|
|
7
|
+
* the user's own hands. Shape:
|
|
8
|
+
*
|
|
9
|
+
* { "version": 1, "children": { "<path>": { "url": "…", "branch": "…" } } }
|
|
10
|
+
*
|
|
11
|
+
* @namespace api.embedded.manifest
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read and parse a manifest file.
|
|
16
|
+
* @param {string} file manifest path (absolute, or relative to `cwd`)
|
|
17
|
+
* @param {string} [cwd] base directory for a relative `file`
|
|
18
|
+
* @returns {{ version: number, children: object }|null} parsed manifest, or
|
|
19
|
+
* null when the file does not exist
|
|
20
|
+
* @throws {Error} when the file exists but is not valid manifest JSON
|
|
21
|
+
*/
|
|
22
|
+
export function read(file, cwd = process.cwd()) {
|
|
23
|
+
const { fs, path } = context;
|
|
24
|
+
const abs = path.isAbsolute(file) ? file : path.resolve(cwd, file);
|
|
25
|
+
if (!fs.existsSync(abs)) return null;
|
|
26
|
+
let obj;
|
|
27
|
+
try {
|
|
28
|
+
obj = JSON.parse(fs.readFileSync(abs, "utf8"));
|
|
29
|
+
} catch (err) {
|
|
30
|
+
throw new Error(`manifest ${abs} is not valid JSON: ${err.message}`);
|
|
31
|
+
}
|
|
32
|
+
if (!obj || typeof obj !== "object" || typeof obj.children !== "object" || obj.children === null || Array.isArray(obj.children)) {
|
|
33
|
+
throw new Error(`manifest ${abs} is missing a "children" object (a path → { url, branch } map, not an array)`);
|
|
34
|
+
}
|
|
35
|
+
// Gate the format version so an incompatible manifest fails loudly at read
|
|
36
|
+
// time instead of producing hard-to-diagnose behavior downstream.
|
|
37
|
+
if (obj.version !== 1) {
|
|
38
|
+
throw new Error(`manifest ${abs} has unsupported version ${JSON.stringify(obj.version)} (expected 1)`);
|
|
39
|
+
}
|
|
40
|
+
return obj;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build a manifest object from registry entries.
|
|
45
|
+
* @param {Array<{ path: string, url?: string, branch?: string }>} entries
|
|
46
|
+
* @returns {{ version: number, children: object }} manifest object; entries
|
|
47
|
+
* without a URL are dropped (a manifest without a URL is useless)
|
|
48
|
+
*/
|
|
49
|
+
export function build(entries) {
|
|
50
|
+
// Null-prototype map: a child path named __proto__ must become a plain own
|
|
51
|
+
// key, never a prototype mutation.
|
|
52
|
+
const children = Object.create(null);
|
|
53
|
+
for (const e of entries || []) {
|
|
54
|
+
if (!e || !e.url) continue;
|
|
55
|
+
children[e.path] = { url: e.url };
|
|
56
|
+
if (e.branch) children[e.path].branch = e.branch;
|
|
57
|
+
}
|
|
58
|
+
return { version: 1, children };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Serialize a manifest object to its on-disk JSON text (tab-indented, trailing
|
|
63
|
+
* newline).
|
|
64
|
+
* @param {object} manifestObj manifest object from {@link build}
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
export function serialize(manifestObj) {
|
|
68
|
+
return JSON.stringify(manifestObj, null, "\t") + "\n";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default { read, build, serialize };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { self, context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Record engine: for each embedded child present on disk, write its
|
|
5
|
+
* `remote.origin.url` and current branch into the parent's LOCAL config
|
|
6
|
+
* registry. This is how a machine that already has the children populates the
|
|
7
|
+
* registry so it can later `export` a manifest or re-`restore` without
|
|
8
|
+
* re-deriving URLs.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} [opts]
|
|
11
|
+
* @param {string} [opts.cwd] working directory inside the parent repo
|
|
12
|
+
* @param {string[]} [opts.paths] restrict to these gitlink paths (default: all
|
|
13
|
+
* gitlink children present on disk)
|
|
14
|
+
* @returns {{ results: Array<{ path: string, url?: string, branch?: string|null,
|
|
15
|
+
* outcome: "recorded"|"no-repo"|"no-origin" }> }}
|
|
16
|
+
*/
|
|
17
|
+
export default function record(opts = {}) {
|
|
18
|
+
const { cwd = process.cwd() } = opts;
|
|
19
|
+
const { paths = [] } = opts;
|
|
20
|
+
|
|
21
|
+
const root = self.git.getRepoRoot(cwd) || cwd;
|
|
22
|
+
// Same filter-spelling normalization as restore/sync: gitlink paths from
|
|
23
|
+
// gitlinks() are root-relative with forward slashes, so accept "./tests",
|
|
24
|
+
// "tests/", and Windows "vendor\\foo" instead of silently not matching.
|
|
25
|
+
const normalizePath = (p) =>
|
|
26
|
+
String(p)
|
|
27
|
+
.replace(/\\/g, "/")
|
|
28
|
+
.replace(/^\.\/+/, "")
|
|
29
|
+
.replace(/\/+$/, "");
|
|
30
|
+
const wantSet = paths.length ? new Set(paths.map(normalizePath)) : null;
|
|
31
|
+
|
|
32
|
+
const links = self.embedded.gitlinks(root);
|
|
33
|
+
const results = [];
|
|
34
|
+
for (const { path: childPath } of links) {
|
|
35
|
+
if (wantSet && !wantSet.has(childPath)) continue;
|
|
36
|
+
const abs = context.path.resolve(root, childPath);
|
|
37
|
+
if (!context.fs.existsSync(context.path.join(abs, ".git"))) {
|
|
38
|
+
// Only children present on disk can be recorded; skip the rest silently
|
|
39
|
+
// unless explicitly requested.
|
|
40
|
+
if (wantSet) results.push({ path: childPath, outcome: "no-repo" });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
results.push(self.embedded.registry.recordOne(childPath, root));
|
|
44
|
+
}
|
|
45
|
+
return { results };
|
|
46
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
function git(args, opts = {}) {
|
|
4
|
+
const res = context.spawnSync("git", args, { encoding: "utf8", ...opts });
|
|
5
|
+
return { code: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The per-clone URL registry: `embedded.<path>.url` / `embedded.<path>.branch`
|
|
10
|
+
* keys in the PARENT repo's LOCAL `.git/config`. This is registry layer 1 (the
|
|
11
|
+
* strictest resolution source) and it is NEVER committed — it lives only in the
|
|
12
|
+
* clone that wrote it. The gitlink path is stored as the config subsection, so
|
|
13
|
+
* paths with slashes (e.g. `vendor/foo`) round-trip correctly.
|
|
14
|
+
*
|
|
15
|
+
* @namespace api.embedded.registry
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Read a child's recorded clone URL from the parent's local config.
|
|
20
|
+
* @param {string} childPath gitlink path (the config subsection)
|
|
21
|
+
* @param {string} [cwd] working directory inside the parent repo
|
|
22
|
+
* @returns {string|null} the URL, or null when unset
|
|
23
|
+
*/
|
|
24
|
+
export function getUrl(childPath, cwd = process.cwd()) {
|
|
25
|
+
const res = git(["config", "--local", "--get", `embedded.${childPath}.url`], { cwd });
|
|
26
|
+
return res.code === 0 && res.stdout ? res.stdout : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read a child's recorded branch from the parent's local config.
|
|
31
|
+
* @param {string} childPath gitlink path
|
|
32
|
+
* @param {string} [cwd] working directory inside the parent repo
|
|
33
|
+
* @returns {string|null} the branch, or null when unset
|
|
34
|
+
*/
|
|
35
|
+
export function getBranch(childPath, cwd = process.cwd()) {
|
|
36
|
+
const res = git(["config", "--local", "--get", `embedded.${childPath}.branch`], { cwd });
|
|
37
|
+
return res.code === 0 && res.stdout ? res.stdout : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Write a child's clone URL into the parent's local config.
|
|
42
|
+
* @param {string} childPath gitlink path
|
|
43
|
+
* @param {string} url clone URL to record
|
|
44
|
+
* @param {string} [cwd] working directory inside the parent repo
|
|
45
|
+
* @returns {boolean} true on success
|
|
46
|
+
*/
|
|
47
|
+
export function setUrl(childPath, url, cwd = process.cwd()) {
|
|
48
|
+
// `--` so a value starting with "-" is never parsed as a git option.
|
|
49
|
+
return git(["config", "--local", "--", `embedded.${childPath}.url`, url], { cwd }).code === 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Write a child's branch into the parent's local config.
|
|
54
|
+
* @param {string} childPath gitlink path
|
|
55
|
+
* @param {string} branch branch name to record
|
|
56
|
+
* @param {string} [cwd] working directory inside the parent repo
|
|
57
|
+
* @returns {boolean} true on success
|
|
58
|
+
*/
|
|
59
|
+
export function setBranch(childPath, branch, cwd = process.cwd()) {
|
|
60
|
+
return git(["config", "--local", "--", `embedded.${childPath}.branch`, branch], { cwd }).code === 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* List every registry entry currently in the parent's local config.
|
|
65
|
+
* @param {string} [cwd] working directory inside the parent repo
|
|
66
|
+
* @returns {Array<{ path: string, url?: string, branch?: string }>} one entry
|
|
67
|
+
* per recorded child path
|
|
68
|
+
*/
|
|
69
|
+
export function entries(cwd = process.cwd()) {
|
|
70
|
+
const res = git(["config", "--local", "--get-regexp", "^embedded\\..*\\.(url|branch)$"], { cwd });
|
|
71
|
+
if (res.code !== 0) return [];
|
|
72
|
+
const map = new Map();
|
|
73
|
+
for (const line of res.stdout.split(/\r?\n/)) {
|
|
74
|
+
if (!line) continue;
|
|
75
|
+
const sp = line.indexOf(" ");
|
|
76
|
+
if (sp < 0) continue;
|
|
77
|
+
const fullKey = line.slice(0, sp);
|
|
78
|
+
const value = line.slice(sp + 1);
|
|
79
|
+
// fullKey is `embedded.<subsection>.<name>`; git preserves the subsection
|
|
80
|
+
// (the path, possibly containing dots) verbatim, so split off the trailing
|
|
81
|
+
// `.url`/`.branch` name and the leading `embedded.` section.
|
|
82
|
+
const rest = fullKey.slice("embedded.".length);
|
|
83
|
+
const lastDot = rest.lastIndexOf(".");
|
|
84
|
+
if (lastDot < 0) continue;
|
|
85
|
+
const sub = rest.slice(0, lastDot);
|
|
86
|
+
const name = rest.slice(lastDot + 1);
|
|
87
|
+
if (!map.has(sub)) map.set(sub, { path: sub });
|
|
88
|
+
map.get(sub)[name] = value;
|
|
89
|
+
}
|
|
90
|
+
return Array.from(map.values());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Record one present child: read its `remote.origin.url` and current branch and
|
|
95
|
+
* write them to the parent registry. Used by `record`, `export --scan`, and the
|
|
96
|
+
* `link` command after a fresh clone.
|
|
97
|
+
* @param {string} childPath gitlink path
|
|
98
|
+
* @param {string} root parent repo root (child lives at `<root>/<childPath>`)
|
|
99
|
+
* @returns {{ path: string, url?: string, branch?: string|null, outcome: "recorded"|"no-repo"|"no-origin" }}
|
|
100
|
+
*/
|
|
101
|
+
export function recordOne(childPath, root) {
|
|
102
|
+
const { fs, path } = context;
|
|
103
|
+
const abs = path.resolve(root, childPath);
|
|
104
|
+
const gitMarker = path.join(abs, ".git");
|
|
105
|
+
if (!fs.existsSync(gitMarker)) return { path: childPath, outcome: "no-repo" };
|
|
106
|
+
|
|
107
|
+
const urlRes = git(["-C", abs, "config", "--get", "remote.origin.url"]);
|
|
108
|
+
const url = urlRes.code === 0 && urlRes.stdout ? urlRes.stdout : null;
|
|
109
|
+
if (!url) return { path: childPath, outcome: "no-origin" };
|
|
110
|
+
setUrl(childPath, url, root);
|
|
111
|
+
|
|
112
|
+
const brRes = git(["-C", abs, "symbolic-ref", "--short", "HEAD"]);
|
|
113
|
+
const branch = brRes.code === 0 && brRes.stdout ? brRes.stdout : null;
|
|
114
|
+
if (branch) setBranch(childPath, branch, root);
|
|
115
|
+
|
|
116
|
+
return { path: childPath, url, branch, outcome: "recorded" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export default { getUrl, getBranch, setUrl, setBranch, entries, recordOne };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { self } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Last path segment of a gitlink path (its "basename"), slash-normalized so
|
|
5
|
+
* `vendor/foo` → `foo` and a trailing slash is ignored.
|
|
6
|
+
* @param {string} childPath
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
function basename(childPath) {
|
|
10
|
+
const parts = String(childPath).split("/").filter(Boolean);
|
|
11
|
+
return parts.length ? parts[parts.length - 1] : String(childPath);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Convention URL: the child is a sibling of wherever the parent was cloned
|
|
16
|
+
* from. Takes the parent's origin URL, drops its final path segment (the
|
|
17
|
+
* parent's own repo name), and appends `<basename>.git`.
|
|
18
|
+
*
|
|
19
|
+
* Handles both scp-style (`git@host:org/parent.git`) and URL-style
|
|
20
|
+
* (`https://host/org/parent.git`, `/srv/remotes/parent.git`) origins: the split
|
|
21
|
+
* is on the last `/` when one exists; a scp-style origin whose repo sits at the
|
|
22
|
+
* path root (`git@host:parent.git`) has no `/`, so the sibling lives after the
|
|
23
|
+
* last `:` instead.
|
|
24
|
+
*
|
|
25
|
+
* @param {string|null} parentOrigin the parent's `remote.origin.url`
|
|
26
|
+
* @param {string} childPath gitlink path
|
|
27
|
+
* @returns {string|null} the derived URL, or null when no origin is available
|
|
28
|
+
*/
|
|
29
|
+
export function conventionUrl(parentOrigin, childPath) {
|
|
30
|
+
if (!parentOrigin) return null;
|
|
31
|
+
const trimmed = parentOrigin.replace(/\/+$/, "");
|
|
32
|
+
const idx = trimmed.lastIndexOf("/");
|
|
33
|
+
if (idx >= 0) return `${trimmed.slice(0, idx)}/${basename(childPath)}.git`;
|
|
34
|
+
const colon = trimmed.lastIndexOf(":");
|
|
35
|
+
if (colon < 0) return null;
|
|
36
|
+
return `${trimmed.slice(0, colon)}:${basename(childPath)}.git`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a child's clone URL, strictest source first. This is the security
|
|
41
|
+
* model's heart: URL knowledge is never committed, so a URL can only come from
|
|
42
|
+
* one of three OPTIONAL layers, tried in order —
|
|
43
|
+
*
|
|
44
|
+
* 1. `local-config` — the per-clone registry (`embedded.<path>.url`).
|
|
45
|
+
* 2. `manifest` — a hand-carried transfer file passed via `--from`.
|
|
46
|
+
* 3. `base` — an explicit `--base <url-base>` + `<basename>.git`.
|
|
47
|
+
* 4. `convention` — sibling of the parent's origin (zero committed state).
|
|
48
|
+
*
|
|
49
|
+
* A `base`/`convention` result is only a *guess*; the caller SHA-verifies every
|
|
50
|
+
* clone so a wrong guess fails closed rather than planting the wrong repo.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} childPath gitlink path to resolve
|
|
53
|
+
* @param {object} [opts]
|
|
54
|
+
* @param {string} [opts.cwd] parent repo working directory (for layer 1)
|
|
55
|
+
* @param {object|null} [opts.manifest] parsed manifest `{ children: {…} }` (layer 2)
|
|
56
|
+
* @param {string|null} [opts.base] explicit URL base (layer 3)
|
|
57
|
+
* @param {string|null} [opts.parentOrigin] parent `remote.origin.url` (layer 4)
|
|
58
|
+
* @returns {{ url: string, source: "local-config"|"manifest"|"base"|"convention" }
|
|
59
|
+
* | { url: null, source: null }}
|
|
60
|
+
*/
|
|
61
|
+
export default function resolve(childPath, opts = {}) {
|
|
62
|
+
const { cwd = process.cwd(), manifest = null, base = null, parentOrigin = null } = opts;
|
|
63
|
+
|
|
64
|
+
// 1. Local-config registry — strictest, per-clone, never committed.
|
|
65
|
+
const cfgUrl = self.embedded.registry.getUrl(childPath, cwd);
|
|
66
|
+
if (cfgUrl) return { url: cfgUrl, source: "local-config" };
|
|
67
|
+
|
|
68
|
+
// 2. Manifest file (transfer format, carried out-of-band via --from).
|
|
69
|
+
// Own properties only — direct indexing could read inherited keys (e.g. a
|
|
70
|
+
// path named "constructor"), and Object.hasOwn also behaves correctly for
|
|
71
|
+
// null-prototype children maps.
|
|
72
|
+
const child = manifest && manifest.children && Object.hasOwn(manifest.children, childPath) ? manifest.children[childPath] : null;
|
|
73
|
+
if (child && child.url) return { url: child.url, source: "manifest" };
|
|
74
|
+
|
|
75
|
+
// 3. Explicit --base + basename.
|
|
76
|
+
if (base) {
|
|
77
|
+
const dir = String(base).replace(/\/+$/, "");
|
|
78
|
+
return { url: `${dir}/${basename(childPath)}.git`, source: "base" };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 4. Convention: sibling of the parent's origin. Zero committed state; a
|
|
82
|
+
// wrong guess is caught by SHA verification downstream.
|
|
83
|
+
const conv = conventionUrl(parentOrigin, childPath);
|
|
84
|
+
if (conv) return { url: conv, source: "convention" };
|
|
85
|
+
|
|
86
|
+
return { url: null, source: null };
|
|
87
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { self, context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
function git(args, opts = {}) {
|
|
4
|
+
const res = context.spawnSync("git", args, { encoding: "utf8", ...opts });
|
|
5
|
+
return { code: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Remove a clone WE created, without ever touching a pre-existing directory.
|
|
10
|
+
* When the target did not exist before we cloned, the whole directory is ours
|
|
11
|
+
* to delete. When it pre-existed (git materializes a gitlink as an empty dir),
|
|
12
|
+
* only our clone's contents are removed — the directory itself is left in place.
|
|
13
|
+
* @param {string} absChild absolute child path
|
|
14
|
+
* @param {boolean} existedBefore whether the directory existed before the clone
|
|
15
|
+
*/
|
|
16
|
+
function removeClone(absChild, existedBefore) {
|
|
17
|
+
const { fs, path } = context;
|
|
18
|
+
if (!existedBefore) {
|
|
19
|
+
fs.rmSync(absChild, { recursive: true, force: true });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
for (const entry of fs.readdirSync(absChild)) {
|
|
23
|
+
fs.rmSync(path.join(absChild, entry), { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Restore engine: clone missing embedded children and check out their pinned
|
|
29
|
+
* SHAs, resolving each URL strictest-source-first and SHA-verifying every clone
|
|
30
|
+
* so a wrong convention guess fails closed.
|
|
31
|
+
*
|
|
32
|
+
* Branch-aware: a branch for the child is resolved with the same layering as
|
|
33
|
+
* the URL — the registry (`embedded.<path>.branch`), then the manifest — and
|
|
34
|
+
* when neither supplies one it is inferred from the pin (exactly ONE `origin`
|
|
35
|
+
* branch containing it). With a branch the child ends ON that branch at the
|
|
36
|
+
* pin (upstream set best-effort, branch auto-registered); without one —
|
|
37
|
+
* including an ambiguous pin — the checkout stays detached.
|
|
38
|
+
*
|
|
39
|
+
* Partial restore is normal — a public cloner without access to a private child
|
|
40
|
+
* passes that path in `skip` and the rest still restore.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} [opts]
|
|
43
|
+
* @param {string} [opts.cwd] working directory inside the parent repo
|
|
44
|
+
* @param {string[]} [opts.paths] restrict to these gitlink paths (default: all)
|
|
45
|
+
* @param {string} [opts.from] manifest file to read child URLs from
|
|
46
|
+
* @param {string} [opts.base] explicit URL base (`<base>/<basename>.git`)
|
|
47
|
+
* @param {string[]} [opts.skip] gitlink paths to skip
|
|
48
|
+
* @param {boolean} [opts.dryRun] resolve and report only; clone/write nothing
|
|
49
|
+
* @returns {{ results: Array<object>, exitCode: number }} per-child outcomes and
|
|
50
|
+
* a process exit code (non-zero when any non-skipped child ends `unresolved`
|
|
51
|
+
* or `pinned-mismatch`)
|
|
52
|
+
*/
|
|
53
|
+
export default function restore(opts = {}) {
|
|
54
|
+
const { fs, path } = context;
|
|
55
|
+
const { cwd = process.cwd(), paths = [], from = null, base = null, skip = [], dryRun = false } = opts;
|
|
56
|
+
|
|
57
|
+
const root = self.git.getRepoRoot(cwd) || cwd;
|
|
58
|
+
const parentOrigin = git(["-C", root, "config", "--get", "remote.origin.url"]).stdout || null;
|
|
59
|
+
const manifest = from ? self.embedded.manifest.read(from, cwd) : null;
|
|
60
|
+
|
|
61
|
+
// Gitlink paths from ls-tree are root-relative with forward slashes; accept
|
|
62
|
+
// the common user spellings of the same path ("./tests", "tests/", Windows
|
|
63
|
+
// "vendor\\foo") for --skip / path filters instead of silently not matching.
|
|
64
|
+
const normalizePath = (p) =>
|
|
65
|
+
String(p)
|
|
66
|
+
.replace(/\\/g, "/")
|
|
67
|
+
.replace(/^\.\/+/, "")
|
|
68
|
+
.replace(/\/+$/, "");
|
|
69
|
+
const skipSet = new Set(skip.map(normalizePath));
|
|
70
|
+
const wantSet = paths.length ? new Set(paths.map(normalizePath)) : null;
|
|
71
|
+
|
|
72
|
+
const links = self.embedded.gitlinks(root);
|
|
73
|
+
const results = [];
|
|
74
|
+
|
|
75
|
+
for (const { path: childPath, sha } of links) {
|
|
76
|
+
if (wantSet && !wantSet.has(childPath)) continue;
|
|
77
|
+
|
|
78
|
+
const record = { path: childPath, sha, url: null, source: null, branch: null, note: null };
|
|
79
|
+
|
|
80
|
+
if (skipSet.has(childPath)) {
|
|
81
|
+
results.push({ ...record, outcome: "skipped" });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const absChild = path.resolve(root, childPath);
|
|
86
|
+
|
|
87
|
+
// lstat BEFORE probing for `.git` so a symlinked child is refused before
|
|
88
|
+
// anything follows it. A symlink that resolves to a real repo would
|
|
89
|
+
// otherwise satisfy the existsSync(.git) check below and be blessed as
|
|
90
|
+
// already-present — yet the packaged hooks cd into the child and would
|
|
91
|
+
// follow that link out of the parent worktree. lstat (not stat) sees the
|
|
92
|
+
// link itself, and also catches a broken symlink that existsSync misses.
|
|
93
|
+
let targetStat = null;
|
|
94
|
+
try {
|
|
95
|
+
targetStat = fs.lstatSync(absChild);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// Only ENOENT means "missing — clone will create it". A non-ENOENT
|
|
98
|
+
// lstat error (EACCES/ENOTDIR on an existing path) must be refused, not
|
|
99
|
+
// assumed absent — otherwise we could clone into, and later removeClone
|
|
100
|
+
// against, a pre-existing path we can't even stat.
|
|
101
|
+
if (err.code !== "ENOENT") {
|
|
102
|
+
/* v8 ignore next -- defensive: an fs error object always carries a `.code`, so the `|| err.message` fallback is unreachable. */
|
|
103
|
+
results.push({ ...record, outcome: "unresolved", note: `target unreadable (${err.code || err.message}) — refusing to touch it` });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (targetStat && targetStat.isSymbolicLink()) {
|
|
108
|
+
results.push({ ...record, outcome: "unresolved", note: "target is a symbolic link — refusing to touch it" });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const hasGit = fs.existsSync(path.join(absChild, ".git"));
|
|
113
|
+
if (hasGit) {
|
|
114
|
+
results.push({ ...record, outcome: "already-present" });
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The only acceptable pre-existing target is an EMPTY, REAL directory —
|
|
119
|
+
// what a fresh parent clone materializes for a gitlink. A file or a
|
|
120
|
+
// directory with contents is user data: never clone into it, never remove
|
|
121
|
+
// it. (A symlink was already refused above.)
|
|
122
|
+
if (targetStat) {
|
|
123
|
+
let refuse = null;
|
|
124
|
+
if (!targetStat.isDirectory()) refuse = "target exists and is not a directory";
|
|
125
|
+
else {
|
|
126
|
+
try {
|
|
127
|
+
if (fs.readdirSync(absChild).length > 0) refuse = "target directory is not empty";
|
|
128
|
+
} catch (err) {
|
|
129
|
+
/* v8 ignore next -- defensive: an fs error object always carries a `.code`, so the `|| err.message` fallback is unreachable. */
|
|
130
|
+
refuse = `target unreadable (${err.code || err.message})`;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (refuse) {
|
|
134
|
+
results.push({ ...record, outcome: "unresolved", note: `${refuse} — refusing to touch it` });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const resolved = self.embedded.resolve(childPath, { cwd: root, manifest, base, parentOrigin });
|
|
140
|
+
record.url = resolved.url;
|
|
141
|
+
record.source = resolved.source;
|
|
142
|
+
if (!resolved.url) {
|
|
143
|
+
results.push({ ...record, outcome: "unresolved", note: "no URL from local config, manifest, --base, or convention" });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Branch precedence mirrors URL precedence: the per-clone registry first,
|
|
148
|
+
// then the manifest. Inference from the pin needs the clone to exist, so
|
|
149
|
+
// it runs after SHA verification below. Own-property manifest access for
|
|
150
|
+
// the same reason as resolve (a child path named "constructor").
|
|
151
|
+
const manifestChild =
|
|
152
|
+
manifest && manifest.children && Object.hasOwn(manifest.children, childPath) ? manifest.children[childPath] : null;
|
|
153
|
+
const wantedBranch = self.embedded.registry.getBranch(childPath, root) || (manifestChild && manifestChild.branch) || null;
|
|
154
|
+
record.branch = wantedBranch;
|
|
155
|
+
|
|
156
|
+
if (dryRun) {
|
|
157
|
+
results.push({ ...record, outcome: "restored", dryRun: true });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const existedBefore = fs.existsSync(absChild);
|
|
162
|
+
// `--` ends option parsing: a URL from config/manifest/--base that starts
|
|
163
|
+
// with "-" must never be interpreted as a git option (e.g. --upload-pack).
|
|
164
|
+
// cwd=root anchors a RELATIVE url (e.g. "../sibling.git") to the parent repo
|
|
165
|
+
// root, so a restore resolves the same regardless of where the caller ran
|
|
166
|
+
// from. Without it git resolves the url against the Node process CWD (the
|
|
167
|
+
// destination is absolute, so only the source url is affected).
|
|
168
|
+
const clone = git(["clone", "--quiet", "--", resolved.url, absChild], { cwd: root });
|
|
169
|
+
if (clone.code !== 0) {
|
|
170
|
+
if (fs.existsSync(absChild)) removeClone(absChild, existedBefore);
|
|
171
|
+
/* v8 ignore next -- git normally writes to stderr on a clone failure; the empty-stderr `|| exit N` fallback covers a stderr-less failure (e.g. signal kill) — real, just not reproducible in the suite */
|
|
172
|
+
results.push({ ...record, outcome: "unresolved", note: `clone failed: ${clone.stderr || `exit ${clone.code}`}` });
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// SHA verification: the parent's pinned commit MUST exist in the clone.
|
|
177
|
+
// One fetch is attempted before giving up, in case origin's default
|
|
178
|
+
// refspec did not include the pinned commit.
|
|
179
|
+
let present = git(["-C", absChild, "cat-file", "-e", `${sha}^{commit}`]).code === 0;
|
|
180
|
+
let fetchErr = null;
|
|
181
|
+
if (!present) {
|
|
182
|
+
const fetch = git(["-C", absChild, "fetch", "--quiet", "origin"]);
|
|
183
|
+
/* v8 ignore next -- the clone above just succeeded from this same origin (git
|
|
184
|
+
stores it as an absolute path), so the follow-up fetch normally succeeds; a
|
|
185
|
+
mid-call network failure (remote unreachable) is real but not reproducible in the suite. */
|
|
186
|
+
if (fetch.code !== 0) fetchErr = fetch.stderr || `git fetch exited ${fetch.code}`;
|
|
187
|
+
present = git(["-C", absChild, "cat-file", "-e", `${sha}^{commit}`]).code === 0;
|
|
188
|
+
}
|
|
189
|
+
if (!present) {
|
|
190
|
+
removeClone(absChild, existedBefore);
|
|
191
|
+
// A failed fetch (auth/network) is not the same as "wrong repo" — surface
|
|
192
|
+
// it so a pinned-mismatch isn't misread as a bad convention guess.
|
|
193
|
+
const why = fetchErr
|
|
194
|
+
? /* v8 ignore next -- fetchErr is set only by the fetch-failure path above — a mid-call network failure, real but not reproducible in the suite */
|
|
195
|
+
`fetch from ${resolved.source} repo failed (${fetchErr})`
|
|
196
|
+
: `pinned ${sha.slice(0, 12)} absent in ${resolved.source} repo`;
|
|
197
|
+
results.push({
|
|
198
|
+
...record,
|
|
199
|
+
outcome: "pinned-mismatch",
|
|
200
|
+
note: `${why}; clone removed`
|
|
201
|
+
});
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Branch-aware checkout: registry/manifest branch wins; otherwise infer it
|
|
206
|
+
// from the pin. Attach failure (e.g. an invalid branch name in the
|
|
207
|
+
// registry) falls back to today's detached checkout rather than failing
|
|
208
|
+
// the restore — the pin is verified present, so detached is always safe.
|
|
209
|
+
const branch = wantedBranch || self.embedded.branch.infer(absChild, sha);
|
|
210
|
+
let attached = false;
|
|
211
|
+
if (branch) {
|
|
212
|
+
attached = self.embedded.branch.attach(absChild, branch, sha);
|
|
213
|
+
if (!attached) record.note = `could not attach branch ${branch}; checked out detached`;
|
|
214
|
+
}
|
|
215
|
+
record.branch = attached ? branch : null;
|
|
216
|
+
if (!attached) {
|
|
217
|
+
const checkout = git(["-C", absChild, "checkout", "--quiet", "--detach", sha]);
|
|
218
|
+
/* v8 ignore start -- the pin was just SHA-verified present in this fresh clone,
|
|
219
|
+
so the detached checkout normally succeeds; an I/O error or mid-call corruption
|
|
220
|
+
is real but not reproducible in the suite. */
|
|
221
|
+
if (checkout.code !== 0) {
|
|
222
|
+
removeClone(absChild, existedBefore);
|
|
223
|
+
results.push({
|
|
224
|
+
...record,
|
|
225
|
+
outcome: "pinned-mismatch",
|
|
226
|
+
note: `could not check out ${sha.slice(0, 12)}: ${checkout.stderr || `git checkout exited ${checkout.code}`}; clone removed`
|
|
227
|
+
});
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
/* v8 ignore stop */
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Persist the resolved URL (and the branch the child ended on) so day-2
|
|
234
|
+
// re-restores and `sync` don't re-derive them.
|
|
235
|
+
self.embedded.registry.setUrl(childPath, resolved.url, root);
|
|
236
|
+
if (attached) self.embedded.registry.setBranch(childPath, branch, root);
|
|
237
|
+
results.push({ ...record, outcome: "restored" });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const exitCode = results.some((r) => r.outcome === "unresolved" || r.outcome === "pinned-mismatch") ? 1 : 0;
|
|
241
|
+
return { results, exitCode };
|
|
242
|
+
}
|