@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.
@@ -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
+ }