From 3bc0e6f695b9e63be19e39ca35dc715545846f1d Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 13 Jul 2026 05:59:57 -0700 Subject: [PATCH 01/18] feat(cli): add restore/record/export provisioning commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provision embedded children on a fresh clone without ever committing a child URL. New `embedded` engine namespace resolves each gitlink's clone URL strictest-source-first — local-config registry, manifest (--from), --base, then origin convention — and SHA-verifies every clone so a wrong convention guess fails closed (the clone is removed, never a pre-existing dir) instead of planting the wrong code. - embedded/gitlinks.mjs enumerate mode-160000 gitlinks from HEAD - embedded/registry.mjs embedded..url/.branch in the parent's LOCAL config (never committed) - embedded/manifest.mjs JSON transfer-file read/build/serialize - embedded/resolve.mjs the 4-layer resolution precedence - embedded/restore.mjs clone + SHA-verify + detach-checkout engine - embedded/record.mjs record present children into the registry CLI leaves: - restore [paths...] [--from] [--base] [--skip] [--dry-run] outcomes: restored|already-present|unresolved|pinned-mismatch|skipped; exits non-zero if any child is unresolved or pinned-mismatch - record [paths...] - export [-o ] [--scan] (appends -o path to .git/info/exclude) --- .gitignore | 1 + src/api/cli/export.mjs | 71 +++++++++++++++++++ src/api/cli/record.mjs | 36 ++++++++++ src/api/cli/restore.mjs | 68 ++++++++++++++++++ src/api/embedded/gitlinks.mjs | 40 +++++++++++ src/api/embedded/manifest.mjs | 64 +++++++++++++++++ src/api/embedded/record.mjs | 38 ++++++++++ src/api/embedded/registry.mjs | 118 ++++++++++++++++++++++++++++++ src/api/embedded/resolve.mjs | 81 +++++++++++++++++++++ src/api/embedded/restore.mjs | 130 ++++++++++++++++++++++++++++++++++ 10 files changed, 647 insertions(+) create mode 100644 src/api/cli/export.mjs create mode 100644 src/api/cli/record.mjs create mode 100644 src/api/cli/restore.mjs create mode 100644 src/api/embedded/gitlinks.mjs create mode 100644 src/api/embedded/manifest.mjs create mode 100644 src/api/embedded/record.mjs create mode 100644 src/api/embedded/registry.mjs create mode 100644 src/api/embedded/resolve.mjs create mode 100644 src/api/embedded/restore.mjs diff --git a/.gitignore b/.gitignore index d14329c..a79a04f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules/ dist/ coverage/ reference/ +tmp/ .idea/ .vscode/ *.tsbuildinfo diff --git a/src/api/cli/export.mjs b/src/api/cli/export.mjs new file mode 100644 index 0000000..ad29f4e --- /dev/null +++ b/src/api/cli/export.mjs @@ -0,0 +1,71 @@ +import { self, context } from "@cldmv/slothlet/runtime"; + +export const spec = { + command: "export", + description: + "Serialize the local-config registry to a manifest JSON (stdout by default). The manifest is a TRANSFER FILE — carry it out-of-band and NEVER commit it; committing child URLs defeats anonymous gitlinks.", + options: [ + ["-o ", "Write the manifest to instead of stdout"], + ["--scan", "Record every present child (like 'record') before exporting"] + ], + examples: ["$ git-embedded export", "$ git-embedded export -o children.json", "$ git-embedded export --scan -o children.json"] +}; + +/** + * Append `relPath` to the repo's `.git/info/exclude` if not already listed, so a + * manifest written inside the worktree is not accidentally staged. + * @param {string} gitDir absolute git dir + * @param {string} relPath worktree-relative path to exclude + * @returns {boolean} true when a new line was added + */ +function addToExclude(gitDir, relPath) { + const { fs, path } = context; + const exclude = path.join(gitDir, "info", "exclude"); + let body = ""; + try { + body = fs.readFileSync(exclude, "utf8"); + } catch { + body = ""; + } + const lines = body.split(/\r?\n/).map((l) => l.trim()); + if (lines.includes(relPath) || lines.includes(`/${relPath}`)) return false; + fs.mkdirSync(path.dirname(exclude), { recursive: true }); + const prefix = body.length === 0 || body.endsWith("\n") ? "" : "\n"; + fs.appendFileSync(exclude, `${prefix}${relPath}\n`); + return true; +} + +export function run(opts = {}) { + const { fs, path } = context; + const cwd = process.cwd(); + const root = self.git.getRepoRoot(cwd) || cwd; + + if (opts.scan) self.embedded.record({ cwd }); + + const entries = self.embedded.registry.entries(root); + const manifest = self.embedded.manifest.build(entries); + const text = self.embedded.manifest.serialize(manifest); + + const outFile = opts.o; + if (!outFile) { + process.stdout.write(text); + return; + } + + const abs = path.isAbsolute(outFile) ? outFile : path.resolve(cwd, outFile); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, text); + self.report.success(`Wrote manifest to ${abs} (${Object.keys(manifest.children).length} children).`); + self.report.warn("This manifest contains child URLs — do NOT commit it. Carry it out-of-band."); + + const rel = path.relative(root, abs); + const insideWorktree = rel && !rel.startsWith("..") && !path.isAbsolute(rel); + if (insideWorktree) { + const gitDir = self.git.getGitDir(cwd); + if (gitDir && addToExclude(gitDir, rel.split(path.sep).join("/"))) { + self.report.plain(` (added ${rel} to .git/info/exclude as a courtesy)`); + } + } +} + +export default { spec, run }; diff --git a/src/api/cli/record.mjs b/src/api/cli/record.mjs new file mode 100644 index 0000000..d1eab92 --- /dev/null +++ b/src/api/cli/record.mjs @@ -0,0 +1,36 @@ +import { self } from "@cldmv/slothlet/runtime"; + +export const spec = { + command: "record", + description: + "Record the origin URL (and current branch) of each embedded child present on disk into the parent's LOCAL config registry, so a later export or re-restore does not have to re-derive it. The registry is never committed.", + args: [["[paths...]", "Restrict to these gitlink paths (default: every child present on disk)"]], + examples: ["$ git-embedded record", "$ git-embedded record tests vendor/foo"] +}; + +const LABEL = { + recorded: (r) => `${r.path} → ${r.url}${r.branch ? ` (${r.branch})` : ""}`, + "no-repo": (r) => `${r.path} not present on disk`, + "no-origin": (r) => `${r.path} has no remote.origin.url` +}; + +export function run(paths = []) { + const { results } = self.embedded.record({ cwd: process.cwd(), paths }); + + if (!results.length) { + self.report.plain("No embedded children present on disk to record."); + return; + } + + for (const r of results) { + const line = LABEL[r.outcome] ? LABEL[r.outcome](r) : `${r.path}: ${r.outcome}`; + if (r.outcome === "recorded") self.report.success(line); + else self.report.warn(line); + } + + const recorded = results.filter((r) => r.outcome === "recorded").length; + self.report.plain(""); + self.report.success(`Recorded ${recorded} of ${results.length} into the local registry (not committed).`); +} + +export default { spec, run }; diff --git a/src/api/cli/restore.mjs b/src/api/cli/restore.mjs new file mode 100644 index 0000000..2d86be0 --- /dev/null +++ b/src/api/cli/restore.mjs @@ -0,0 +1,68 @@ +import { self } from "@cldmv/slothlet/runtime"; + +export const spec = { + command: "restore", + description: + "Clone missing embedded child repos and check out their pinned SHAs. Each child's URL is resolved strictest-first — local config, a manifest (--from), --base, then the parent's origin convention — and every clone is SHA-verified so a wrong guess fails closed.", + args: [["[paths...]", "Restrict to these gitlink paths (default: every embedded gitlink)"]], + options: [ + ["--from ", "Read child URLs from a manifest JSON file (a transfer file; never committed)"], + ["--base ", "Derive each child URL as /.git"], + ["--skip ", "Comma-separated gitlink paths to skip (for a partial restore without access to a private child)"], + ["--dry-run", "Report what would happen without cloning or writing config"] + ], + examples: [ + "$ git-embedded restore", + "$ git-embedded restore tests", + "$ git-embedded restore --from children.json", + "$ git-embedded restore --base git@example.com:org", + "$ git-embedded restore --skip tests --dry-run" + ] +}; + +const LABEL = { + restored: (r) => `${r.dryRun ? "would restore" : "restored"} ${r.path} from ${r.source} (${r.url})`, + "already-present": (r) => `${r.path} already present`, + skipped: (r) => `${r.path} skipped`, + unresolved: (r) => `${r.path} unresolved${r.note ? ` — ${r.note}` : ""}`, + "pinned-mismatch": (r) => `${r.path} pinned-mismatch${r.note ? ` — ${r.note}` : ""}` +}; + +export function run(paths = [], opts = {}) { + const skip = + typeof opts.skip === "string" + ? opts.skip + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; + + const { results, exitCode } = self.embedded.restore({ + cwd: process.cwd(), + paths, + from: opts.from || null, + base: opts.base || null, + skip, + dryRun: Boolean(opts.dryRun) + }); + + if (!results.length) { + self.report.plain("No embedded gitlinks in HEAD."); + process.exit(0); + } + + for (const r of results) { + const line = LABEL[r.outcome] ? LABEL[r.outcome](r) : `${r.path}: ${r.outcome}`; + if (r.outcome === "restored") self.report.success(line); + else if (r.outcome === "unresolved" || r.outcome === "pinned-mismatch") self.report.error(line); + else self.report.warn(line); + } + + const restored = results.filter((r) => r.outcome === "restored").length; + const failed = results.filter((r) => r.outcome === "unresolved" || r.outcome === "pinned-mismatch").length; + self.report.plain(""); + self.report.plain(`${restored} ${opts.dryRun ? "resolvable" : "restored"}, ${results.length - restored} unchanged, ${failed} failed.`); + process.exit(exitCode); +} + +export default { spec, run }; diff --git a/src/api/embedded/gitlinks.mjs b/src/api/embedded/gitlinks.mjs new file mode 100644 index 0000000..0e7264c --- /dev/null +++ b/src/api/embedded/gitlinks.mjs @@ -0,0 +1,40 @@ +import { context } from "@cldmv/slothlet/runtime"; + +function git(args, opts = {}) { + const res = context.spawnSync("git", args, { encoding: "utf8", ...opts }); + return { code: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() }; +} + +/** + * Enumerate the anonymous gitlinks recorded in the parent's HEAD tree. + * + * Reads `git ls-tree -r HEAD` and keeps only mode-`160000` / type-`commit` + * entries — the same detection the `update-embedded-repos` and + * `reference-transaction` hooks use. No `.gitmodules` is consulted; the pinned + * SHA in the parent tree is the only committed information about a child. + * + * @param {string} [cwd] working directory inside the parent repo (default: cwd) + * @returns {Array<{ path: string, sha: string }>} gitlink path + pinned SHA, + * in tree order. Empty when HEAD has no gitlinks or `cwd` is not a repo. + * + * @example + * const links = self.embedded.gitlinks(); + * // → [{ path: "tests", sha: "a1b2c3…" }, { path: "vendor/foo", sha: "d4e5…" }] + */ +export default function gitlinks(cwd = process.cwd()) { + const res = git(["ls-tree", "-r", "HEAD"], { cwd }); + if (res.code !== 0) return []; + const out = []; + for (const line of res.stdout.split(/\r?\n/)) { + if (!line) continue; + // SP SP TAB + const tab = line.indexOf("\t"); + if (tab < 0) continue; + const meta = line.slice(0, tab).split(/\s+/); + if (meta.length < 3) continue; + const [mode, type, sha] = meta; + if (mode !== "160000" || type !== "commit") continue; + out.push({ path: line.slice(tab + 1), sha }); + } + return out; +} diff --git a/src/api/embedded/manifest.mjs b/src/api/embedded/manifest.mjs new file mode 100644 index 0000000..4ab9b5a --- /dev/null +++ b/src/api/embedded/manifest.mjs @@ -0,0 +1,64 @@ +import { context } from "@cldmv/slothlet/runtime"; + +/** + * The manifest is a TRANSFER FORMAT only — a JSON document that carries child + * URLs between machines by hand. It is never committed to any repo (that would + * defeat the whole point of anonymous gitlinks); it lives outside the tree, in + * the user's own hands. Shape: + * + * { "version": 1, "children": { "": { "url": "…", "branch": "…" } } } + * + * @namespace api.embedded.manifest + */ + +/** + * Read and parse a manifest file. + * @param {string} file manifest path (absolute, or relative to `cwd`) + * @param {string} [cwd] base directory for a relative `file` + * @returns {{ version: number, children: object }|null} parsed manifest, or + * null when the file does not exist + * @throws {Error} when the file exists but is not valid manifest JSON + */ +export function read(file, cwd = process.cwd()) { + const { fs, path } = context; + const abs = path.isAbsolute(file) ? file : path.resolve(cwd, file); + if (!fs.existsSync(abs)) return null; + let obj; + try { + obj = JSON.parse(fs.readFileSync(abs, "utf8")); + } catch (err) { + throw new Error(`manifest ${abs} is not valid JSON: ${err.message}`); + } + if (!obj || typeof obj !== "object" || typeof obj.children !== "object" || obj.children === null) { + throw new Error(`manifest ${abs} is missing a "children" object`); + } + return obj; +} + +/** + * Build a manifest object from registry entries. + * @param {Array<{ path: string, url?: string, branch?: string }>} entries + * @returns {{ version: number, children: object }} manifest object; entries + * without a URL are dropped (a manifest without a URL is useless) + */ +export function build(entries) { + const children = {}; + for (const e of entries || []) { + if (!e || !e.url) continue; + children[e.path] = { url: e.url }; + if (e.branch) children[e.path].branch = e.branch; + } + return { version: 1, children }; +} + +/** + * Serialize a manifest object to its on-disk JSON text (tab-indented, trailing + * newline). + * @param {object} manifestObj manifest object from {@link build} + * @returns {string} + */ +export function serialize(manifestObj) { + return JSON.stringify(manifestObj, null, "\t") + "\n"; +} + +export default { read, build, serialize }; diff --git a/src/api/embedded/record.mjs b/src/api/embedded/record.mjs new file mode 100644 index 0000000..61ddc7f --- /dev/null +++ b/src/api/embedded/record.mjs @@ -0,0 +1,38 @@ +import { self, context } from "@cldmv/slothlet/runtime"; + +/** + * Record engine: for each embedded child present on disk, write its + * `remote.origin.url` and current branch into the parent's LOCAL config + * registry. This is how a machine that already has the children populates the + * registry so it can later `export` a manifest or re-`restore` without + * re-deriving URLs. + * + * @param {object} [opts] + * @param {string} [opts.cwd] working directory inside the parent repo + * @param {string[]} [opts.paths] restrict to these gitlink paths (default: all + * gitlink children present on disk) + * @returns {{ results: Array<{ path: string, url?: string, branch?: string|null, + * outcome: "recorded"|"no-repo"|"no-origin" }> }} + */ +export default function record(opts = {}) { + const { cwd = process.cwd() } = opts; + const { paths = [] } = opts; + + const root = self.git.getRepoRoot(cwd) || cwd; + const wantSet = paths.length ? new Set(paths) : null; + + const links = self.embedded.gitlinks(root); + const results = []; + for (const { path: childPath } of links) { + if (wantSet && !wantSet.has(childPath)) continue; + const abs = context.path.resolve(root, childPath); + if (!context.fs.existsSync(context.path.join(abs, ".git"))) { + // Only children present on disk can be recorded; skip the rest silently + // unless explicitly requested. + if (wantSet) results.push({ path: childPath, outcome: "no-repo" }); + continue; + } + results.push(self.embedded.registry.recordOne(childPath, root)); + } + return { results }; +} diff --git a/src/api/embedded/registry.mjs b/src/api/embedded/registry.mjs new file mode 100644 index 0000000..01d6385 --- /dev/null +++ b/src/api/embedded/registry.mjs @@ -0,0 +1,118 @@ +import { context } from "@cldmv/slothlet/runtime"; + +function git(args, opts = {}) { + const res = context.spawnSync("git", args, { encoding: "utf8", ...opts }); + return { code: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() }; +} + +/** + * The per-clone URL registry: `embedded..url` / `embedded..branch` + * keys in the PARENT repo's LOCAL `.git/config`. This is registry layer 1 (the + * strictest resolution source) and it is NEVER committed — it lives only in the + * clone that wrote it. The gitlink path is stored as the config subsection, so + * paths with slashes (e.g. `vendor/foo`) round-trip correctly. + * + * @namespace api.embedded.registry + */ + +/** + * Read a child's recorded clone URL from the parent's local config. + * @param {string} childPath gitlink path (the config subsection) + * @param {string} [cwd] working directory inside the parent repo + * @returns {string|null} the URL, or null when unset + */ +export function getUrl(childPath, cwd = process.cwd()) { + const res = git(["config", "--local", "--get", `embedded.${childPath}.url`], { cwd }); + return res.code === 0 && res.stdout ? res.stdout : null; +} + +/** + * Read a child's recorded branch from the parent's local config. + * @param {string} childPath gitlink path + * @param {string} [cwd] working directory inside the parent repo + * @returns {string|null} the branch, or null when unset + */ +export function getBranch(childPath, cwd = process.cwd()) { + const res = git(["config", "--local", "--get", `embedded.${childPath}.branch`], { cwd }); + return res.code === 0 && res.stdout ? res.stdout : null; +} + +/** + * Write a child's clone URL into the parent's local config. + * @param {string} childPath gitlink path + * @param {string} url clone URL to record + * @param {string} [cwd] working directory inside the parent repo + * @returns {boolean} true on success + */ +export function setUrl(childPath, url, cwd = process.cwd()) { + return git(["config", "--local", `embedded.${childPath}.url`, url], { cwd }).code === 0; +} + +/** + * Write a child's branch into the parent's local config. + * @param {string} childPath gitlink path + * @param {string} branch branch name to record + * @param {string} [cwd] working directory inside the parent repo + * @returns {boolean} true on success + */ +export function setBranch(childPath, branch, cwd = process.cwd()) { + return git(["config", "--local", `embedded.${childPath}.branch`, branch], { cwd }).code === 0; +} + +/** + * List every registry entry currently in the parent's local config. + * @param {string} [cwd] working directory inside the parent repo + * @returns {Array<{ path: string, url?: string, branch?: string }>} one entry + * per recorded child path + */ +export function entries(cwd = process.cwd()) { + const res = git(["config", "--local", "--get-regexp", "^embedded\\..*\\.(url|branch)$"], { cwd }); + if (res.code !== 0) return []; + const map = new Map(); + for (const line of res.stdout.split(/\r?\n/)) { + if (!line) continue; + const sp = line.indexOf(" "); + if (sp < 0) continue; + const fullKey = line.slice(0, sp); + const value = line.slice(sp + 1); + // fullKey is `embedded..`; git preserves the subsection + // (the path, possibly containing dots) verbatim, so split off the trailing + // `.url`/`.branch` name and the leading `embedded.` section. + const rest = fullKey.slice("embedded.".length); + const lastDot = rest.lastIndexOf("."); + if (lastDot < 0) continue; + const sub = rest.slice(0, lastDot); + const name = rest.slice(lastDot + 1); + if (!map.has(sub)) map.set(sub, { path: sub }); + map.get(sub)[name] = value; + } + return Array.from(map.values()); +} + +/** + * Record one present child: read its `remote.origin.url` and current branch and + * write them to the parent registry. Used by `record`, `export --scan`, and the + * `link` command after a fresh clone. + * @param {string} childPath gitlink path + * @param {string} root parent repo root (child lives at `/`) + * @returns {{ path: string, url?: string, branch?: string|null, outcome: "recorded"|"no-repo"|"no-origin" }} + */ +export function recordOne(childPath, root) { + const { fs, path } = context; + const abs = path.resolve(root, childPath); + const gitMarker = path.join(abs, ".git"); + if (!fs.existsSync(gitMarker)) return { path: childPath, outcome: "no-repo" }; + + const urlRes = git(["-C", abs, "config", "--get", "remote.origin.url"]); + const url = urlRes.code === 0 && urlRes.stdout ? urlRes.stdout : null; + if (!url) return { path: childPath, outcome: "no-origin" }; + setUrl(childPath, url, root); + + const brRes = git(["-C", abs, "symbolic-ref", "--short", "HEAD"]); + const branch = brRes.code === 0 && brRes.stdout ? brRes.stdout : null; + if (branch) setBranch(childPath, branch, root); + + return { path: childPath, url, branch, outcome: "recorded" }; +} + +export default { getUrl, getBranch, setUrl, setBranch, entries, recordOne }; diff --git a/src/api/embedded/resolve.mjs b/src/api/embedded/resolve.mjs new file mode 100644 index 0000000..24458e1 --- /dev/null +++ b/src/api/embedded/resolve.mjs @@ -0,0 +1,81 @@ +import { self } from "@cldmv/slothlet/runtime"; + +/** + * Last path segment of a gitlink path (its "basename"), slash-normalized so + * `vendor/foo` → `foo` and a trailing slash is ignored. + * @param {string} childPath + * @returns {string} + */ +function basename(childPath) { + const parts = String(childPath).split("/").filter(Boolean); + return parts.length ? parts[parts.length - 1] : String(childPath); +} + +/** + * Convention URL: the child is a sibling of wherever the parent was cloned + * from. Takes the parent's origin URL, drops its final path segment (the + * parent's own repo name), and appends `.git`. + * + * Handles both scp-style (`git@host:org/parent.git`) and URL-style + * (`https://host/org/parent.git`, `/srv/remotes/parent.git`) origins — the + * split is purely on the last `/`, which is correct for all three. + * + * @param {string|null} parentOrigin the parent's `remote.origin.url` + * @param {string} childPath gitlink path + * @returns {string|null} the derived URL, or null when no origin is available + */ +export function conventionUrl(parentOrigin, childPath) { + if (!parentOrigin) return null; + const trimmed = parentOrigin.replace(/\/+$/, ""); + const idx = trimmed.lastIndexOf("/"); + if (idx < 0) return null; + const dir = trimmed.slice(0, idx); + return `${dir}/${basename(childPath)}.git`; +} + +/** + * Resolve a child's clone URL, strictest source first. This is the security + * model's heart: URL knowledge is never committed, so a URL can only come from + * one of three OPTIONAL layers, tried in order — + * + * 1. `local-config` — the per-clone registry (`embedded..url`). + * 2. `manifest` — a hand-carried transfer file passed via `--from`. + * 3. `base` — an explicit `--base ` + `.git`. + * 4. `convention` — sibling of the parent's origin (zero committed state). + * + * A `base`/`convention` result is only a *guess*; the caller SHA-verifies every + * clone so a wrong guess fails closed rather than planting the wrong repo. + * + * @param {string} childPath gitlink path to resolve + * @param {object} [opts] + * @param {string} [opts.cwd] parent repo working directory (for layer 1) + * @param {object|null} [opts.manifest] parsed manifest `{ children: {…} }` (layer 2) + * @param {string|null} [opts.base] explicit URL base (layer 3) + * @param {string|null} [opts.parentOrigin] parent `remote.origin.url` (layer 4) + * @returns {{ url: string, source: "local-config"|"manifest"|"base"|"convention" } + * | { url: null, source: null }} + */ +export default function resolve(childPath, opts = {}) { + const { cwd = process.cwd(), manifest = null, base = null, parentOrigin = null } = opts; + + // 1. Local-config registry — strictest, per-clone, never committed. + const cfgUrl = self.embedded.registry.getUrl(childPath, cwd); + if (cfgUrl) return { url: cfgUrl, source: "local-config" }; + + // 2. Manifest file (transfer format, carried out-of-band via --from). + const child = manifest && manifest.children ? manifest.children[childPath] : null; + if (child && child.url) return { url: child.url, source: "manifest" }; + + // 3. Explicit --base + basename. + if (base) { + const dir = String(base).replace(/\/+$/, ""); + return { url: `${dir}/${basename(childPath)}.git`, source: "base" }; + } + + // 4. Convention: sibling of the parent's origin. Zero committed state; a + // wrong guess is caught by SHA verification downstream. + const conv = conventionUrl(parentOrigin, childPath); + if (conv) return { url: conv, source: "convention" }; + + return { url: null, source: null }; +} diff --git a/src/api/embedded/restore.mjs b/src/api/embedded/restore.mjs new file mode 100644 index 0000000..ade69c1 --- /dev/null +++ b/src/api/embedded/restore.mjs @@ -0,0 +1,130 @@ +import { self, context } from "@cldmv/slothlet/runtime"; + +function git(args, opts = {}) { + const res = context.spawnSync("git", args, { encoding: "utf8", ...opts }); + return { code: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() }; +} + +/** + * Remove a clone WE created, without ever touching a pre-existing directory. + * When the target did not exist before we cloned, the whole directory is ours + * to delete. When it pre-existed (git materializes a gitlink as an empty dir), + * only our clone's contents are removed — the directory itself is left in place. + * @param {string} absChild absolute child path + * @param {boolean} existedBefore whether the directory existed before the clone + */ +function removeClone(absChild, existedBefore) { + const { fs, path } = context; + if (!existedBefore) { + fs.rmSync(absChild, { recursive: true, force: true }); + return; + } + for (const entry of fs.readdirSync(absChild)) { + fs.rmSync(path.join(absChild, entry), { recursive: true, force: true }); + } +} + +/** + * Restore engine: clone missing embedded children and check out their pinned + * SHAs, resolving each URL strictest-source-first and SHA-verifying every clone + * so a wrong convention guess fails closed. + * + * Partial restore is normal — a public cloner without access to a private child + * passes that path in `skip` and the rest still restore. + * + * @param {object} [opts] + * @param {string} [opts.cwd] working directory inside the parent repo + * @param {string[]} [opts.paths] restrict to these gitlink paths (default: all) + * @param {string} [opts.from] manifest file to read child URLs from + * @param {string} [opts.base] explicit URL base (`/.git`) + * @param {string[]} [opts.skip] gitlink paths to skip + * @param {boolean} [opts.dryRun] resolve and report only; clone/write nothing + * @returns {{ results: Array, exitCode: number }} per-child outcomes and + * a process exit code (non-zero when any non-skipped child ends `unresolved` + * or `pinned-mismatch`) + */ +export default function restore(opts = {}) { + const { fs, path } = context; + const { cwd = process.cwd(), paths = [], from = null, base = null, skip = [], dryRun = false } = opts; + + const root = self.git.getRepoRoot(cwd) || cwd; + const parentOrigin = git(["-C", root, "config", "--get", "remote.origin.url"]).stdout || null; + const manifest = from ? self.embedded.manifest.read(from, cwd) : null; + + const skipSet = new Set(skip); + const wantSet = paths.length ? new Set(paths) : null; + + const links = self.embedded.gitlinks(root); + const results = []; + + for (const { path: childPath, sha } of links) { + if (wantSet && !wantSet.has(childPath)) continue; + + const record = { path: childPath, sha, url: null, source: null, note: null }; + + if (skipSet.has(childPath)) { + results.push({ ...record, outcome: "skipped" }); + continue; + } + + const absChild = path.resolve(root, childPath); + const hasGit = fs.existsSync(path.join(absChild, ".git")); + if (hasGit) { + results.push({ ...record, outcome: "already-present" }); + continue; + } + + const resolved = self.embedded.resolve(childPath, { cwd: root, manifest, base, parentOrigin }); + record.url = resolved.url; + record.source = resolved.source; + if (!resolved.url) { + results.push({ ...record, outcome: "unresolved", note: "no URL from local config, manifest, --base, or convention" }); + continue; + } + + if (dryRun) { + results.push({ ...record, outcome: "restored", dryRun: true }); + continue; + } + + const existedBefore = fs.existsSync(absChild); + const clone = git(["clone", "--quiet", resolved.url, absChild]); + if (clone.code !== 0) { + if (fs.existsSync(absChild)) removeClone(absChild, existedBefore); + results.push({ ...record, outcome: "unresolved", note: `clone failed: ${clone.stderr || `exit ${clone.code}`}` }); + continue; + } + + // SHA verification: the parent's pinned commit MUST exist in the clone. + // One fetch is attempted before giving up, in case origin's default + // refspec did not include the pinned commit. + let present = git(["-C", absChild, "cat-file", "-e", `${sha}^{commit}`]).code === 0; + if (!present) { + git(["-C", absChild, "fetch", "--quiet", "origin"]); + present = git(["-C", absChild, "cat-file", "-e", `${sha}^{commit}`]).code === 0; + } + if (!present) { + removeClone(absChild, existedBefore); + results.push({ + ...record, + outcome: "pinned-mismatch", + note: `pinned ${sha.slice(0, 12)} absent in ${resolved.source} repo; clone removed` + }); + continue; + } + + const checkout = git(["-C", absChild, "checkout", "--quiet", "--detach", sha]); + if (checkout.code !== 0) { + removeClone(absChild, existedBefore); + results.push({ ...record, outcome: "pinned-mismatch", note: `could not check out ${sha.slice(0, 12)}; clone removed` }); + continue; + } + + // Persist the resolved URL so day-2 re-restores don't re-derive it. + self.embedded.registry.setUrl(childPath, resolved.url, root); + results.push({ ...record, outcome: "restored" }); + } + + const exitCode = results.some((r) => r.outcome === "unresolved" || r.outcome === "pinned-mismatch") ? 1 : 0; + return { results, exitCode }; +} From 9cdccb0645f957ae7a9aba634863659c8daa2194 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 13 Jul 2026 06:00:07 -0700 Subject: [PATCH 02/18] fix(cli): link accepts an empty gitlink dir and records its URL A fresh clone of a parent materializes each gitlink as an empty directory, so `link` refusing any existing path made it unusable to fill one in. It now clones into a missing OR empty target and refuses only a non-empty directory. After staging, it records the child's URL + branch into the parent's local registry (same as `record`), so a later restore/export already knows the child. --- src/api/cli/link.mjs | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/api/cli/link.mjs b/src/api/cli/link.mjs index 3e4c9db..43070a1 100644 --- a/src/api/cli/link.mjs +++ b/src/api/cli/link.mjs @@ -5,7 +5,7 @@ export const spec = { description: "Clone a remote repo into and stage it as an anonymous gitlink. Does NOT commit (you may want to stage other things in the same commit).", args: [ - ["", "Where to clone the child repo (created if missing)"], + ["", "Where to clone the child repo (created if missing; an empty gitlink dir is accepted)"], ["", "The child repo's clone URL (will NOT be recorded in .gitmodules)"] ], examples: [ @@ -14,11 +14,27 @@ export const spec = { ] }; +/** + * Whether `dir` blocks a fresh clone. A missing path is fine, and an empty + * directory is fine (a fresh clone of the parent materializes each gitlink as + * an empty dir). A directory with contents — or an existing repo — is refused. + * @param {string} dir + * @returns {boolean} + */ +function isNonEmpty(dir) { + const { fs } = context; + try { + return fs.readdirSync(dir).length > 0; + } catch { + return false; + } +} + export function run(localPath, remoteUrl) { const { fs, spawnSync } = context; - if (fs.existsSync(localPath)) { - self.report.error(`${localPath} already exists. Remove it or pick a different path before linking.`); + if (fs.existsSync(localPath) && isNonEmpty(localPath)) { + self.report.error(`${localPath} already exists and is not empty. Remove it or pick a different path before linking.`); process.exit(2); } @@ -35,6 +51,11 @@ export function run(localPath, remoteUrl) { process.exit(add.status || 1); } + // Record the URL + branch into the parent's LOCAL config registry (never + // committed) so a later restore/export already knows this child. + const root = self.git.getRepoRoot() || process.cwd(); + self.embedded.registry.recordOne(localPath, root); + self.report.success(`Staged gitlink at ${localPath} (no .gitmodules entry written).`); self.report.plain("Commit when ready: git commit -m 'embed '"); } From b68f5d46e5fe808af7af9ff5a9d373344f69107e Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 13 Jul 2026 06:00:07 -0700 Subject: [PATCH 03/18] test: end-to-end provisioning tests Covers restore-by-convention (clone + pin checkout + registry write + day-2 already-present), obscured-child unresolved -> link -> present, pinned-mismatch (clone removed, non-zero exit), record/export round-trip through a manifest on a second machine, --skip partial restore, and the link empty-dir fix. Real git repos in tmp with hermetic config. Suite: 22 passing (15 pre-existing + 7 new). --- tests/embedded-provisioning.test.mjs | 294 +++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 tests/embedded-provisioning.test.mjs diff --git a/tests/embedded-provisioning.test.mjs b/tests/embedded-provisioning.test.mjs new file mode 100644 index 0000000..5ba32bd --- /dev/null +++ b/tests/embedded-provisioning.test.mjs @@ -0,0 +1,294 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { getApi } from "./_setup.mjs"; + +const tmpRoots = []; + +function mkTmp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "git-embedded-prov-")); + tmpRoots.push(dir); + return dir; +} + +function git(args, cwd) { + const res = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (res.status !== 0) throw new Error(`git ${args.join(" ")} (cwd=${cwd}) failed: ${res.stderr || res.stdout}`); + return (res.stdout || "").trim(); +} + +/** + * Build a bare "child source" repo with one commit and return its bare path + + * pinned SHA. The bare lives under `remotes/.git`. + */ +function makeChildBare(work, remotes, bareName, marker) { + const bare = path.join(remotes, `${bareName}.git`); + git(["init", "--bare", "-b", "main", bare]); + const src = path.join(work, `src-${bareName}`); + git(["init", "-b", "main", src]); + fs.writeFileSync(path.join(src, "spec.txt"), marker); + git(["add", "."], src); + git(["commit", "-m", `${bareName} init`], src); + git(["remote", "add", "origin", bare], src); + git(["push", "origin", "main"], src); + const sha = git(["rev-parse", "HEAD"], src); + return { bare, sha }; +} + +/** + * Assemble a parent repo carrying an anonymous gitlink and push it to a bare. + * The gitlink at `gitlinkPath` is pinned to `pinBare`'s HEAD; the convention + * sibling name is controlled by `childBareName` (defaults to the gitlink + * basename → convention resolves; set it different to obscure the child). + */ +function makeParent({ childBareName = null, gitlinkPath = "tests", pinMarker = "child" } = {}) { + const work = mkTmp(); + const remotes = path.join(work, "remotes"); + fs.mkdirSync(remotes, { recursive: true }); + + const bareName = childBareName || gitlinkPath.split("/").pop(); + const child = makeChildBare(work, remotes, bareName, pinMarker); + + const parentBare = path.join(remotes, "parent.git"); + git(["init", "--bare", "-b", "main", parentBare]); + const parentSrc = path.join(work, "src-parent"); + git(["init", "-b", "main", parentSrc]); + fs.writeFileSync(path.join(parentSrc, "README.md"), "parent"); + git(["add", "."], parentSrc); + git(["commit", "-m", "parent init"], parentSrc); + git(["clone", "--quiet", child.bare, path.join(parentSrc, gitlinkPath)]); + git(["add", gitlinkPath], parentSrc); + git(["commit", "-m", `embed ${gitlinkPath}`], parentSrc); + git(["remote", "add", "origin", parentBare], parentSrc); + git(["push", "origin", "main"], parentSrc); + + return { work, remotes, parentBare, childBare: child.bare, childSha: child.sha, gitlinkPath }; +} + +function freshClone(parentBare) { + const dir = path.join(mkTmp(), "clone"); + git(["clone", "--quiet", parentBare, dir]); + return dir; +} + +let originalEnv; +let originalCwd; + +beforeEach(() => { + originalEnv = { ...process.env }; + originalCwd = process.cwd(); + // Hermetic git: ignore host/global config, supply a commit identity. + process.env.GIT_CONFIG_GLOBAL = os.platform() === "win32" ? "NUL" : "/dev/null"; + process.env.GIT_CONFIG_SYSTEM = os.platform() === "win32" ? "NUL" : "/dev/null"; + process.env.GIT_AUTHOR_NAME = "test"; + process.env.GIT_AUTHOR_EMAIL = "test@example.com"; + process.env.GIT_COMMITTER_NAME = "test"; + process.env.GIT_COMMITTER_EMAIL = "test@example.com"; +}); + +afterEach(() => { + try { + process.chdir(originalCwd); + } catch { + // ignore + } + process.env = originalEnv; + vi.restoreAllMocks(); + while (tmpRoots.length) { + const d = tmpRoots.pop(); + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch { + // ignore + } + } +}); + +let api; +beforeAll(async () => { + api = await getApi(); +}); + +describe("api.embedded.restore (convention)", () => { + it("restores a convention-resolvable child end-to-end and writes the registry", () => { + const { parentBare, childBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + + // Fresh clone materializes the gitlink as an empty dir with no .git. + expect(fs.existsSync(path.join(fresh, "tests"))).toBe(true); + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(false); + + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(exitCode).toBe(0); + expect(results).toHaveLength(1); + expect(results[0].outcome).toBe("restored"); + expect(results[0].source).toBe("convention"); + expect(results[0].url).toBe(childBare); + + // Pinned SHA is checked out (detached) inside the child. + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(true); + expect(git(["rev-parse", "HEAD"], path.join(fresh, "tests"))).toBe(childSha); + + // Registry recorded so day-2 does not re-derive. + expect(api.embedded.registry.getUrl("tests", fresh)).toBe(childBare); + + // Day-2 re-restore is a no-op. + const again = api.embedded.restore({ cwd: fresh }); + expect(again.results[0].outcome).toBe("already-present"); + expect(again.exitCode).toBe(0); + }); + + it("honors --skip for a partial restore (skipped child does not fail the run)", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + const { results, exitCode } = api.embedded.restore({ cwd: fresh, skip: ["tests"] }); + expect(results[0].outcome).toBe("skipped"); + expect(exitCode).toBe(0); + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(false); + }); +}); + +describe("api.embedded.restore (obscured child)", () => { + it("is unresolved by convention, then link into the empty dir makes a later restore already-present", () => { + // Child bare name differs from the gitlink basename → convention guesses + // a non-existent sibling and fails closed. + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests", childBareName: "secret-xyz" }); + const fresh = freshClone(parentBare); + + const first = api.embedded.restore({ cwd: fresh }); + expect(first.results[0].outcome).toBe("unresolved"); + expect(first.exitCode).toBe(1); + // Nothing planted; the materialized empty dir is left intact. + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(false); + + // link the real (obscured) URL into the empty gitlink dir. + process.chdir(fresh); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + api.cli.link.run("tests", childBare); + process.chdir(originalCwd); + + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(true); + expect(api.embedded.registry.getUrl("tests", fresh)).toBe(childBare); + + const second = api.embedded.restore({ cwd: fresh }); + expect(second.results[0].outcome).toBe("already-present"); + expect(second.exitCode).toBe(0); + }); +}); + +describe("api.embedded.restore (pinned-mismatch)", () => { + it("removes a clone whose repo lacks the pinned SHA and exits non-zero", () => { + const work = mkTmp(); + const remotes = path.join(work, "remotes"); + fs.mkdirSync(remotes, { recursive: true }); + + // Decoy at the convention target (tests.git) with unrelated history. + makeChildBare(work, remotes, "tests", "DECOY"); + // Real pin lives in a differently-named bare that convention never finds. + const real = makeChildBare(work, remotes, "real-child", "REAL"); + + const parentBare = path.join(remotes, "parent.git"); + git(["init", "--bare", "-b", "main", parentBare]); + const parentSrc = path.join(work, "src-parent"); + git(["init", "-b", "main", parentSrc]); + fs.writeFileSync(path.join(parentSrc, "README.md"), "parent"); + git(["add", "."], parentSrc); + git(["commit", "-m", "parent init"], parentSrc); + git(["clone", "--quiet", real.bare, path.join(parentSrc, "tests")]); + git(["add", "tests"], parentSrc); + git(["commit", "-m", "embed tests"], parentSrc); + git(["remote", "add", "origin", parentBare], parentSrc); + git(["push", "origin", "main"], parentSrc); + + const fresh = freshClone(parentBare); + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + + expect(results[0].outcome).toBe("pinned-mismatch"); + expect(results[0].source).toBe("convention"); + expect(exitCode).toBe(1); + + // The clone we created was removed; the pre-existing empty dir remains empty. + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(false); + expect(fs.readdirSync(path.join(fresh, "tests"))).toHaveLength(0); + // No registry entry was written on failure. + expect(api.embedded.registry.getUrl("tests", fresh)).toBeNull(); + }); +}); + +describe("record / export round-trip", () => { + it("exports a manifest that resolves an obscured child on a second machine", () => { + const { parentBare, childBare, childSha } = makeParent({ gitlinkPath: "tests", childBareName: "secret-xyz" }); + + // Machine A: link the obscured child, then export a manifest. + const machineA = freshClone(parentBare); + process.chdir(machineA); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + api.cli.link.run("tests", childBare); + process.chdir(originalCwd); + + const entries = api.embedded.registry.entries(machineA); + expect(entries).toEqual([{ path: "tests", url: childBare, branch: "main" }]); + + const manifest = api.embedded.manifest.build(entries); + const manifestFile = path.join(mkTmp(), "children.json"); + fs.writeFileSync(manifestFile, api.embedded.manifest.serialize(manifest)); + + // Round-trip through disk. + const parsed = api.embedded.manifest.read(manifestFile); + expect(parsed.version).toBe(1); + expect(parsed.children.tests.url).toBe(childBare); + + // Machine B: convention cannot find the child; --from manifest resolves it. + const machineB = freshClone(parentBare); + const conv = api.embedded.restore({ cwd: machineB }); + expect(conv.results[0].outcome).toBe("unresolved"); + + const viaManifest = api.embedded.restore({ cwd: machineB, from: manifestFile }); + expect(viaManifest.results[0].outcome).toBe("restored"); + expect(viaManifest.results[0].source).toBe("manifest"); + expect(viaManifest.exitCode).toBe(0); + expect(git(["rev-parse", "HEAD"], path.join(machineB, "tests"))).toBe(childSha); + }); + + it("record writes the child's origin URL into the local registry", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + // Clear the registry entry restore wrote, to prove record repopulates it. + git(["config", "--local", "--unset", "embedded.tests.url"], fresh); + expect(api.embedded.registry.getUrl("tests", fresh)).toBeNull(); + + const { results } = api.embedded.record({ cwd: fresh }); + expect(results).toHaveLength(1); + expect(results[0].outcome).toBe("recorded"); + expect(results[0].url).toBe(childBare); + expect(api.embedded.registry.getUrl("tests", fresh)).toBe(childBare); + }); +}); + +describe("api.cli.link (empty-dir fix)", () => { + it("clones into an empty gitlink dir and refuses a non-empty one", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests", childBareName: "secret-xyz" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + + // Empty materialized dir → link succeeds. + expect(fs.readdirSync(path.join(fresh, "tests"))).toHaveLength(0); + expect(() => api.cli.link.run("tests", childBare)).not.toThrow(); + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(true); + + // Non-empty, non-repo dir → link refuses with exit code 2. + fs.mkdirSync(path.join(fresh, "vendor")); + fs.writeFileSync(path.join(fresh, "vendor", "junk.txt"), "x"); + expect(() => api.cli.link.run("vendor", childBare)).toThrow(/process\.exit\(2\)/); + }); +}); From fa9f54c728e29b6abe4aaf218e1e7c182c608dc8 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 13 Jul 2026 06:00:13 -0700 Subject: [PATCH 04/18] docs: document provisioning commands and update coverage matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README gains a "Restoring embedded children (machine-B bootstrap)" section — clone parent -> `git embedded restore` -> done — plus the resolution precedence, SHA-verification, obscured-child handling, and the export/record manifest workflow with a loud "never commit the manifest" warning. design.md's coverage matrix now marks initial child clone as covered by `git embedded restore`, and a new "Provisioning" section documents the security rationale (convention is a resolution default, never a disclosure; SHA-verification; obscured children resolvable only via the config/manifest layers). Full suite: 22 passing (15 pre-existing + 7 new). --- README.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++ docs/design.md | 37 +++++++++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0b5d991..b40771c 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,62 @@ git config advice.addEmbeddedRepo false The committed parent tree now contains a gitlink at `embedded-child` pinning the child's current HEAD. No `.gitmodules` is created; the child's URL never lands in the public repo. +`link` clones into a missing **or empty** target directory (a fresh clone of a parent materializes each gitlink as an empty dir, so `link` works to fill one in); it refuses only a non-empty directory. After staging, it also records the child's URL and branch into this clone's local registry (see below). + +## Restoring embedded children (machine-B bootstrap) + +The parent commits only anonymous gitlinks — a path and a pinned SHA, never a URL. So a fresh clone of the parent materializes each embedded child as an _empty directory_: git knows the pin but has nowhere to fetch it from. `git embedded restore` fills those directories in. + +```bash +git clone myproject +cd myproject +git embedded restore # clone every embedded child and check out its pinned SHA +``` + +`restore` resolves each child's clone URL from up to four **optional** sources, strictest first, stopping at the first that yields a URL: + +1. **Local config registry** — `embedded..url` in _this clone's_ `.git/config`. Per-clone, never committed. Written automatically after a successful restore, and by `record` / `link`. +2. **Manifest file** (`--from `) — a JSON transfer file carried out-of-band (never committed). See `export` below. +3. **`--base `** — derives `/.git` for each child. +4. **Convention** (zero state) — the child is a sibling of wherever the parent was cloned from: `dirname(parent origin) + "/" + basename() + ".git"`. No configuration, but it only resolves when the child's repository is actually named after the gitlink path and sits beside the parent. A convention guess can only ever name strings already derivable from the committed tree, so it discloses nothing new. + +Every clone is **SHA-verified**: the parent's pinned commit must exist in the freshly cloned child (a `git fetch` is attempted first). If it doesn't — e.g. a convention guess resolved to the wrong repository — the clone `restore` created is removed and the child is reported `pinned-mismatch`. A wrong guess fails closed; it never plants the wrong code. + +Per-child outcomes are `restored`, `already-present`, `unresolved`, `pinned-mismatch`, or `skipped`, and `restore` exits non-zero if any child ends `unresolved` or `pinned-mismatch`. Use `--dry-run` to report resolution without cloning. + +**Partial restore is the normal case.** A public contributor without access to a private child simply skips it: + +```bash +git embedded restore --skip tests # comma-separate several: --skip tests,vendor/foo +``` + +### Obscured children + +A child whose repository name does not match its gitlink path — the intended state for a hidden private child — is deliberately _not_ convention-resolvable. Provide its URL once (via `link` into the empty gitlink dir, or `record` if it is already cloned) and this clone's registry remembers it for every later restore: + +```bash +git embedded link tests git@example.com:org/private-tests.git +# ...or, if the child is already present on disk: +git embedded record +``` + +### Sharing URLs between machines: `export` / `record` + +`record` writes the origin URL (and current branch) of every present child into the local registry. `export` serializes that registry to a manifest another machine can consume: + +```bash +git embedded export --scan -o children.json # record present children, then write the manifest +``` + +On the other machine: + +```bash +git clone myproject && cd myproject +git embedded restore --from children.json +``` + +> **Never commit the manifest.** It contains the very URLs the anonymous-gitlink design keeps out of the tree. When `export -o` writes inside the worktree it appends the filename to `.git/info/exclude` as a courtesy, but keeping the manifest out-of-band is your responsibility. + ## Manual install (no CLI) If you'd rather wire things up by hand: diff --git a/docs/design.md b/docs/design.md index 4d97759..39f883a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,6 +1,6 @@ # Design: hooks for embedded git repositories -This document describes the hook system that `@cldmv/git-embedded` installs and the design choices behind it. It is intended for anyone evaluating the approach, debugging an installed hook, or working on the planned CLI. +This document describes the hook system that `@cldmv/git-embedded` installs and the design choices behind it. It is intended for anyone evaluating the approach, debugging an installed hook, or working on the CLI. ## Background: gitlinks, submodules, and the registration gap @@ -108,11 +108,44 @@ The detached-HEAD checkout matches standard submodule behavior: parents pin spec | `git status` divergence | Yes | Yes | | `git add path` infers SHA | Yes | Yes | | `--recurse-submodules` clone | Pulls child | No-op (no registry) | -| Initial child clone | Automatic via registry | Manual or via the planned CLI | +| Initial child clone | Automatic via registry | `git embedded restore` (SHA-verified; see [Provisioning](#provisioning-restoring-embedded-children)) | | Dirty-child guard | Default refuses on update | Hook refuses on the HEAD move itself | The most useful difference is the **guard timing**. Standard submodules let the parent operation proceed and then refuse the child update, leaving the developer in a parent-moved-child-stale state that has to be backed out. The `reference-transaction` guard refuses the whole transaction at the parent level, so the working tree never reaches the inconsistent state. +## Provisioning: restoring embedded children + +The hooks above keep an *already-cloned* child in sync with the parent's pin. They do not perform the *initial* clone, because the parent tree deliberately records no URL to clone from. Standard submodules get the initial clone from the `.gitmodules` registry; anonymous gitlinks need another way to answer "where does this child come from?" without committing the answer. + +`git embedded restore` is that mechanism. It enumerates the gitlinks in HEAD (the same `git ls-tree -r HEAD`, mode-`160000` walk the hooks use) and, for every child that is missing, empty, or lacks a `.git`, resolves a clone URL, clones, verifies, and checks out the pin. The design's core property holds throughout: child URLs are never committed. + +### URL knowledge lives in three optional layers + +URL knowledge is never in the committed tree. It can only come from one of three optional layers, tried strictest-first at resolve time: + +1. **Local config registry** — `embedded..url` / `embedded..branch` in the parent clone's `.git/config`. Per-clone, never committed, never leaves the machine that wrote it. This is the durable record: a successful restore writes it, as do `record` and `link`. +2. **Manifest** — a JSON transfer file (`{ "version": 1, "children": { "": { "url": …, "branch": … } } }`) passed via `--from`. It is a transfer format only: it lives outside any repo, in the operator's hands, and is never committed. `export` produces it from the registry; `restore --from` consumes it. +3. **Convention** — with zero recorded state, the child is assumed to be a sibling of wherever the parent was cloned from: `dirname(parent remote.origin.url) + "/" + basename() + ".git"`. + +`--base ` sits between the manifest and convention as an explicit one-off override (`/.git`), useful when children live under a known base that differs from the parent's origin. + +### Why convention discloses nothing + +The convention layer looks like it might leak, but it cannot reveal anything not already implied by the committed tree. The gitlink path (e.g. `tests`) and the parent's own origin are both already visible to anyone who has the parent. Convention only *combines* them into a guess — it invents no new information — and because the guess is a guess, it is not trusted. It is SHA-verified. + +### SHA verification makes wrong guesses fail closed + +After every clone, the parent's pinned SHA must exist in the cloned child (`git cat-file -e ^{commit}`, retried once after a `git fetch origin`). If it is absent, the clone `restore` created is removed — never a pre-existing directory — and the child is reported `pinned-mismatch` with a non-zero exit. A convention guess that resolves to the wrong repository (or an out-of-date one) therefore fails closed rather than silently planting unrelated code at the pinned path. Only a repository that actually contains the pinned commit is accepted. + +An *obscured* child — one whose repository name does not match its gitlink path — is by construction not convention-resolvable, which is exactly the property that keeps a private child hidden. Such a child is reachable only through layer 1 or layer 2: someone with access records its URL (via `link` or `record`) or is handed a manifest. A public cloner without either simply `--skip`s it; partial restore is the expected outcome, not an error. + +### The commands + +- `restore [paths…] [--from ] [--base ] [--skip ] [--dry-run]` — resolve, clone, SHA-verify, detach-checkout the pin, and record the resolved URL. Per-child outcome is one of `restored`, `already-present`, `unresolved`, `pinned-mismatch`, `skipped`; the command exits non-zero when any non-skipped child ends `unresolved` or `pinned-mismatch`. +- `record [paths…]` — write each present child's `remote.origin.url` and current branch into the registry. +- `export [-o ] [--scan]` — serialize the registry to a manifest (stdout by default; `--scan` records first). The manifest must never be committed; when `-o` writes inside the worktree the filename is appended to `.git/info/exclude` as a courtesy. +- `link ` — clone a child into a missing or empty gitlink directory, stage the gitlink, and record its URL. + ## Implementation notes - The hooks are POSIX-shell scripts to avoid Node or other runtime dependencies at hook execution time. They use `git ls-tree`, `git diff-index`, `git rev-parse`, `git cat-file`, `git fetch`, and `git checkout` — all standard plumbing. From f947db3cb36b1104ad04c84012bc51f880d5fc12 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 13 Jul 2026 18:49:46 -0700 Subject: [PATCH 05/18] fix: harden provisioning target guards (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the three Copilot review findings on #2: - restore: refuse any pre-existing target that is not an empty directory (a file or a non-empty dir is user data — never clone into it, never remove it; previously a failed clone could wipe pre-existing contents via removeClone) - link: isNonEmpty treats a file (ENOTDIR) or unreadable dir as blocking instead of acceptable; only ENOENT passes - manifest.read: gate version === 1 with a clear error instead of accepting unknown formats silently Adds 4 guard tests (existing tests untouched); suite 26 passing. --- src/api/cli/link.mjs | 10 ++++-- src/api/embedded/manifest.mjs | 5 +++ src/api/embedded/restore.mjs | 17 ++++++++++ tests/embedded-provisioning.test.mjs | 49 ++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/api/cli/link.mjs b/src/api/cli/link.mjs index 43070a1..e5b7e0f 100644 --- a/src/api/cli/link.mjs +++ b/src/api/cli/link.mjs @@ -17,7 +17,8 @@ export const spec = { /** * Whether `dir` blocks a fresh clone. A missing path is fine, and an empty * directory is fine (a fresh clone of the parent materializes each gitlink as - * an empty dir). A directory with contents — or an existing repo — is refused. + * an empty dir). A directory with contents, an existing repo, a FILE at the + * path, or an unreadable directory — all refused. * @param {string} dir * @returns {boolean} */ @@ -25,8 +26,11 @@ function isNonEmpty(dir) { const { fs } = context; try { return fs.readdirSync(dir).length > 0; - } catch { - return false; + } catch (err) { + // A file (ENOTDIR) or an unreadable directory must be refused up-front — + // cloning into it fails confusingly (or worse). Only a missing path + // (ENOENT) is safe to treat as empty: git clone creates it. + return err.code !== "ENOENT"; } } diff --git a/src/api/embedded/manifest.mjs b/src/api/embedded/manifest.mjs index 4ab9b5a..e1fcb8c 100644 --- a/src/api/embedded/manifest.mjs +++ b/src/api/embedded/manifest.mjs @@ -32,6 +32,11 @@ export function read(file, cwd = process.cwd()) { if (!obj || typeof obj !== "object" || typeof obj.children !== "object" || obj.children === null) { throw new Error(`manifest ${abs} is missing a "children" object`); } + // Gate the format version so an incompatible manifest fails loudly at read + // time instead of producing hard-to-diagnose behavior downstream. + if (obj.version !== 1) { + throw new Error(`manifest ${abs} has unsupported version ${JSON.stringify(obj.version)} (expected 1)`); + } return obj; } diff --git a/src/api/embedded/restore.mjs b/src/api/embedded/restore.mjs index ade69c1..241aa85 100644 --- a/src/api/embedded/restore.mjs +++ b/src/api/embedded/restore.mjs @@ -74,6 +74,23 @@ export default function restore(opts = {}) { continue; } + // The only acceptable pre-existing target is an EMPTY directory — what a + // fresh parent clone materializes for a gitlink. A file, or a directory + // with contents, is user data: never clone into it, never remove it. + if (fs.existsSync(absChild)) { + let refuse = null; + try { + if (!fs.statSync(absChild).isDirectory()) refuse = "target exists and is not a directory"; + else if (fs.readdirSync(absChild).length > 0) refuse = "target directory is not empty"; + } catch (err) { + refuse = `target unreadable (${err.code || err.message})`; + } + if (refuse) { + results.push({ ...record, outcome: "unresolved", note: `${refuse} — refusing to touch it` }); + continue; + } + } + const resolved = self.embedded.resolve(childPath, { cwd: root, manifest, base, parentOrigin }); record.url = resolved.url; record.source = resolved.source; diff --git a/tests/embedded-provisioning.test.mjs b/tests/embedded-provisioning.test.mjs index 5ba32bd..c3ee686 100644 --- a/tests/embedded-provisioning.test.mjs +++ b/tests/embedded-provisioning.test.mjs @@ -292,3 +292,52 @@ describe("api.cli.link (empty-dir fix)", () => { expect(() => api.cli.link.run("vendor", childBare)).toThrow(/process\.exit\(2\)/); }); }); + +describe("target safety guards (review hardening)", () => { + it("restore refuses a non-empty directory at a gitlink path and leaves it untouched", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + // User data sitting in the materialized gitlink dir must never be touched. + fs.writeFileSync(path.join(fresh, "tests", "precious.txt"), "user data"); + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(results[0].outcome).toBe("unresolved"); + expect(results[0].note).toMatch(/not empty.*refusing/); + expect(exitCode).toBe(1); + expect(fs.readFileSync(path.join(fresh, "tests", "precious.txt"), "utf8")).toBe("user data"); + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(false); + }); + + it("restore refuses a file at a gitlink path and leaves it untouched", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + fs.rmdirSync(path.join(fresh, "tests")); + fs.writeFileSync(path.join(fresh, "tests"), "a file, not a dir"); + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(results[0].outcome).toBe("unresolved"); + expect(results[0].note).toMatch(/not a directory.*refusing/); + expect(exitCode).toBe(1); + expect(fs.readFileSync(path.join(fresh, "tests"), "utf8")).toBe("a file, not a dir"); + }); + + it("link refuses a file target up-front with exit 2", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + fs.writeFileSync(path.join(fresh, "somefile"), "x"); + expect(() => api.cli.link.run("somefile", childBare)).toThrow(/process\.exit\(2\)/); + expect(fs.readFileSync(path.join(fresh, "somefile"), "utf8")).toBe("x"); + }); + + it("manifest.read rejects a missing or unsupported version", () => { + const dir = mkTmp(); + const noVersion = path.join(dir, "no-version.json"); + fs.writeFileSync(noVersion, JSON.stringify({ children: {} })); + expect(() => api.embedded.manifest.read(noVersion)).toThrow(/version/); + const badVersion = path.join(dir, "bad-version.json"); + fs.writeFileSync(badVersion, JSON.stringify({ version: 2, children: {} })); + expect(() => api.embedded.manifest.read(badVersion)).toThrow(/unsupported version 2/); + }); +}); From 6db4b13ba0e5670f7080c75e341ed1ebff55eb72 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 13 Jul 2026 19:32:05 -0700 Subject: [PATCH 06/18] fix: symlink-safe target guards + scp-root convention (review round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the second Copilot review round on #2: - restore/link: lstat-based target checks — a symlink at a gitlink path (even to an empty dir) is refused instead of cloned through (writing outside the repo); broken symlinks are caught too (existsSync missed them) - link: guard folded into blocksClone(target) called unconditionally, so a dangling link no longer slips past the existsSync pre-check - conventionUrl: scp-style origins with the repo at the path root (git@host:parent.git) now derive the sibling after the last ':' — matching the documented scp support Adds 4 tests (scp-root dry-run, symlink, broken symlink, link symlink refusal); existing tests untouched; suite 30 passing. --- src/api/cli/link.mjs | 34 +++++++++++------- src/api/embedded/resolve.mjs | 13 ++++--- src/api/embedded/restore.mjs | 30 +++++++++++----- tests/embedded-provisioning.test.mjs | 54 ++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 27 deletions(-) diff --git a/src/api/cli/link.mjs b/src/api/cli/link.mjs index e5b7e0f..868f0bf 100644 --- a/src/api/cli/link.mjs +++ b/src/api/cli/link.mjs @@ -15,30 +15,38 @@ export const spec = { }; /** - * Whether `dir` blocks a fresh clone. A missing path is fine, and an empty - * directory is fine (a fresh clone of the parent materializes each gitlink as - * an empty dir). A directory with contents, an existing repo, a FILE at the - * path, or an unreadable directory — all refused. - * @param {string} dir + * Whether the target path blocks a fresh clone. A missing path is fine, and an + * empty REAL directory is fine (a fresh clone of the parent materializes each + * gitlink as an empty dir). Everything else is refused: a directory with + * contents, an existing repo, a file, an unreadable directory, or a SYMLINK — + * even one pointing at an empty dir, since cloning through it would write + * outside the repo. lstat so links are seen (and broken links caught), never + * followed. + * @param {string} target * @returns {boolean} */ -function isNonEmpty(dir) { +function blocksClone(target) { const { fs } = context; + let st; try { - return fs.readdirSync(dir).length > 0; + st = fs.lstatSync(target); } catch (err) { - // A file (ENOTDIR) or an unreadable directory must be refused up-front — - // cloning into it fails confusingly (or worse). Only a missing path - // (ENOENT) is safe to treat as empty: git clone creates it. + // Only a missing path (ENOENT) is safe — git clone creates it. return err.code !== "ENOENT"; } + if (st.isSymbolicLink() || !st.isDirectory()) return true; + try { + return fs.readdirSync(target).length > 0; + } catch { + return true; // unreadable directory + } } export function run(localPath, remoteUrl) { - const { fs, spawnSync } = context; + const { spawnSync } = context; - if (fs.existsSync(localPath) && isNonEmpty(localPath)) { - self.report.error(`${localPath} already exists and is not empty. Remove it or pick a different path before linking.`); + if (blocksClone(localPath)) { + self.report.error(`${localPath} exists and is not an empty directory. Remove it or pick a different path before linking.`); process.exit(2); } diff --git a/src/api/embedded/resolve.mjs b/src/api/embedded/resolve.mjs index 24458e1..300933e 100644 --- a/src/api/embedded/resolve.mjs +++ b/src/api/embedded/resolve.mjs @@ -17,8 +17,10 @@ function basename(childPath) { * parent's own repo name), and appends `.git`. * * Handles both scp-style (`git@host:org/parent.git`) and URL-style - * (`https://host/org/parent.git`, `/srv/remotes/parent.git`) origins — the - * split is purely on the last `/`, which is correct for all three. + * (`https://host/org/parent.git`, `/srv/remotes/parent.git`) origins: the split + * is on the last `/` when one exists; a scp-style origin whose repo sits at the + * path root (`git@host:parent.git`) has no `/`, so the sibling lives after the + * last `:` instead. * * @param {string|null} parentOrigin the parent's `remote.origin.url` * @param {string} childPath gitlink path @@ -28,9 +30,10 @@ export function conventionUrl(parentOrigin, childPath) { if (!parentOrigin) return null; const trimmed = parentOrigin.replace(/\/+$/, ""); const idx = trimmed.lastIndexOf("/"); - if (idx < 0) return null; - const dir = trimmed.slice(0, idx); - return `${dir}/${basename(childPath)}.git`; + if (idx >= 0) return `${trimmed.slice(0, idx)}/${basename(childPath)}.git`; + const colon = trimmed.lastIndexOf(":"); + if (colon < 0) return null; + return `${trimmed.slice(0, colon)}:${basename(childPath)}.git`; } /** diff --git a/src/api/embedded/restore.mjs b/src/api/embedded/restore.mjs index 241aa85..8fb2d5a 100644 --- a/src/api/embedded/restore.mjs +++ b/src/api/embedded/restore.mjs @@ -74,16 +74,28 @@ export default function restore(opts = {}) { continue; } - // The only acceptable pre-existing target is an EMPTY directory — what a - // fresh parent clone materializes for a gitlink. A file, or a directory - // with contents, is user data: never clone into it, never remove it. - if (fs.existsSync(absChild)) { + // The only acceptable pre-existing target is an EMPTY, REAL directory — + // what a fresh parent clone materializes for a gitlink. A file, a + // directory with contents, or a SYMLINK (even to an empty dir — cloning + // through it would write outside the repo) is user data: never clone + // into it, never remove it. lstat so links are seen, not followed; this + // also catches a broken symlink, which existsSync would miss. + let targetStat = null; + try { + targetStat = fs.lstatSync(absChild); + } catch { + /* missing — clone will create it */ + } + if (targetStat) { let refuse = null; - try { - if (!fs.statSync(absChild).isDirectory()) refuse = "target exists and is not a directory"; - else if (fs.readdirSync(absChild).length > 0) refuse = "target directory is not empty"; - } catch (err) { - refuse = `target unreadable (${err.code || err.message})`; + if (targetStat.isSymbolicLink()) refuse = "target is a symbolic link"; + else if (!targetStat.isDirectory()) refuse = "target exists and is not a directory"; + else { + try { + if (fs.readdirSync(absChild).length > 0) refuse = "target directory is not empty"; + } catch (err) { + refuse = `target unreadable (${err.code || err.message})`; + } } if (refuse) { results.push({ ...record, outcome: "unresolved", note: `${refuse} — refusing to touch it` }); diff --git a/tests/embedded-provisioning.test.mjs b/tests/embedded-provisioning.test.mjs index c3ee686..01c1e0b 100644 --- a/tests/embedded-provisioning.test.mjs +++ b/tests/embedded-provisioning.test.mjs @@ -341,3 +341,57 @@ describe("target safety guards (review hardening)", () => { expect(() => api.embedded.manifest.read(badVersion)).toThrow(/unsupported version 2/); }); }); + +describe("review hardening round 2 (scp-root convention + symlink guards)", () => { + it("derives the convention sibling for a scp-style origin with no path component", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + // Repo at the scp path root: no "/" in the origin — sibling lives after the last ":". + git(["remote", "set-url", "origin", "git@host.example:parent.git"], fresh); + const { results } = api.embedded.restore({ cwd: fresh, dryRun: true }); + expect(results[0].source).toBe("convention"); + expect(results[0].url).toBe("git@host.example:tests.git"); + }); + + it("restore refuses a symlink at a gitlink path — even one pointing at an empty dir", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + const target = path.join(mkTmp(), "elsewhere"); + fs.mkdirSync(target); + fs.rmdirSync(path.join(fresh, "tests")); + fs.symlinkSync(target, path.join(fresh, "tests")); + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(results[0].outcome).toBe("unresolved"); + expect(results[0].note).toMatch(/symbolic link.*refusing/); + expect(exitCode).toBe(1); + // The symlink target stays untouched — nothing was cloned through it. + expect(fs.readdirSync(target)).toHaveLength(0); + expect(fs.lstatSync(path.join(fresh, "tests")).isSymbolicLink()).toBe(true); + }); + + it("restore refuses a BROKEN symlink at a gitlink path", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + fs.rmdirSync(path.join(fresh, "tests")); + fs.symlinkSync(path.join(fresh, "does-not-exist"), path.join(fresh, "tests")); + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(results[0].outcome).toBe("unresolved"); + expect(results[0].note).toMatch(/symbolic link.*refusing/); + expect(exitCode).toBe(1); + expect(fs.lstatSync(path.join(fresh, "tests")).isSymbolicLink()).toBe(true); + }); + + it("link refuses a symlink target up-front with exit 2", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + const target = path.join(mkTmp(), "elsewhere2"); + fs.mkdirSync(target); + fs.symlinkSync(target, path.join(fresh, "linked")); + expect(() => api.cli.link.run("linked", childBare)).toThrow(/process\.exit\(2\)/); + expect(fs.readdirSync(target)).toHaveLength(0); + }); +}); From 425e54720885e9d1c575d6a919a681278cc835c2 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 13 Jul 2026 20:16:36 -0700 Subject: [PATCH 07/18] test: gate symlink-guard cases behind a creation-capability probe Windows can't create symlinks without Developer Mode/elevation, so the three cases that CREATE links to prove the guards skip where creation is denied (probe at suite setup); creations get an explicit 'dir' type for Windows correctness when they do run. The guards themselves are lstat-based, need no symlink rights, and stay exercised on POSIX CI. Suite 30 passing. --- tests/embedded-provisioning.test.mjs | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/tests/embedded-provisioning.test.mjs b/tests/embedded-provisioning.test.mjs index 01c1e0b..3031b75 100644 --- a/tests/embedded-provisioning.test.mjs +++ b/tests/embedded-provisioning.test.mjs @@ -13,6 +13,20 @@ function mkTmp() { return dir; } +// Whether this environment can CREATE symlinks — Windows requires Developer +// Mode or elevation. The symlink-guard cases skip where creation is denied; +// the guards themselves need no symlink rights and stay exercised on POSIX CI. +const canSymlink = (() => { + try { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "git-embedded-symlink-probe-")); + fs.symlinkSync(dir, path.join(dir, "probe"), "dir"); + fs.rmSync(dir, { recursive: true, force: true }); + return true; + } catch { + return false; + } +})(); + function git(args, cwd) { const res = spawnSync("git", args, { cwd, encoding: "utf8" }); if (res.status !== 0) throw new Error(`git ${args.join(" ")} (cwd=${cwd}) failed: ${res.stderr || res.stdout}`); @@ -353,13 +367,13 @@ describe("review hardening round 2 (scp-root convention + symlink guards)", () = expect(results[0].url).toBe("git@host.example:tests.git"); }); - it("restore refuses a symlink at a gitlink path — even one pointing at an empty dir", () => { + it.skipIf(!canSymlink)("restore refuses a symlink at a gitlink path — even one pointing at an empty dir", () => { const { parentBare } = makeParent({ gitlinkPath: "tests" }); const fresh = freshClone(parentBare); const target = path.join(mkTmp(), "elsewhere"); fs.mkdirSync(target); fs.rmdirSync(path.join(fresh, "tests")); - fs.symlinkSync(target, path.join(fresh, "tests")); + fs.symlinkSync(target, path.join(fresh, "tests"), "dir"); const { results, exitCode } = api.embedded.restore({ cwd: fresh }); expect(results[0].outcome).toBe("unresolved"); expect(results[0].note).toMatch(/symbolic link.*refusing/); @@ -369,11 +383,11 @@ describe("review hardening round 2 (scp-root convention + symlink guards)", () = expect(fs.lstatSync(path.join(fresh, "tests")).isSymbolicLink()).toBe(true); }); - it("restore refuses a BROKEN symlink at a gitlink path", () => { + it.skipIf(!canSymlink)("restore refuses a BROKEN symlink at a gitlink path", () => { const { parentBare } = makeParent({ gitlinkPath: "tests" }); const fresh = freshClone(parentBare); fs.rmdirSync(path.join(fresh, "tests")); - fs.symlinkSync(path.join(fresh, "does-not-exist"), path.join(fresh, "tests")); + fs.symlinkSync(path.join(fresh, "does-not-exist"), path.join(fresh, "tests"), "dir"); const { results, exitCode } = api.embedded.restore({ cwd: fresh }); expect(results[0].outcome).toBe("unresolved"); expect(results[0].note).toMatch(/symbolic link.*refusing/); @@ -381,7 +395,7 @@ describe("review hardening round 2 (scp-root convention + symlink guards)", () = expect(fs.lstatSync(path.join(fresh, "tests")).isSymbolicLink()).toBe(true); }); - it("link refuses a symlink target up-front with exit 2", () => { + it.skipIf(!canSymlink)("link refuses a symlink target up-front with exit 2", () => { const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); const fresh = freshClone(parentBare); process.chdir(fresh); @@ -390,7 +404,7 @@ describe("review hardening round 2 (scp-root convention + symlink guards)", () = }); const target = path.join(mkTmp(), "elsewhere2"); fs.mkdirSync(target); - fs.symlinkSync(target, path.join(fresh, "linked")); + fs.symlinkSync(target, path.join(fresh, "linked"), "dir"); expect(() => api.cli.link.run("linked", childBare)).toThrow(/process\.exit\(2\)/); expect(fs.readdirSync(target)).toHaveLength(0); }); From b4544876c657d3f1aa23e963c289e5661a1bb8bf Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 13 Jul 2026 20:33:21 -0700 Subject: [PATCH 08/18] fix(cli): correct restore summary buckets; docs: four resolution sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 3: the summary line counted failures inside 'unchanged' (results.length - restored) — each outcome now lands in exactly one bucket (restored / unchanged=already-present / skipped when present / failed), verified live: '0 restored, 0 unchanged, 1 skipped, 0 failed.' design.md's provisioning section said 'three optional layers' while implementing four sources — now lists local config → manifest → explicit --base → convention, matching README and the resolver. --- docs/design.md | 69 ++++++++++++++++++++--------------------- src/api/cli/restore.mjs | 8 ++++- 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/docs/design.md b/docs/design.md index 39f883a..1f53c5d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -19,7 +19,7 @@ The hooks in this package close the registration gap without requiring a registr ## Why this matters: the URL is the leak -For most submodule use cases, the URL in `.gitmodules` is uncontroversial — the parent is open and the child is open, the URL is just a convenience for `clone --recurse-submodules`. For a parent that wants to hide the *existence* of a private child repo, the `.gitmodules` URL is the leak. Anyone who can read the public parent can read `.gitmodules`, see the URL of the private child, and at minimum learn that a private resource exists at that location. +For most submodule use cases, the URL in `.gitmodules` is uncontroversial — the parent is open and the child is open, the URL is just a convenience for `clone --recurse-submodules`. For a parent that wants to hide the _existence_ of a private child repo, the `.gitmodules` URL is the leak. Anyone who can read the public parent can read `.gitmodules`, see the URL of the private child, and at minimum learn that a private resource exists at that location. Avoiding `.gitmodules` is the obvious fix, but doing so loses the working-tree automation. This package restores the automation while keeping the parent free of URL data. @@ -80,64 +80,63 @@ The detached-HEAD checkout matches standard submodule behavior: parents pin spec ## Coverage matrix -| Operation | `reference-transaction` (guard) | `update-embedded-repos` (update) | -|---|---|---| -| `git checkout ` | Refuses if any child is dirty | Updates children to new pins | -| `git switch ` | Refuses if any child is dirty | Updates children to new pins | -| `git reset --hard ` | Refuses if any child is dirty | **Gap** — does not fire `post-*` hooks | +| Operation | `reference-transaction` (guard) | `update-embedded-repos` (update) | +| ----------------------------------- | ------------------------------------------ | ----------------------------------------------- | +| `git checkout ` | Refuses if any child is dirty | Updates children to new pins | +| `git switch ` | Refuses if any child is dirty | Updates children to new pins | +| `git reset --hard ` | Refuses if any child is dirty | **Gap** — does not fire `post-*` hooks | | `git reset --soft/--mixed ` | Refuses if any child is dirty (HEAD moves) | Does not fire `post-*` hooks (HEAD-only change) | -| `git pull` (fast-forward) | Refuses if any child is dirty | Updates children | -| `git pull --rebase` | Refuses at each rebase step | Updates children after rebase completes | -| `git merge ` | Refuses if any child is dirty | Updates children via `post-merge` | -| `git rebase` | Refuses at each step | Updates children via `post-rewrite` | -| `git bisect ` | Refuses if any child is dirty | Updates children at each bisect step | -| `git cherry-pick` | Refuses if any child is dirty | Updates children via `post-checkout` | -| `git stash pop` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) | -| `git commit` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) | -| `git checkout -- file` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) | +| `git pull` (fast-forward) | Refuses if any child is dirty | Updates children | +| `git pull --rebase` | Refuses at each rebase step | Updates children after rebase completes | +| `git merge ` | Refuses if any child is dirty | Updates children via `post-merge` | +| `git rebase` | Refuses at each step | Updates children via `post-rewrite` | +| `git bisect ` | Refuses if any child is dirty | Updates children at each bisect step | +| `git cherry-pick` | Refuses if any child is dirty | Updates children via `post-checkout` | +| `git stash pop` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) | +| `git commit` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) | +| `git checkout -- file` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) | ## Comparison to standard submodules -| Property | Standard submodule | Anonymous gitlink + these hooks | -|---|---|---| -| Child URL in parent | Yes, in `.gitmodules` | No | -| Tree-level pin | Gitlink | Gitlink | -| Public viewer sees | URL, path, current SHA | Just the SHA (no link to follow) | -| `git submodule update` | Works | Not used (registry-bound; hooks replace it) | -| `submodule.recurse=true` | Works | Not used (registry-bound; hooks replace it) | -| `git status` divergence | Yes | Yes | -| `git add path` infers SHA | Yes | Yes | -| `--recurse-submodules` clone | Pulls child | No-op (no registry) | -| Initial child clone | Automatic via registry | `git embedded restore` (SHA-verified; see [Provisioning](#provisioning-restoring-embedded-children)) | -| Dirty-child guard | Default refuses on update | Hook refuses on the HEAD move itself | +| Property | Standard submodule | Anonymous gitlink + these hooks | +| ---------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------- | +| Child URL in parent | Yes, in `.gitmodules` | No | +| Tree-level pin | Gitlink | Gitlink | +| Public viewer sees | URL, path, current SHA | Just the SHA (no link to follow) | +| `git submodule update` | Works | Not used (registry-bound; hooks replace it) | +| `submodule.recurse=true` | Works | Not used (registry-bound; hooks replace it) | +| `git status` divergence | Yes | Yes | +| `git add path` infers SHA | Yes | Yes | +| `--recurse-submodules` clone | Pulls child | No-op (no registry) | +| Initial child clone | Automatic via registry | `git embedded restore` (SHA-verified; see [Provisioning](#provisioning-restoring-embedded-children)) | +| Dirty-child guard | Default refuses on update | Hook refuses on the HEAD move itself | The most useful difference is the **guard timing**. Standard submodules let the parent operation proceed and then refuse the child update, leaving the developer in a parent-moved-child-stale state that has to be backed out. The `reference-transaction` guard refuses the whole transaction at the parent level, so the working tree never reaches the inconsistent state. ## Provisioning: restoring embedded children -The hooks above keep an *already-cloned* child in sync with the parent's pin. They do not perform the *initial* clone, because the parent tree deliberately records no URL to clone from. Standard submodules get the initial clone from the `.gitmodules` registry; anonymous gitlinks need another way to answer "where does this child come from?" without committing the answer. +The hooks above keep an _already-cloned_ child in sync with the parent's pin. They do not perform the _initial_ clone, because the parent tree deliberately records no URL to clone from. Standard submodules get the initial clone from the `.gitmodules` registry; anonymous gitlinks need another way to answer "where does this child come from?" without committing the answer. `git embedded restore` is that mechanism. It enumerates the gitlinks in HEAD (the same `git ls-tree -r HEAD`, mode-`160000` walk the hooks use) and, for every child that is missing, empty, or lacks a `.git`, resolves a clone URL, clones, verifies, and checks out the pin. The design's core property holds throughout: child URLs are never committed. -### URL knowledge lives in three optional layers +### URL knowledge lives in four optional sources -URL knowledge is never in the committed tree. It can only come from one of three optional layers, tried strictest-first at resolve time: +URL knowledge is never in the committed tree. It can only come from one of four optional sources, tried strictest-first at resolve time: 1. **Local config registry** — `embedded..url` / `embedded..branch` in the parent clone's `.git/config`. Per-clone, never committed, never leaves the machine that wrote it. This is the durable record: a successful restore writes it, as do `record` and `link`. 2. **Manifest** — a JSON transfer file (`{ "version": 1, "children": { "": { "url": …, "branch": … } } }`) passed via `--from`. It is a transfer format only: it lives outside any repo, in the operator's hands, and is never committed. `export` produces it from the registry; `restore --from` consumes it. -3. **Convention** — with zero recorded state, the child is assumed to be a sibling of wherever the parent was cloned from: `dirname(parent remote.origin.url) + "/" + basename() + ".git"`. - -`--base ` sits between the manifest and convention as an explicit one-off override (`/.git`), useful when children live under a known base that differs from the parent's origin. +3. **Explicit base** — `--base ` derives `/.git`; a per-invocation override for children living under a known base that differs from the parent's origin. Supplied on the command line, recorded nowhere. +4. **Convention** — with zero supplied state, the child is assumed to be a sibling of wherever the parent was cloned from: `dirname(parent remote.origin.url) + "/" + basename() + ".git"`. ### Why convention discloses nothing -The convention layer looks like it might leak, but it cannot reveal anything not already implied by the committed tree. The gitlink path (e.g. `tests`) and the parent's own origin are both already visible to anyone who has the parent. Convention only *combines* them into a guess — it invents no new information — and because the guess is a guess, it is not trusted. It is SHA-verified. +The convention layer looks like it might leak, but it cannot reveal anything not already implied by the committed tree. The gitlink path (e.g. `tests`) and the parent's own origin are both already visible to anyone who has the parent. Convention only _combines_ them into a guess — it invents no new information — and because the guess is a guess, it is not trusted. It is SHA-verified. ### SHA verification makes wrong guesses fail closed After every clone, the parent's pinned SHA must exist in the cloned child (`git cat-file -e ^{commit}`, retried once after a `git fetch origin`). If it is absent, the clone `restore` created is removed — never a pre-existing directory — and the child is reported `pinned-mismatch` with a non-zero exit. A convention guess that resolves to the wrong repository (or an out-of-date one) therefore fails closed rather than silently planting unrelated code at the pinned path. Only a repository that actually contains the pinned commit is accepted. -An *obscured* child — one whose repository name does not match its gitlink path — is by construction not convention-resolvable, which is exactly the property that keeps a private child hidden. Such a child is reachable only through layer 1 or layer 2: someone with access records its URL (via `link` or `record`) or is handed a manifest. A public cloner without either simply `--skip`s it; partial restore is the expected outcome, not an error. +An _obscured_ child — one whose repository name does not match its gitlink path — is by construction not convention-resolvable, which is exactly the property that keeps a private child hidden. Such a child is reachable only through layer 1 or layer 2: someone with access records its URL (via `link` or `record`) or is handed a manifest. A public cloner without either simply `--skip`s it; partial restore is the expected outcome, not an error. ### The commands diff --git a/src/api/cli/restore.mjs b/src/api/cli/restore.mjs index 2d86be0..2fcf90c 100644 --- a/src/api/cli/restore.mjs +++ b/src/api/cli/restore.mjs @@ -58,10 +58,16 @@ export function run(paths = [], opts = {}) { else self.report.warn(line); } + // Count each outcome into exactly one bucket — "unchanged" is only + // already-present, never a failure or a skip counted twice. const restored = results.filter((r) => r.outcome === "restored").length; + const unchanged = results.filter((r) => r.outcome === "already-present").length; + const skipped = results.filter((r) => r.outcome === "skipped").length; const failed = results.filter((r) => r.outcome === "unresolved" || r.outcome === "pinned-mismatch").length; self.report.plain(""); - self.report.plain(`${restored} ${opts.dryRun ? "resolvable" : "restored"}, ${results.length - restored} unchanged, ${failed} failed.`); + self.report.plain( + `${restored} ${opts.dryRun ? "resolvable" : "restored"}, ${unchanged} unchanged${skipped ? `, ${skipped} skipped` : ""}, ${failed} failed.` + ); process.exit(exitCode); } From f9f5dbe309b0f1be08855a98054c24ec258e606d Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 13 Jul 2026 21:04:31 -0700 Subject: [PATCH 09/18] fix: manifest shape hardening + accurate link docs (review round 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - manifest.read rejects an array children value (typeof [] === 'object' slipped through) - manifest.build uses a null-prototype children map so a '__proto__' child path is a plain key, never a prototype mutation - resolve's manifest layer reads own properties only (Object.hasOwn — also correct for null-proto maps) - README: link's refusal wording now matches the implementation (non-empty dir, file, symlink, unreadable — not 'only a non-empty directory') - test symlink probe cleans its temp dir in finally (no leak when the probe fails, e.g. Windows without Developer Mode) Two tests added; suite 32 passing. --- README.md | 2 +- src/api/embedded/manifest.mjs | 8 +++++--- src/api/embedded/resolve.mjs | 5 ++++- tests/embedded-provisioning.test.mjs | 25 +++++++++++++++++++++++-- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b40771c..4161028 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ git config advice.addEmbeddedRepo false The committed parent tree now contains a gitlink at `embedded-child` pinning the child's current HEAD. No `.gitmodules` is created; the child's URL never lands in the public repo. -`link` clones into a missing **or empty** target directory (a fresh clone of a parent materializes each gitlink as an empty dir, so `link` works to fill one in); it refuses only a non-empty directory. After staging, it also records the child's URL and branch into this clone's local registry (see below). +`link` clones into a missing **or empty** target directory (a fresh clone of a parent materializes each gitlink as an empty dir, so `link` works to fill one in); it refuses anything else — a non-empty directory, a file, a symlink (even to an empty dir), or an unreadable path. After staging, it also records the child's URL and branch into this clone's local registry (see below). ## Restoring embedded children (machine-B bootstrap) diff --git a/src/api/embedded/manifest.mjs b/src/api/embedded/manifest.mjs index e1fcb8c..711f567 100644 --- a/src/api/embedded/manifest.mjs +++ b/src/api/embedded/manifest.mjs @@ -29,8 +29,8 @@ export function read(file, cwd = process.cwd()) { } catch (err) { throw new Error(`manifest ${abs} is not valid JSON: ${err.message}`); } - if (!obj || typeof obj !== "object" || typeof obj.children !== "object" || obj.children === null) { - throw new Error(`manifest ${abs} is missing a "children" object`); + if (!obj || typeof obj !== "object" || typeof obj.children !== "object" || obj.children === null || Array.isArray(obj.children)) { + throw new Error(`manifest ${abs} is missing a "children" object (a path → { url, branch } map, not an array)`); } // Gate the format version so an incompatible manifest fails loudly at read // time instead of producing hard-to-diagnose behavior downstream. @@ -47,7 +47,9 @@ export function read(file, cwd = process.cwd()) { * without a URL are dropped (a manifest without a URL is useless) */ export function build(entries) { - const children = {}; + // Null-prototype map: a child path named __proto__ must become a plain own + // key, never a prototype mutation. + const children = Object.create(null); for (const e of entries || []) { if (!e || !e.url) continue; children[e.path] = { url: e.url }; diff --git a/src/api/embedded/resolve.mjs b/src/api/embedded/resolve.mjs index 300933e..3c5e96a 100644 --- a/src/api/embedded/resolve.mjs +++ b/src/api/embedded/resolve.mjs @@ -66,7 +66,10 @@ export default function resolve(childPath, opts = {}) { if (cfgUrl) return { url: cfgUrl, source: "local-config" }; // 2. Manifest file (transfer format, carried out-of-band via --from). - const child = manifest && manifest.children ? manifest.children[childPath] : null; + // Own properties only — direct indexing could read inherited keys (e.g. a + // path named "constructor"), and Object.hasOwn also behaves correctly for + // null-prototype children maps. + const child = manifest && manifest.children && Object.hasOwn(manifest.children, childPath) ? manifest.children[childPath] : null; if (child && child.url) return { url: child.url, source: "manifest" }; // 3. Explicit --base + basename. diff --git a/tests/embedded-provisioning.test.mjs b/tests/embedded-provisioning.test.mjs index 3031b75..bc22948 100644 --- a/tests/embedded-provisioning.test.mjs +++ b/tests/embedded-provisioning.test.mjs @@ -17,13 +17,17 @@ function mkTmp() { // Mode or elevation. The symlink-guard cases skip where creation is denied; // the guards themselves need no symlink rights and stay exercised on POSIX CI. const canSymlink = (() => { + let dir = null; try { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "git-embedded-symlink-probe-")); + dir = fs.mkdtempSync(path.join(os.tmpdir(), "git-embedded-symlink-probe-")); fs.symlinkSync(dir, path.join(dir, "probe"), "dir"); - fs.rmSync(dir, { recursive: true, force: true }); return true; } catch { return false; + } finally { + // Clean up on BOTH paths — a failed probe (Windows without Developer + // Mode) must not leak the temp dir. + if (dir) fs.rmSync(dir, { recursive: true, force: true }); } })(); @@ -409,3 +413,20 @@ describe("review hardening round 2 (scp-root convention + symlink guards)", () = expect(fs.readdirSync(target)).toHaveLength(0); }); }); + +describe("manifest shape hardening (review round 4)", () => { + it("read rejects an array children value", () => { + const dir = mkTmp(); + const f = path.join(dir, "array-children.json"); + fs.writeFileSync(f, JSON.stringify({ version: 1, children: [] })); + expect(() => api.embedded.manifest.read(f)).toThrow(/children/); + }); + + it("build treats a __proto__ child path as a plain key without polluting prototypes", () => { + const manifest = api.embedded.manifest.build([{ path: "__proto__", url: "ssh://h/p.git" }]); + expect(Object.hasOwn(manifest.children, "__proto__")).toBe(true); + expect({}.url).toBeUndefined(); // Object.prototype untouched + // Round-trips through JSON as an ordinary key. + expect(JSON.parse(JSON.stringify(manifest)).children["__proto__"].url).toBe("ssh://h/p.git"); + }); +}); From 0625a0bc015007cc453ef2b7a79f050e042084fa Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 13 Jul 2026 21:29:20 -0700 Subject: [PATCH 10/18] fix: git end-of-options guards + link registry-key normalization (review round 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - restore/link/registry: every git call that receives a resolved URL or user path now passes `--` first, so a value starting with '-' (e.g. --upload-pack=...) is a repo argument, never an option — injection test proves the payload is not executed - link: normalizes the target to the repo-root-relative slash-normalized gitlink path before recording ('./tests' and 'tests/' record as 'tests', matching restore/gitlinks/export), and refuses a target outside the worktree with exit 2 Three tests added; suite 35 passing. --- src/api/cli/link.mjs | 28 ++++++++++++----- src/api/embedded/registry.mjs | 5 ++-- src/api/embedded/restore.mjs | 4 ++- tests/embedded-provisioning.test.mjs | 45 ++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 11 deletions(-) diff --git a/src/api/cli/link.mjs b/src/api/cli/link.mjs index 868f0bf..d535084 100644 --- a/src/api/cli/link.mjs +++ b/src/api/cli/link.mjs @@ -43,32 +43,44 @@ function blocksClone(target) { } export function run(localPath, remoteUrl) { - const { spawnSync } = context; + const { spawnSync, path } = context; + + // Normalize to the repo-root-relative, slash-normalized gitlink path — the + // key restore/gitlinks/export all use. "./tests" or "tests/" must record as + // "tests", and a target outside the worktree is refused outright. + const root = self.git.getRepoRoot() || process.cwd(); + const rel = path.relative(root, path.resolve(process.cwd(), localPath)).split(path.sep).join("/"); + if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) { + self.report.error(`${localPath} is outside the repository worktree — link inside the parent repo.`); + process.exit(2); + } if (blocksClone(localPath)) { self.report.error(`${localPath} exists and is not an empty directory. Remove it or pick a different path before linking.`); process.exit(2); } - self.report.plain(`Cloning ${remoteUrl} into ${localPath}…`); - const clone = spawnSync("git", ["clone", remoteUrl, localPath], { stdio: "inherit" }); + self.report.plain(`Cloning ${remoteUrl} into ${rel}…`); + // `--` ends option parsing: a URL or path starting with "-" must never be + // interpreted as a git option (e.g. --upload-pack). + const clone = spawnSync("git", ["clone", "--", remoteUrl, localPath], { stdio: "inherit" }); if (clone.status !== 0) { self.report.error(`git clone exited with status ${clone.status}`); process.exit(clone.status || 1); } - const add = spawnSync("git", ["add", localPath], { stdio: "inherit" }); + const add = spawnSync("git", ["add", "--", localPath], { stdio: "inherit" }); if (add.status !== 0) { self.report.error(`git add ${localPath} exited with status ${add.status}`); process.exit(add.status || 1); } // Record the URL + branch into the parent's LOCAL config registry (never - // committed) so a later restore/export already knows this child. - const root = self.git.getRepoRoot() || process.cwd(); - self.embedded.registry.recordOne(localPath, root); + // committed) so a later restore/export already knows this child — keyed by + // the NORMALIZED gitlink path so day-2 restore/export find it. + self.embedded.registry.recordOne(rel, root); - self.report.success(`Staged gitlink at ${localPath} (no .gitmodules entry written).`); + self.report.success(`Staged gitlink at ${rel} (no .gitmodules entry written).`); self.report.plain("Commit when ready: git commit -m 'embed '"); } diff --git a/src/api/embedded/registry.mjs b/src/api/embedded/registry.mjs index 01d6385..c28cba9 100644 --- a/src/api/embedded/registry.mjs +++ b/src/api/embedded/registry.mjs @@ -45,7 +45,8 @@ export function getBranch(childPath, cwd = process.cwd()) { * @returns {boolean} true on success */ export function setUrl(childPath, url, cwd = process.cwd()) { - return git(["config", "--local", `embedded.${childPath}.url`, url], { cwd }).code === 0; + // `--` so a value starting with "-" is never parsed as a git option. + return git(["config", "--local", "--", `embedded.${childPath}.url`, url], { cwd }).code === 0; } /** @@ -56,7 +57,7 @@ export function setUrl(childPath, url, cwd = process.cwd()) { * @returns {boolean} true on success */ export function setBranch(childPath, branch, cwd = process.cwd()) { - return git(["config", "--local", `embedded.${childPath}.branch`, branch], { cwd }).code === 0; + return git(["config", "--local", "--", `embedded.${childPath}.branch`, branch], { cwd }).code === 0; } /** diff --git a/src/api/embedded/restore.mjs b/src/api/embedded/restore.mjs index 8fb2d5a..6d7f797 100644 --- a/src/api/embedded/restore.mjs +++ b/src/api/embedded/restore.mjs @@ -117,7 +117,9 @@ export default function restore(opts = {}) { } const existedBefore = fs.existsSync(absChild); - const clone = git(["clone", "--quiet", resolved.url, absChild]); + // `--` ends option parsing: a URL from config/manifest/--base that starts + // with "-" must never be interpreted as a git option (e.g. --upload-pack). + const clone = git(["clone", "--quiet", "--", resolved.url, absChild]); if (clone.code !== 0) { if (fs.existsSync(absChild)) removeClone(absChild, existedBefore); results.push({ ...record, outcome: "unresolved", note: `clone failed: ${clone.stderr || `exit ${clone.code}`}` }); diff --git a/tests/embedded-provisioning.test.mjs b/tests/embedded-provisioning.test.mjs index bc22948..722343f 100644 --- a/tests/embedded-provisioning.test.mjs +++ b/tests/embedded-provisioning.test.mjs @@ -430,3 +430,48 @@ describe("manifest shape hardening (review round 4)", () => { expect(JSON.parse(JSON.stringify(manifest)).children["__proto__"].url).toBe("ssh://h/p.git"); }); }); + +describe("git argument-injection + registry-key normalization (review round 5)", () => { + it("a registry URL starting with '-' is passed as a repo, never a git option", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + const marker = path.join(mkTmp(), "pwned"); + // Classic vector: without `--`, git clone would honor --upload-pack and run it. + api.embedded.registry.setUrl("tests", `--upload-pack=touch ${marker}`, fresh); + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(results[0].outcome).toBe("unresolved"); // clone failed cleanly + expect(exitCode).toBe(1); + expect(fs.existsSync(marker)).toBe(false); // nothing executed + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(false); + }); + + it("link normalizes './tests' and 'tests/' to the gitlink path for the registry key", () => { + const a = makeParent({ gitlinkPath: "tests" }); + const freshA = freshClone(a.parentBare); + process.chdir(freshA); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + api.cli.link.run("./tests", a.childBare); + expect(api.embedded.registry.getUrl("tests", freshA)).toBe(a.childBare); + expect(api.embedded.restore({ cwd: freshA }).results[0].outcome).toBe("already-present"); + process.chdir(originalCwd); + + const b = makeParent({ gitlinkPath: "tests" }); + const freshB = freshClone(b.parentBare); + process.chdir(freshB); + api.cli.link.run("tests/", b.childBare); + expect(api.embedded.registry.getUrl("tests", freshB)).toBe(b.childBare); + }); + + it("link refuses a target outside the repository worktree", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + process.chdir(fresh); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + expect(() => api.cli.link.run("../escaped", childBare)).toThrow(/process\.exit\(2\)/); + expect(fs.existsSync(path.join(path.dirname(fresh), "escaped"))).toBe(false); + }); +}); From 91edfe291ddebed9b82451c9abbaeeee50798cf4 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 13 Jul 2026 22:00:22 -0700 Subject: [PATCH 11/18] fix(restore): normalize --skip / path filters to gitlink-path spelling (review round 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filters matched gitlink paths by exact string, so './tests', 'tests/', or Windows 'vendor\foo' silently didn't match. The engine now normalizes filter entries (backslashes → slashes, strip leading ./ and trailing /) before building the skip/want sets — same spelling rules link applies to its registry key. Test covers all three spellings; suite 36 passing. --- src/api/embedded/restore.mjs | 12 ++++++++++-- tests/embedded-provisioning.test.mjs | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/api/embedded/restore.mjs b/src/api/embedded/restore.mjs index 6d7f797..1cef5aa 100644 --- a/src/api/embedded/restore.mjs +++ b/src/api/embedded/restore.mjs @@ -51,8 +51,16 @@ export default function restore(opts = {}) { const parentOrigin = git(["-C", root, "config", "--get", "remote.origin.url"]).stdout || null; const manifest = from ? self.embedded.manifest.read(from, cwd) : null; - const skipSet = new Set(skip); - const wantSet = paths.length ? new Set(paths) : null; + // Gitlink paths from ls-tree are root-relative with forward slashes; accept + // the common user spellings of the same path ("./tests", "tests/", Windows + // "vendor\\foo") for --skip / path filters instead of silently not matching. + const normalizePath = (p) => + String(p) + .replace(/\\/g, "/") + .replace(/^\.\/+/, "") + .replace(/\/+$/, ""); + const skipSet = new Set(skip.map(normalizePath)); + const wantSet = paths.length ? new Set(paths.map(normalizePath)) : null; const links = self.embedded.gitlinks(root); const results = []; diff --git a/tests/embedded-provisioning.test.mjs b/tests/embedded-provisioning.test.mjs index 722343f..310e9d7 100644 --- a/tests/embedded-provisioning.test.mjs +++ b/tests/embedded-provisioning.test.mjs @@ -475,3 +475,21 @@ describe("git argument-injection + registry-key normalization (review round 5)", expect(fs.existsSync(path.join(path.dirname(fresh), "escaped"))).toBe(false); }); }); + +describe("filter-path normalization (review round 6)", () => { + it("--skip and paths filters accept './x', 'x/', and backslash spellings", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + // skip spelled './tests' must actually skip (previously a silent no-match). + const skipped = api.embedded.restore({ cwd: fresh, skip: ["./tests"] }); + expect(skipped.results[0].outcome).toBe("skipped"); + expect(skipped.exitCode).toBe(0); + // paths filter spelled 'tests/' must select the gitlink (dry-run). + const wanted = api.embedded.restore({ cwd: fresh, paths: ["tests/"], dryRun: true }); + expect(wanted.results).toHaveLength(1); + expect(wanted.results[0].outcome).toBe("restored"); + // backslash spelling normalizes too. + const bs = api.embedded.restore({ cwd: fresh, skip: ["tests\\"], dryRun: true }); + expect(bs.results[0].outcome).toBe("skipped"); + }); +}); From bc849cda18a165d77ffc8c46a7ac753f8198fcc6 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Fri, 17 Jul 2026 07:17:36 -0700 Subject: [PATCH 12/18] feat(cli): branch-aware restore + day-2 sync command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the provisioning CLI's restore with branch awareness: a branch is resolved with the same layering as the URL (registry embedded..branch, then the manifest) and otherwise inferred from the pin — exactly ONE origin branch containing it, listed via full refnames so origin/HEAD cannot poison the uniqueness check. With a branch the child ends ON it at the pin with best-effort upstream tracking, and the branch is auto-registered like the URL; ambiguity keeps the detached checkout. restore --from now consumes the manifest's branch, closing the record → export → restore round-trip. Adds the sync command for day-2 pin sync (parent pull stays the caller's step): clean children follow moved pins — the registered branch fast-forwards (HEAD must be an ancestor of the pin), a detached child snaps — while dirty children, commits beyond the pin, and unregistered branches are reported and left alone. One fetch before declaring a pin unavailable; only pin-unavailable/sync-failed exit non-zero. --- README.md | 28 ++- docs/design.md | 37 +++- src/api/cli/restore.mjs | 4 +- src/api/cli/sync.mjs | 70 +++++++ src/api/embedded/branch.mjs | 62 ++++++ src/api/embedded/restore.mjs | 45 ++++- src/api/embedded/sync.mjs | 158 ++++++++++++++++ tests/embedded-provisioning.test.mjs | 273 +++++++++++++++++++++++++++ 8 files changed, 660 insertions(+), 17 deletions(-) create mode 100644 src/api/cli/sync.mjs create mode 100644 src/api/embedded/branch.mjs create mode 100644 src/api/embedded/sync.mjs diff --git a/README.md b/README.md index 4161028..e10ab16 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ git embedded restore # clone every embedded child and check out its pin `restore` resolves each child's clone URL from up to four **optional** sources, strictest first, stopping at the first that yields a URL: -1. **Local config registry** — `embedded..url` in _this clone's_ `.git/config`. Per-clone, never committed. Written automatically after a successful restore, and by `record` / `link`. +1. **Local config registry** — `embedded..url` (and `embedded..branch`, see below) in _this clone's_ `.git/config`. Per-clone, never committed. Written automatically after a successful restore, and by `record` / `link`. 2. **Manifest file** (`--from `) — a JSON transfer file carried out-of-band (never committed). See `export` below. 3. **`--base `** — derives `/.git` for each child. 4. **Convention** (zero state) — the child is a sibling of wherever the parent was cloned from: `dirname(parent origin) + "/" + basename() + ".git"`. No configuration, but it only resolves when the child's repository is actually named after the gitlink path and sits beside the parent. A convention guess can only ever name strings already derivable from the committed tree, so it discloses nothing new. @@ -109,6 +109,10 @@ Every clone is **SHA-verified**: the parent's pinned commit must exist in the fr Per-child outcomes are `restored`, `already-present`, `unresolved`, `pinned-mismatch`, or `skipped`, and `restore` exits non-zero if any child ends `unresolved` or `pinned-mismatch`. Use `--dry-run` to report resolution without cloning. +### Branch-aware checkout + +A restored child does not have to end up detached. `restore` resolves a **branch** for each child with the same layering as the URL — `embedded..branch` in the local registry, then the manifest — and when neither supplies one, it infers the branch from the pin: if exactly **one** `origin` branch contains the pinned commit, that branch is used. With a branch, the child ends ON it at the pin (`checkout -B`), with upstream tracking set to `origin/` when it exists, and the branch is auto-registered like the URL. An ambiguous pin (on several branches) or an unmatchable one keeps today's detached checkout — inference never guesses. + **Partial restore is the normal case.** A public contributor without access to a private child simply skips it: ```bash @@ -142,6 +146,28 @@ git embedded restore --from children.json > **Never commit the manifest.** It contains the very URLs the anonymous-gitlink design keeps out of the tree. When `export -o` writes inside the worktree it appends the filename to `.git/info/exclude` as a courtesy, but keeping the manifest out-of-band is your responsibility. +## Day-2: syncing pins + +When the parent pulls commits that move gitlink pins, the children on disk are still at the old SHAs. `git embedded sync` moves them — and only them; sync never touches the parent, so pulling the parent first is your step: + +```bash +git pull +git embedded sync +``` + +Per child, sync is deliberately conservative — a clean child follows the pin, anything that looks like your work is reported and left alone: + +- **already at the pin** — nothing to do (`in-sync`). +- **uncommitted changes** — left alone (`dirty`). +- **on the registered branch** (`embedded..branch`), clean — the branch is moved to the pin **fast-forward only**: the child's HEAD must be an ancestor of the pin. Commits beyond the pin are your work (`ahead`, left alone). +- **on any other branch** — left alone (`unregistered-branch`). +- **detached**, clean — snapped to the pin, staying detached (`synced`). +- **pin not present locally** — one `git fetch origin` inside the child; if the pin still cannot be found the child is reported `pin-unavailable` and sync exits non-zero. + +Only `pin-unavailable` (and an unexpected checkout failure) fail the run — the left-alone outcomes protect in-progress work and exit zero. `sync` takes the same `[paths…]`, `--skip`, and `--dry-run` surface as `restore`. + +If the hooks from this package are installed, most parent operations already update the children automatically (detached, like standard submodules). `sync` covers the rest: hook-less clones, the `git reset --hard` gap, and keeping a child _on its branch_ as pins advance. + ## Manual install (no CLI) If you'd rather wire things up by hand: diff --git a/docs/design.md b/docs/design.md index 1f53c5d..a55885b 100644 --- a/docs/design.md +++ b/docs/design.md @@ -69,13 +69,13 @@ For each gitlink path, the script: 4. If the pinned SHA is not in the child's local object store, runs `git fetch` inside the child (using the child's own remote config — `.gitmodules` is not consulted). 5. Runs `git checkout --detach ` inside the child. -The detached-HEAD checkout matches standard submodule behavior: parents pin specific commits, not branches, so the child ends up in detached-HEAD state after each parent operation. If the child needs to be on a branch for editing, the developer attaches to one (`git -C embedded-child switch -c work` or `git -C embedded-child checkout main`) after the operation completes. +The detached-HEAD checkout matches standard submodule behavior: parents pin specific commits, not branches, so the child ends up in detached-HEAD state after each parent operation. If the child needs to be on a branch for editing, the developer attaches to one (`git -C embedded-child switch -c work` or `git -C embedded-child checkout main`) after the operation completes. The provisioning CLI is branch-aware where the hooks are not: `restore` can put a child ON a branch at the pin and `sync` fast-forwards a registered branch (see [Branch-aware checkout](#branch-aware-checkout) and [Day-2 pin sync](#day-2-pin-sync)). **What it catches.** Together, the three hook names cover essentially every checkout-flavored parent operation. See the coverage matrix below. **What it does not catch.** Two notable gaps: -- `git reset --hard ` updates the index and working tree but does **not** fire `post-checkout`, `post-merge`, or `post-rewrite`. The `reference-transaction` guard catches this case at the prepared phase (because `reset` does move HEAD via a ref transaction), so a `--hard` reset with a dirty child is refused — but a `--hard` reset with a clean child completes without the children being auto-updated. The mitigation is to either accept the gap, manually re-run the script, or use a `git-foo` wrapper command. +- `git reset --hard ` updates the index and working tree but does **not** fire `post-checkout`, `post-merge`, or `post-rewrite`. The `reference-transaction` guard catches this case at the prepared phase (because `reset` does move HEAD via a ref transaction), so a `--hard` reset with a dirty child is refused — but a `--hard` reset with a clean child completes without the children being auto-updated. The mitigation is `git embedded sync` (see [Day-2 pin sync](#day-2-pin-sync)), which snaps clean children to the pins on demand. - `git stash pop` modifies the working tree without moving HEAD. It does not affect embedded children (stash entries are recorded in the parent's stash ref, not in the children), but anyone expecting "all working-tree-modifying commands are guarded" will not see consistency here. ## Coverage matrix @@ -84,7 +84,7 @@ The detached-HEAD checkout matches standard submodule behavior: parents pin spec | ----------------------------------- | ------------------------------------------ | ----------------------------------------------- | | `git checkout ` | Refuses if any child is dirty | Updates children to new pins | | `git switch ` | Refuses if any child is dirty | Updates children to new pins | -| `git reset --hard ` | Refuses if any child is dirty | **Gap** — does not fire `post-*` hooks | +| `git reset --hard ` | Refuses if any child is dirty | **Gap** — run `git embedded sync` after | | `git reset --soft/--mixed ` | Refuses if any child is dirty (HEAD moves) | Does not fire `post-*` hooks (HEAD-only change) | | `git pull` (fast-forward) | Refuses if any child is dirty | Updates children | | `git pull --rebase` | Refuses at each rebase step | Updates children after rebase completes | @@ -123,7 +123,7 @@ The hooks above keep an _already-cloned_ child in sync with the parent's pin. Th URL knowledge is never in the committed tree. It can only come from one of four optional sources, tried strictest-first at resolve time: -1. **Local config registry** — `embedded..url` / `embedded..branch` in the parent clone's `.git/config`. Per-clone, never committed, never leaves the machine that wrote it. This is the durable record: a successful restore writes it, as do `record` and `link`. +1. **Local config registry** — `embedded..url` / `embedded..branch` in the parent clone's `.git/config`. Per-clone, never committed, never leaves the machine that wrote it. This is the durable record: a successful restore writes it, as do `record` and `link`. The `.branch` key records the branch this clone keeps the child on — `restore` attaches the child to it and `sync` fast-forwards it; unset means the child lives detached. 2. **Manifest** — a JSON transfer file (`{ "version": 1, "children": { "": { "url": …, "branch": … } } }`) passed via `--from`. It is a transfer format only: it lives outside any repo, in the operator's hands, and is never committed. `export` produces it from the registry; `restore --from` consumes it. 3. **Explicit base** — `--base ` derives `/.git`; a per-invocation override for children living under a known base that differs from the parent's origin. Supplied on the command line, recorded nowhere. 4. **Convention** — with zero supplied state, the child is assumed to be a sibling of wherever the parent was cloned from: `dirname(parent remote.origin.url) + "/" + basename() + ".git"`. @@ -138,12 +138,35 @@ After every clone, the parent's pinned SHA must exist in the cloned child (`git An _obscured_ child — one whose repository name does not match its gitlink path — is by construction not convention-resolvable, which is exactly the property that keeps a private child hidden. Such a child is reachable only through layer 1 or layer 2: someone with access records its URL (via `link` or `record`) or is handed a manifest. A public cloner without either simply `--skip`s it; partial restore is the expected outcome, not an error. +### Branch-aware checkout + +Gitlinks pin commits, not branches, so the baseline checkout is detached — but a child a developer works in usually _lives_ on a branch, and re-attaching by hand after every restore is friction. `restore` therefore resolves a branch per child with the same layering as the URL: the registry (`embedded..branch`), then the manifest, and — when neither supplies one — **inference from the pin**: if exactly one `origin` branch contains the pinned commit, that branch is taken. With a branch, the child ends ON it at the pin (`checkout -B `), upstream tracking is set to `origin/` when that ref exists (best-effort — a registered local-only branch is legitimate), and the branch is auto-registered exactly like the URL. Ambiguity — the pin reachable from several branches — declines to detached; inference never guesses. + +One implementation detail is load-bearing: containing branches are listed with **full refnames** (`refs/remotes/origin/`). The short form renders `origin/HEAD` as bare `origin`, which enters the candidate set as a phantom branch and poisons the exactly-one uniqueness check whenever the remote HEAD symref is set (i.e. after every normal clone). + +### Day-2 pin sync + +The hooks update children when a parent operation moves HEAD, but they detach (standard submodule semantics), require installation, and have the `git reset --hard` gap. `git embedded sync` is the explicit, branch-preserving alternative: after the parent has pulled new pins (pulling the parent is the caller's step — sync, like restore, never touches the parent), it walks the present children and moves each clean one to its pin. The dispositions, in evaluation order: + +| Child state | Disposition | +| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| HEAD already at the pin | `in-sync` — nothing to do | +| Uncommitted changes | `dirty` — left alone (your work) | +| Pin absent after one `git fetch origin` | `pin-unavailable` — reported, non-zero exit | +| On the **registered** branch, HEAD ancestor of pin | `synced` — branch moved to the pin (`checkout -B`, fast-forward only), upstream refreshed | +| On the registered branch, commits beyond the pin | `ahead` — left alone (your work) | +| On any **unregistered** branch | `unregistered-branch` — left alone (reported) | +| Detached, clean | `synced` — detached to the pin | + +Only `pin-unavailable` (and an unexpected checkout failure, `sync-failed`) make the exit code non-zero: the left-alone outcomes are deliberate protection of in-progress work, not errors. A dry run classifies without fetching or moving anything — with the pin not yet in the local object store it reports optimistically (like restore's dry run) and says a real run would fetch. + ### The commands -- `restore [paths…] [--from ] [--base ] [--skip ] [--dry-run]` — resolve, clone, SHA-verify, detach-checkout the pin, and record the resolved URL. Per-child outcome is one of `restored`, `already-present`, `unresolved`, `pinned-mismatch`, `skipped`; the command exits non-zero when any non-skipped child ends `unresolved` or `pinned-mismatch`. +- `restore [paths…] [--from ] [--base ] [--skip ] [--dry-run]` — resolve, clone, SHA-verify, check out the pin (on the resolved branch, else detached), and record the resolved URL and branch. Per-child outcome is one of `restored`, `already-present`, `unresolved`, `pinned-mismatch`, `skipped`; the command exits non-zero when any non-skipped child ends `unresolved` or `pinned-mismatch`. +- `sync [paths…] [--skip ] [--dry-run]` — move present children to the pins in the parent's HEAD, per the disposition table above. Exits non-zero only on `pin-unavailable` / `sync-failed`. - `record [paths…]` — write each present child's `remote.origin.url` and current branch into the registry. -- `export [-o ] [--scan]` — serialize the registry to a manifest (stdout by default; `--scan` records first). The manifest must never be committed; when `-o` writes inside the worktree the filename is appended to `.git/info/exclude` as a courtesy. -- `link ` — clone a child into a missing or empty gitlink directory, stage the gitlink, and record its URL. +- `export [-o ] [--scan]` — serialize the registry (URLs and branches) to a manifest (stdout by default; `--scan` records first). The manifest must never be committed; when `-o` writes inside the worktree the filename is appended to `.git/info/exclude` as a courtesy. `restore --from` consumes both the URL and the branch, so the record → export → restore loop round-trips the branch. +- `link ` — clone a child into a missing or empty gitlink directory, stage the gitlink, and record its URL and branch. ## Implementation notes diff --git a/src/api/cli/restore.mjs b/src/api/cli/restore.mjs index 2fcf90c..a0714c9 100644 --- a/src/api/cli/restore.mjs +++ b/src/api/cli/restore.mjs @@ -3,7 +3,7 @@ import { self } from "@cldmv/slothlet/runtime"; export const spec = { command: "restore", description: - "Clone missing embedded child repos and check out their pinned SHAs. Each child's URL is resolved strictest-first — local config, a manifest (--from), --base, then the parent's origin convention — and every clone is SHA-verified so a wrong guess fails closed.", + "Clone missing embedded child repos and check out their pinned SHAs. Each child's URL is resolved strictest-first — local config, a manifest (--from), --base, then the parent's origin convention — and every clone is SHA-verified so a wrong guess fails closed. A branch from the registry/manifest (or inferred when exactly one origin branch contains the pin) puts the child ON that branch at the pin; otherwise the checkout is detached.", args: [["[paths...]", "Restrict to these gitlink paths (default: every embedded gitlink)"]], options: [ ["--from ", "Read child URLs from a manifest JSON file (a transfer file; never committed)"], @@ -21,7 +21,7 @@ export const spec = { }; const LABEL = { - restored: (r) => `${r.dryRun ? "would restore" : "restored"} ${r.path} from ${r.source} (${r.url})`, + restored: (r) => `${r.dryRun ? "would restore" : "restored"} ${r.path} from ${r.source} (${r.url})${r.branch ? ` on branch ${r.branch}` : ""}`, "already-present": (r) => `${r.path} already present`, skipped: (r) => `${r.path} skipped`, unresolved: (r) => `${r.path} unresolved${r.note ? ` — ${r.note}` : ""}`, diff --git a/src/api/cli/sync.mjs b/src/api/cli/sync.mjs new file mode 100644 index 0000000..7de2a29 --- /dev/null +++ b/src/api/cli/sync.mjs @@ -0,0 +1,70 @@ +import { self } from "@cldmv/slothlet/runtime"; + +export const spec = { + command: "sync", + description: + "Move already-present embedded children to the pins in the parent's HEAD (day-2, after pulling the parent — sync never touches the parent itself). Clean children follow the pin: the registered branch fast-forwards, a detached child snaps. Dirty children, commits beyond the pin, and unregistered branches are your work — reported and left alone.", + args: [["[paths...]", "Restrict to these gitlink paths (default: every embedded gitlink)"]], + options: [ + ["--skip ", "Comma-separated gitlink paths to skip"], + ["--dry-run", "Report what would happen without fetching or moving anything"] + ], + examples: ["$ git pull && git-embedded sync", "$ git-embedded sync tests", "$ git-embedded sync --dry-run"] +}; + +const LABEL = { + synced: (r) => + `${r.dryRun ? "would sync" : "synced"} ${r.path} → ${r.sha.slice(0, 12)}${r.branch ? ` (branch ${r.branch})` : " (detached)"}${r.note ? ` — ${r.note}` : ""}`, + "in-sync": (r) => `${r.path} already at pin`, + dirty: (r) => `${r.path} ${r.note}`, + ahead: (r) => `${r.path} ${r.note}`, + "unregistered-branch": (r) => `${r.path} ${r.note}`, + "pin-unavailable": (r) => `${r.path} pin-unavailable${r.note ? ` — ${r.note}` : ""}`, + "sync-failed": (r) => `${r.path} sync-failed${r.note ? ` — ${r.note}` : ""}`, + skipped: (r) => `${r.path} skipped`, + "no-repo": (r) => `${r.path} ${r.note}` +}; + +export function run(paths = [], opts = {}) { + const skip = + typeof opts.skip === "string" + ? opts.skip + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : []; + + const { results, exitCode } = self.embedded.sync({ + cwd: process.cwd(), + paths, + skip, + dryRun: Boolean(opts.dryRun) + }); + + if (!results.length) { + self.report.plain("No embedded children present to sync."); + process.exit(0); + } + + for (const r of results) { + const line = LABEL[r.outcome] ? LABEL[r.outcome](r) : `${r.path}: ${r.outcome}`; + if (r.outcome === "synced") self.report.success(line); + else if (r.outcome === "pin-unavailable" || r.outcome === "sync-failed") self.report.error(line); + else self.report.warn(line); + } + + // Each outcome lands in exactly one bucket; "left alone" collects the + // deliberate your-work outcomes, which are not failures. + const synced = results.filter((r) => r.outcome === "synced").length; + const unchanged = results.filter((r) => r.outcome === "in-sync").length; + const leftAlone = results.filter((r) => ["dirty", "ahead", "unregistered-branch"].includes(r.outcome)).length; + const skipped = results.filter((r) => ["skipped", "no-repo"].includes(r.outcome)).length; + const failed = results.filter((r) => r.outcome === "pin-unavailable" || r.outcome === "sync-failed").length; + self.report.plain(""); + self.report.plain( + `${synced} ${opts.dryRun ? "syncable" : "synced"}, ${unchanged} unchanged, ${leftAlone} left alone${skipped ? `, ${skipped} skipped` : ""}, ${failed} failed.` + ); + process.exit(exitCode); +} + +export default { spec, run }; diff --git a/src/api/embedded/branch.mjs b/src/api/embedded/branch.mjs new file mode 100644 index 0000000..d599872 --- /dev/null +++ b/src/api/embedded/branch.mjs @@ -0,0 +1,62 @@ +import { context } from "@cldmv/slothlet/runtime"; + +function git(args, opts = {}) { + const res = context.spawnSync("git", args, { encoding: "utf8", ...opts }); + return { code: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() }; +} + +/** + * Branch helpers for embedded children — inference of the branch a pin lives + * on, and attaching a child to a branch at a pin. Used by `restore` (initial + * branch-aware checkout) and `sync` (day-2 fast-forward of the registered + * branch). + * + * @namespace api.embedded.branch + */ + +/** + * Infer the branch a pinned commit lives on: exactly ONE `origin` remote + * branch must contain the pin, otherwise inference declines (returns null) and + * the caller keeps detached-HEAD behavior. + * + * Full refnames (`%(refname)`) are load-bearing: `origin/HEAD` short-forms to + * bare `origin`, which would enter the candidate set as a phantom "branch" and + * poison the uniqueness check. Matching `refs/remotes/origin/` and + * excluding `HEAD` explicitly keeps the symref out. + * + * @param {string} childDir absolute path of the child working tree + * @param {string} sha the pinned commit + * @returns {string|null} the single containing branch name, or null when the + * pin is on no remote branch or more than one (ambiguous) + */ +export function infer(childDir, sha) { + const res = git(["-C", childDir, "branch", "-r", "--contains", sha, "--format=%(refname)"]); + if (res.code !== 0) return null; + const names = res.stdout + .split(/\r?\n/) + .map((line) => (line.match(/^refs\/remotes\/origin\/(?!HEAD$)(.+)$/) || [])[1]) + .filter(Boolean); + const unique = new Set(names); + return unique.size === 1 ? names[0] : null; +} + +/** + * Put a child ON `branch` at `sha`: `checkout -B` (create or reset the local + * branch at the pin) plus a soft `--set-upstream-to=origin/` — soft + * because a registered branch need not exist on the remote (a local working + * branch is legitimate), and tracking is a convenience, not a correctness + * requirement. + * + * @param {string} childDir absolute path of the child working tree + * @param {string} branch branch name to attach + * @param {string} sha the pinned commit the branch should point at + * @returns {boolean} true when the checkout succeeded (upstream is best-effort) + */ +export function attach(childDir, branch, sha) { + const checkout = git(["-C", childDir, "checkout", "--quiet", "-B", branch, sha]); + if (checkout.code !== 0) return false; + git(["-C", childDir, "branch", `--set-upstream-to=origin/${branch}`, branch]); + return true; +} + +export default { infer, attach }; diff --git a/src/api/embedded/restore.mjs b/src/api/embedded/restore.mjs index 1cef5aa..29c6f88 100644 --- a/src/api/embedded/restore.mjs +++ b/src/api/embedded/restore.mjs @@ -29,6 +29,13 @@ function removeClone(absChild, existedBefore) { * SHAs, resolving each URL strictest-source-first and SHA-verifying every clone * so a wrong convention guess fails closed. * + * Branch-aware: a branch for the child is resolved with the same layering as + * the URL — the registry (`embedded..branch`), then the manifest — and + * when neither supplies one it is inferred from the pin (exactly ONE `origin` + * branch containing it). With a branch the child ends ON that branch at the + * pin (upstream set best-effort, branch auto-registered); without one — + * including an ambiguous pin — the checkout stays detached. + * * Partial restore is normal — a public cloner without access to a private child * passes that path in `skip` and the rest still restore. * @@ -68,7 +75,7 @@ export default function restore(opts = {}) { for (const { path: childPath, sha } of links) { if (wantSet && !wantSet.has(childPath)) continue; - const record = { path: childPath, sha, url: null, source: null, note: null }; + const record = { path: childPath, sha, url: null, source: null, branch: null, note: null }; if (skipSet.has(childPath)) { results.push({ ...record, outcome: "skipped" }); @@ -119,6 +126,15 @@ export default function restore(opts = {}) { continue; } + // Branch precedence mirrors URL precedence: the per-clone registry first, + // then the manifest. Inference from the pin needs the clone to exist, so + // it runs after SHA verification below. Own-property manifest access for + // the same reason as resolve (a child path named "constructor"). + const manifestChild = + manifest && manifest.children && Object.hasOwn(manifest.children, childPath) ? manifest.children[childPath] : null; + const wantedBranch = self.embedded.registry.getBranch(childPath, root) || (manifestChild && manifestChild.branch) || null; + record.branch = wantedBranch; + if (dryRun) { results.push({ ...record, outcome: "restored", dryRun: true }); continue; @@ -152,15 +168,30 @@ export default function restore(opts = {}) { continue; } - const checkout = git(["-C", absChild, "checkout", "--quiet", "--detach", sha]); - if (checkout.code !== 0) { - removeClone(absChild, existedBefore); - results.push({ ...record, outcome: "pinned-mismatch", note: `could not check out ${sha.slice(0, 12)}; clone removed` }); - continue; + // Branch-aware checkout: registry/manifest branch wins; otherwise infer it + // from the pin. Attach failure (e.g. an invalid branch name in the + // registry) falls back to today's detached checkout rather than failing + // the restore — the pin is verified present, so detached is always safe. + const branch = wantedBranch || self.embedded.branch.infer(absChild, sha); + let attached = false; + if (branch) { + attached = self.embedded.branch.attach(absChild, branch, sha); + if (!attached) record.note = `could not attach branch ${branch}; checked out detached`; + } + record.branch = attached ? branch : null; + if (!attached) { + const checkout = git(["-C", absChild, "checkout", "--quiet", "--detach", sha]); + if (checkout.code !== 0) { + removeClone(absChild, existedBefore); + results.push({ ...record, outcome: "pinned-mismatch", note: `could not check out ${sha.slice(0, 12)}; clone removed` }); + continue; + } } - // Persist the resolved URL so day-2 re-restores don't re-derive it. + // Persist the resolved URL (and the branch the child ended on) so day-2 + // re-restores and `sync` don't re-derive them. self.embedded.registry.setUrl(childPath, resolved.url, root); + if (attached) self.embedded.registry.setBranch(childPath, branch, root); results.push({ ...record, outcome: "restored" }); } diff --git a/src/api/embedded/sync.mjs b/src/api/embedded/sync.mjs new file mode 100644 index 0000000..843c0e4 --- /dev/null +++ b/src/api/embedded/sync.mjs @@ -0,0 +1,158 @@ +import { self, context } from "@cldmv/slothlet/runtime"; + +function git(args, opts = {}) { + const res = context.spawnSync("git", args, { encoding: "utf8", ...opts }); + return { code: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() }; +} + +/** + * Sync engine: move already-present embedded children to the pins in the + * parent's HEAD (day-2 — after the parent pulled new gitlink pins). The parent + * itself is never touched; pulling it first is the caller's step. + * + * Per child, in order: + * - HEAD already at the pin → `in-sync` (done). + * - uncommitted changes → `dirty`, left alone (that's your work). + * - pin absent locally → one `git fetch origin`; still absent → + * `pin-unavailable` (a real failure — non-zero exit). + * - on the REGISTERED branch (`embedded..branch`) and clean → + * fast-forward-only: HEAD must be an ancestor of the pin, then the branch + * is moved to the pin (upstream refreshed best-effort). Ahead/diverged → + * `ahead`, left alone (your work). + * - on any other branch → `unregistered-branch`, left alone (reported). + * - detached and clean → detach to the pin. + * + * Only `pin-unavailable` (and an unexpected checkout failure, `sync-failed`) + * make the exit code non-zero — the left-alone outcomes are deliberate + * protection of in-progress work, not errors. + * + * @param {object} [opts] + * @param {string} [opts.cwd] working directory inside the parent repo + * @param {string[]} [opts.paths] restrict to these gitlink paths (default: all) + * @param {string[]} [opts.skip] gitlink paths to skip + * @param {boolean} [opts.dryRun] classify and report only; fetch/move nothing + * @returns {{ results: Array, exitCode: number }} per-child outcomes + * (`synced`, `in-sync`, `dirty`, `ahead`, `unregistered-branch`, + * `pin-unavailable`, `sync-failed`, `skipped`, `no-repo`) and a process exit + * code (non-zero when any child ends `pin-unavailable` or `sync-failed`) + */ +export default function sync(opts = {}) { + const { fs, path } = context; + const { cwd = process.cwd(), paths = [], skip = [], dryRun = false } = opts; + + const root = self.git.getRepoRoot(cwd) || cwd; + + // Same filter-spelling normalization as restore: gitlink paths are + // root-relative with forward slashes; accept "./tests", "tests/", "vendor\\foo". + const normalizePath = (p) => + String(p) + .replace(/\\/g, "/") + .replace(/^\.\/+/, "") + .replace(/\/+$/, ""); + const skipSet = new Set(skip.map(normalizePath)); + const wantSet = paths.length ? new Set(paths.map(normalizePath)) : null; + + const links = self.embedded.gitlinks(root); + const results = []; + + for (const { path: childPath, sha } of links) { + if (wantSet && !wantSet.has(childPath)) continue; + + const record = { path: childPath, sha, branch: null, note: null }; + + if (skipSet.has(childPath)) { + results.push({ ...record, outcome: "skipped" }); + continue; + } + + const absChild = path.resolve(root, childPath); + if (!fs.existsSync(path.join(absChild, ".git"))) { + // A missing child is restore's job, not sync's; report it only when the + // caller asked for this path explicitly (mirrors record's idiom). + if (wantSet) results.push({ ...record, outcome: "no-repo", note: "not present on disk — run restore" }); + continue; + } + + const head = git(["-C", absChild, "rev-parse", "HEAD"]).stdout; + if (head === sha) { + results.push({ ...record, outcome: "in-sync" }); + continue; + } + + const status = git(["-C", absChild, "status", "--porcelain"]); + if (status.code !== 0 || status.stdout) { + results.push({ ...record, outcome: "dirty", note: "pin moved but child has uncommitted changes — left alone" }); + continue; + } + + // Pin availability: one fetch before giving up. A dry run must not write + // even to the object store, so it reports optimistically (like restore's + // dry run) with a note instead of fetching. + let pinPresent = git(["-C", absChild, "cat-file", "-e", `${sha}^{commit}`]).code === 0; + if (!pinPresent && !dryRun) { + git(["-C", absChild, "fetch", "--quiet", "origin"]); + pinPresent = git(["-C", absChild, "cat-file", "-e", `${sha}^{commit}`]).code === 0; + if (!pinPresent) { + results.push({ ...record, outcome: "pin-unavailable", note: `pinned ${sha.slice(0, 12)} not found at origin after fetch` }); + continue; + } + } + if (!pinPresent && dryRun) record.note = "pin not in the local object store — a real run would fetch origin first"; + + const branch = git(["-C", absChild, "branch", "--show-current"]).stdout || null; + const registered = self.embedded.registry.getBranch(childPath, root); + + if (branch && (!registered || branch !== registered)) { + results.push({ + ...record, + branch, + outcome: "unregistered-branch", + note: `pin moved but child is on unregistered branch '${branch}' — left alone` + }); + continue; + } + + if (branch) { + // The child LIVES on this branch (registry says so) — move the branch to + // the pin, fast-forward only: HEAD must be an ancestor of the pin. + // Commits beyond the pin are your work and stay untouched. With the pin + // object absent (dry run), ancestry is unknowable — keep the optimistic + // dry-run report. + const ancestor = pinPresent ? git(["-C", absChild, "merge-base", "--is-ancestor", "HEAD", sha]).code === 0 : true; + if (!ancestor) { + results.push({ + ...record, + branch, + outcome: "ahead", + note: `on '${branch}' with commits beyond the pin — left alone (your work)` + }); + continue; + } + if (dryRun) { + results.push({ ...record, branch, outcome: "synced", dryRun: true }); + continue; + } + if (!self.embedded.branch.attach(absChild, branch, sha)) { + results.push({ ...record, branch, outcome: "sync-failed", note: `could not move branch ${branch} to ${sha.slice(0, 12)}` }); + continue; + } + results.push({ ...record, branch, outcome: "synced" }); + continue; + } + + // Detached and clean: snap to the pin, staying detached. + if (dryRun) { + results.push({ ...record, outcome: "synced", dryRun: true }); + continue; + } + const checkout = git(["-C", absChild, "checkout", "--quiet", "--detach", sha]); + if (checkout.code !== 0) { + results.push({ ...record, outcome: "sync-failed", note: `could not check out ${sha.slice(0, 12)}` }); + continue; + } + results.push({ ...record, outcome: "synced" }); + } + + const exitCode = results.some((r) => r.outcome === "pin-unavailable" || r.outcome === "sync-failed") ? 1 : 0; + return { results, exitCode }; +} diff --git a/tests/embedded-provisioning.test.mjs b/tests/embedded-provisioning.test.mjs index 310e9d7..2ef0a01 100644 --- a/tests/embedded-provisioning.test.mjs +++ b/tests/embedded-provisioning.test.mjs @@ -91,6 +91,31 @@ function freshClone(parentBare) { return dir; } +/** + * Advance the child source repo by one commit. Pushed to the bare's `main` by + * default; `push: false` creates a commit that exists NOWHERE the child clone + * can fetch from (the missing-pin case). Returns the new SHA. + */ +function advanceChild(work, bareName, marker, { push = true } = {}) { + const src = path.join(work, `src-${bareName}`); + fs.writeFileSync(path.join(src, "next.txt"), marker); + git(["add", "."], src); + git(["commit", "-m", `${bareName} advance`], src); + if (push) git(["push", "origin", "main"], src); + return git(["rev-parse", "HEAD"], src); +} + +/** + * Move the parent's gitlink pin to `sha` without touching the child on disk — + * exactly the state a `git pull` of new parent commits leaves behind. + * `--cacheinfo` records the gitlink straight into the index, so the pinned + * commit need not exist locally. + */ +function bumpPin(parentDir, childPath, sha) { + git(["update-index", "--cacheinfo", `160000,${sha},${childPath}`], parentDir); + git(["commit", "-m", `bump ${childPath} pin`], parentDir); +} + let originalEnv; let originalCwd; @@ -476,6 +501,254 @@ describe("git argument-injection + registry-key normalization (review round 5)", }); }); +describe("branch-aware restore", () => { + it("puts the child ON the unique containing branch, sets upstream, and auto-registers it", () => { + const { parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("restored"); + expect(results[0].branch).toBe("main"); + + const child = path.join(fresh, "tests"); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); + expect(git(["branch", "--show-current"], child)).toBe("main"); + expect(git(["rev-parse", "--abbrev-ref", "main@{upstream}"], child)).toBe("origin/main"); + // Auto-registered like the URL, so day-2 sync knows the child's branch. + expect(api.embedded.registry.getBranch("tests", fresh)).toBe("main"); + }); + + it("stays detached when the pin is on more than one remote branch (ambiguous)", () => { + const { work, parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + // A second remote branch containing the same pin → inference must decline. + git(["push", "origin", "main:dev"], path.join(work, "src-tests")); + const fresh = freshClone(parentBare); + + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("restored"); + expect(results[0].branch).toBeNull(); + + const child = path.join(fresh, "tests"); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); + expect(git(["branch", "--show-current"], child)).toBe(""); // detached + expect(api.embedded.registry.getBranch("tests", fresh)).toBeNull(); + }); + + it("infer is not poisoned by origin/HEAD (full-refname regression)", () => { + const { work, childBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const clone = path.join(work, "infer-clone"); + git(["clone", "--quiet", childBare, clone]); + git(["remote", "set-head", "origin", "--auto"], clone); + // Precondition: origin/HEAD is set — with short refnames it would list as + // bare "origin" and fake a second candidate, breaking uniqueness. + expect(git(["symbolic-ref", "refs/remotes/origin/HEAD"], clone)).toBe("refs/remotes/origin/main"); + expect(api.embedded.branch.infer(clone, childSha)).toBe("main"); + }); + + it("a registered branch beats inference (and survives a missing remote branch)", () => { + const { parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + // Inference alone would pick "main"; the registry says otherwise. + api.embedded.registry.setBranch("tests", "pinned-work", fresh); + + const { results } = api.embedded.restore({ cwd: fresh }); + expect(results[0].outcome).toBe("restored"); + expect(results[0].branch).toBe("pinned-work"); + + const child = path.join(fresh, "tests"); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); + expect(git(["branch", "--show-current"], child)).toBe("pinned-work"); + // No origin/pinned-work exists — upstream is best-effort, not a failure. + expect(api.embedded.registry.getBranch("tests", fresh)).toBe("pinned-work"); + }); + + it("round-trips the branch record → export → restore --from on a second machine", () => { + // Obscured name (no convention) + ambiguous inference (two branches carry + // the pin): only the manifest can supply BOTH the URL and the branch. + const { work, parentBare, childBare, childSha } = makeParent({ gitlinkPath: "tests", childBareName: "secret-xyz" }); + git(["push", "origin", "main:dev"], path.join(work, "src-secret-xyz")); + + // Machine A: link records url + branch; export serializes both. + const machineA = freshClone(parentBare); + process.chdir(machineA); + vi.spyOn(process, "exit").mockImplementation((code) => { + throw new Error(`process.exit(${code})`); + }); + api.cli.link.run("tests", childBare); + process.chdir(originalCwd); + + const entries = api.embedded.registry.entries(machineA); + expect(entries).toEqual([{ path: "tests", url: childBare, branch: "main" }]); + const manifestFile = path.join(mkTmp(), "children.json"); + fs.writeFileSync(manifestFile, api.embedded.manifest.serialize(api.embedded.manifest.build(entries))); + + // Machine B: restore --from puts the child ON the manifest's branch. + const machineB = freshClone(parentBare); + const { results, exitCode } = api.embedded.restore({ cwd: machineB, from: manifestFile }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("restored"); + expect(results[0].branch).toBe("main"); + + const child = path.join(machineB, "tests"); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); + expect(git(["branch", "--show-current"], child)).toBe("main"); + expect(api.embedded.registry.getBranch("tests", machineB)).toBe("main"); + }); +}); + +describe("api.embedded.sync (day-2 pin sync)", () => { + it("fast-forwards the registered branch to a moved pin (fetching the pin first)", () => { + const { work, parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); // child on main @ childSha, branch registered + + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("synced"); + expect(results[0].branch).toBe("main"); + + const child = path.join(fresh, "tests"); + expect(git(["rev-parse", "HEAD"], child)).toBe(sha2); + expect(git(["branch", "--show-current"], child)).toBe("main"); + expect(git(["rev-parse", "--abbrev-ref", "main@{upstream}"], child)).toBe("origin/main"); + + // Idempotent: a second sync is a no-op. + const again = api.embedded.sync({ cwd: fresh }); + expect(again.results[0].outcome).toBe("in-sync"); + expect(again.exitCode).toBe(0); + }); + + it("dry-run reports the move without fetching or touching the child", () => { + const { work, parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh, dryRun: true }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("synced"); + expect(results[0].dryRun).toBe(true); + + const child = path.join(fresh, "tests"); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); // unmoved + // No fetch happened — the new pin is still absent from the object store. + const probe = spawnSync("git", ["cat-file", "-e", `${sha2}^{commit}`], { cwd: child }); + expect(probe.status).not.toBe(0); + }); + + it("leaves a registered branch with commits beyond the pin alone (your work)", () => { + const { parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + + const child = path.join(fresh, "tests"); + fs.writeFileSync(path.join(child, "wip.txt"), "local work"); + git(["add", "."], child); + git(["commit", "-m", "local work beyond the pin"], child); + const localSha = git(["rev-parse", "HEAD"], child); + expect(localSha).not.toBe(childSha); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("ahead"); + expect(results[0].note).toMatch(/beyond the pin.*your work/); + expect(git(["rev-parse", "HEAD"], child)).toBe(localSha); // untouched + }); + + it("leaves a dirty child alone", () => { + const { work, parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); + const child = path.join(fresh, "tests"); + fs.writeFileSync(path.join(child, "uncommitted.txt"), "precious"); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("dirty"); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); // unmoved + expect(fs.readFileSync(path.join(child, "uncommitted.txt"), "utf8")).toBe("precious"); + }); + + it("snaps a clean, detached child to the moved pin (staying detached)", () => { + const { work, parentBare } = makeParent({ gitlinkPath: "tests" }); + // Ambiguous inference → restore leaves the child detached, no branch registered. + git(["push", "origin", "main:dev"], path.join(work, "src-tests")); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("synced"); + expect(results[0].branch).toBeNull(); + + const child = path.join(fresh, "tests"); + expect(git(["rev-parse", "HEAD"], child)).toBe(sha2); + expect(git(["branch", "--show-current"], child)).toBe(""); // still detached + }); + + it("leaves a child on an unregistered branch alone", () => { + const { work, parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); // registers "main" + + const child = path.join(fresh, "tests"); + git(["checkout", "-b", "feature"], child); + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(exitCode).toBe(0); + expect(results[0].outcome).toBe("unregistered-branch"); + expect(results[0].note).toMatch(/'feature'.*left alone/); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); // unmoved + expect(git(["branch", "--show-current"], child)).toBe("feature"); + }); + + it("reports pin-unavailable (non-zero) when one fetch cannot find the pin", () => { + const { work, parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + + // A pin that exists nowhere the child can fetch from (never pushed). + const ghostSha = advanceChild(work, "tests", "ghost", { push: false }); + bumpPin(fresh, "tests", ghostSha); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(exitCode).toBe(1); + expect(results[0].outcome).toBe("pin-unavailable"); + expect(results[0].note).toMatch(/not found at origin/); + expect(git(["rev-parse", "HEAD"], path.join(fresh, "tests"))).toBe(childSha); // unmoved + }); + + it("reports no-repo only for an explicitly requested absent child", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); // child never restored + + // Unfiltered: an absent child is restore's job — silently ignored. + const all = api.embedded.sync({ cwd: fresh }); + expect(all.results).toHaveLength(0); + expect(all.exitCode).toBe(0); + + // Explicitly requested: reported, but not a sync failure. + const asked = api.embedded.sync({ cwd: fresh, paths: ["tests"] }); + expect(asked.results[0].outcome).toBe("no-repo"); + expect(asked.exitCode).toBe(0); + }); +}); + describe("filter-path normalization (review round 6)", () => { it("--skip and paths filters accept './x', 'x/', and backslash spellings", () => { const { parentBare } = makeParent({ gitlinkPath: "tests" }); From 9496c0997776e64a69d0e05d2f9158189cf3e4f7 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Fri, 17 Jul 2026 21:25:39 -0700 Subject: [PATCH 13/18] fix(embedded): symlink-safe restore, normalized record filters, sync git-failure reporting (review round 7) - restore: lstat + refuse a symlinked child BEFORE the .git probe, so a symlink resolving to a real repo can no longer be blessed as already-present and then followed out of the worktree by the packaged hooks. - record: normalize path filters (./x, x/, backslashes) like restore/sync, so 'record ./tests' no longer silently no-matches the gitlink path. - sync: report git command failures (rev-parse HEAD / status) as sync-failed (non-zero) instead of mislabeling them in-sync/dirty. - docs(README): document the scp-style-root form of the convention resolver (git@host:parent.git -> git@host:tests.git), matching resolve.mjs. - tests: symlink-with-.git bypass, record filter normalization, and both sync git-failure paths. --- README.md | 2 +- src/api/embedded/record.mjs | 10 +++- src/api/embedded/restore.mjs | 35 ++++++++------ src/api/embedded/sync.mjs | 27 +++++++++-- tests/embedded-provisioning.test.mjs | 69 ++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index e10ab16..be73460 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ git embedded restore # clone every embedded child and check out its pin 1. **Local config registry** — `embedded..url` (and `embedded..branch`, see below) in _this clone's_ `.git/config`. Per-clone, never committed. Written automatically after a successful restore, and by `record` / `link`. 2. **Manifest file** (`--from `) — a JSON transfer file carried out-of-band (never committed). See `export` below. 3. **`--base `** — derives `/.git` for each child. -4. **Convention** (zero state) — the child is a sibling of wherever the parent was cloned from: `dirname(parent origin) + "/" + basename() + ".git"`. No configuration, but it only resolves when the child's repository is actually named after the gitlink path and sits beside the parent. A convention guess can only ever name strings already derivable from the committed tree, so it discloses nothing new. +4. **Convention** (zero state) — the child is a sibling of wherever the parent was cloned from: the parent's origin with its last path segment replaced by `.git`. A URL- or path-style origin splits on the final `/` (`https://host/org/parent.git` → `https://host/org/tests.git`); a scp-style origin whose repo sits at the path root has no `/`, so the sibling is taken after the last `:` instead (`git@host:parent.git` → `git@host:tests.git`). No configuration, but it only resolves when the child's repository is actually named after the gitlink path and sits beside the parent. A convention guess can only ever name strings already derivable from the committed tree, so it discloses nothing new. Every clone is **SHA-verified**: the parent's pinned commit must exist in the freshly cloned child (a `git fetch` is attempted first). If it doesn't — e.g. a convention guess resolved to the wrong repository — the clone `restore` created is removed and the child is reported `pinned-mismatch`. A wrong guess fails closed; it never plants the wrong code. diff --git a/src/api/embedded/record.mjs b/src/api/embedded/record.mjs index 61ddc7f..3e57727 100644 --- a/src/api/embedded/record.mjs +++ b/src/api/embedded/record.mjs @@ -19,7 +19,15 @@ export default function record(opts = {}) { const { paths = [] } = opts; const root = self.git.getRepoRoot(cwd) || cwd; - const wantSet = paths.length ? new Set(paths) : null; + // Same filter-spelling normalization as restore/sync: gitlink paths from + // gitlinks() are root-relative with forward slashes, so accept "./tests", + // "tests/", and Windows "vendor\\foo" instead of silently not matching. + const normalizePath = (p) => + String(p) + .replace(/\\/g, "/") + .replace(/^\.\/+/, "") + .replace(/\/+$/, ""); + const wantSet = paths.length ? new Set(paths.map(normalizePath)) : null; const links = self.embedded.gitlinks(root); const results = []; diff --git a/src/api/embedded/restore.mjs b/src/api/embedded/restore.mjs index 29c6f88..fc63640 100644 --- a/src/api/embedded/restore.mjs +++ b/src/api/embedded/restore.mjs @@ -83,28 +83,37 @@ export default function restore(opts = {}) { } const absChild = path.resolve(root, childPath); - const hasGit = fs.existsSync(path.join(absChild, ".git")); - if (hasGit) { - results.push({ ...record, outcome: "already-present" }); - continue; - } - // The only acceptable pre-existing target is an EMPTY, REAL directory — - // what a fresh parent clone materializes for a gitlink. A file, a - // directory with contents, or a SYMLINK (even to an empty dir — cloning - // through it would write outside the repo) is user data: never clone - // into it, never remove it. lstat so links are seen, not followed; this - // also catches a broken symlink, which existsSync would miss. + // lstat BEFORE probing for `.git` so a symlinked child is refused before + // anything follows it. A symlink that resolves to a real repo would + // otherwise satisfy the existsSync(.git) check below and be blessed as + // already-present — yet the packaged hooks cd into the child and would + // follow that link out of the parent worktree. lstat (not stat) sees the + // link itself, and also catches a broken symlink that existsSync misses. let targetStat = null; try { targetStat = fs.lstatSync(absChild); } catch { /* missing — clone will create it */ } + if (targetStat && targetStat.isSymbolicLink()) { + results.push({ ...record, outcome: "unresolved", note: "target is a symbolic link — refusing to touch it" }); + continue; + } + + const hasGit = fs.existsSync(path.join(absChild, ".git")); + if (hasGit) { + results.push({ ...record, outcome: "already-present" }); + continue; + } + + // The only acceptable pre-existing target is an EMPTY, REAL directory — + // what a fresh parent clone materializes for a gitlink. A file or a + // directory with contents is user data: never clone into it, never remove + // it. (A symlink was already refused above.) if (targetStat) { let refuse = null; - if (targetStat.isSymbolicLink()) refuse = "target is a symbolic link"; - else if (!targetStat.isDirectory()) refuse = "target exists and is not a directory"; + if (!targetStat.isDirectory()) refuse = "target exists and is not a directory"; else { try { if (fs.readdirSync(absChild).length > 0) refuse = "target directory is not empty"; diff --git a/src/api/embedded/sync.mjs b/src/api/embedded/sync.mjs index 843c0e4..0fb37b9 100644 --- a/src/api/embedded/sync.mjs +++ b/src/api/embedded/sync.mjs @@ -22,9 +22,10 @@ function git(args, opts = {}) { * - on any other branch → `unregistered-branch`, left alone (reported). * - detached and clean → detach to the pin. * - * Only `pin-unavailable` (and an unexpected checkout failure, `sync-failed`) - * make the exit code non-zero — the left-alone outcomes are deliberate - * protection of in-progress work, not errors. + * Only `pin-unavailable` and `sync-failed` (an unexpected git failure — reading + * HEAD or status, the branch move, or the checkout) make the exit code + * non-zero; the left-alone outcomes are deliberate protection of in-progress + * work, not errors. * * @param {object} [opts] * @param {string} [opts.cwd] working directory inside the parent repo @@ -73,14 +74,30 @@ export default function sync(opts = {}) { continue; } - const head = git(["-C", absChild, "rev-parse", "HEAD"]).stdout; + const headRes = git(["-C", absChild, "rev-parse", "HEAD"]); + if (headRes.code !== 0) { + results.push({ + ...record, + outcome: "sync-failed", + note: `could not read HEAD: ${headRes.stderr || `git rev-parse exited ${headRes.code}`}` + }); + continue; + } + const head = headRes.stdout; if (head === sha) { results.push({ ...record, outcome: "in-sync" }); continue; } + // A non-zero `git status` is a command failure (corrupt repo, permissions), + // not "uncommitted changes" — report it as sync-failed so the exit code is + // non-zero and stderr surfaces, instead of mislabeling it dirty. const status = git(["-C", absChild, "status", "--porcelain"]); - if (status.code !== 0 || status.stdout) { + if (status.code !== 0) { + results.push({ ...record, outcome: "sync-failed", note: `git status failed: ${status.stderr || `exit ${status.code}`}` }); + continue; + } + if (status.stdout) { results.push({ ...record, outcome: "dirty", note: "pin moved but child has uncommitted changes — left alone" }); continue; } diff --git a/tests/embedded-provisioning.test.mjs b/tests/embedded-provisioning.test.mjs index 2ef0a01..10fbba2 100644 --- a/tests/embedded-provisioning.test.mjs +++ b/tests/embedded-provisioning.test.mjs @@ -313,6 +313,26 @@ describe("record / export round-trip", () => { expect(results[0].url).toBe(childBare); expect(api.embedded.registry.getUrl("tests", fresh)).toBe(childBare); }); + + it("record normalizes './tests' and 'tests/' path filters to the gitlink path", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); // child present with origin wired + git(["config", "--local", "--unset", "embedded.tests.url"], fresh); + + // './tests' previously missed the gitlink path ('tests') and silently + // recorded nothing; it must now match and record. + const dotSlash = api.embedded.record({ cwd: fresh, paths: ["./tests"] }); + expect(dotSlash.results).toHaveLength(1); + expect(dotSlash.results[0].outcome).toBe("recorded"); + expect(api.embedded.registry.getUrl("tests", fresh)).toBe(childBare); + + // Trailing-slash spelling matches too. + git(["config", "--local", "--unset", "embedded.tests.url"], fresh); + const trailing = api.embedded.record({ cwd: fresh, paths: ["tests/"] }); + expect(trailing.results[0].outcome).toBe("recorded"); + expect(api.embedded.registry.getUrl("tests", fresh)).toBe(childBare); + }); }); describe("api.cli.link (empty-dir fix)", () => { @@ -424,6 +444,28 @@ describe("review hardening round 2 (scp-root convention + symlink guards)", () = expect(fs.lstatSync(path.join(fresh, "tests")).isSymbolicLink()).toBe(true); }); + it.skipIf(!canSymlink)("restore refuses a symlinked child even when it resolves to a real repo with .git", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + // A real child clone OUTSIDE the parent, symlinked in at the gitlink path: + // the link target has a .git, so the old .git-first check blessed it as + // already-present and skipped the symlink refusal. The packaged hooks + // would then cd through the link out of the parent worktree. + const outside = path.join(mkTmp(), "outside-child"); + git(["clone", "--quiet", childBare, outside]); + expect(fs.existsSync(path.join(outside, ".git"))).toBe(true); + fs.rmdirSync(path.join(fresh, "tests")); + fs.symlinkSync(outside, path.join(fresh, "tests"), "dir"); + + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(results[0].outcome).toBe("unresolved"); + expect(results[0].note).toMatch(/symbolic link.*refusing/); + expect(exitCode).toBe(1); + // Never adopted as already-present; the link and its target stay intact. + expect(fs.lstatSync(path.join(fresh, "tests")).isSymbolicLink()).toBe(true); + expect(fs.existsSync(path.join(outside, ".git"))).toBe(true); + }); + it.skipIf(!canSymlink)("link refuses a symlink target up-front with exit 2", () => { const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); const fresh = freshClone(parentBare); @@ -747,6 +789,33 @@ describe("api.embedded.sync (day-2 pin sync)", () => { expect(asked.results[0].outcome).toBe("no-repo"); expect(asked.exitCode).toBe(0); }); + + it("reports sync-failed (non-zero) when reading the child's HEAD fails", () => { + const { parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + const child = path.join(fresh, "tests"); + // Point HEAD at a ref that does not exist so `git rev-parse HEAD` errors — + // a git failure, not "uncommitted changes" and not "not at pin". + fs.writeFileSync(path.join(child, ".git", "HEAD"), "ref: refs/heads/corrupt-gone"); + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(results[0].outcome).toBe("sync-failed"); + expect(exitCode).toBe(1); + }); + + it("reports sync-failed for a git status failure instead of mislabeling it dirty", () => { + const { work, parentBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + const sha2 = advanceChild(work, "tests", "v2"); + bumpPin(fresh, "tests", sha2); // HEAD != pin, so status is consulted + const child = path.join(fresh, "tests"); + // Corrupt the index so `git status` errors while HEAD still reads fine. + fs.writeFileSync(path.join(child, ".git", "index"), "not a valid git index"); + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(results[0].outcome).toBe("sync-failed"); + expect(exitCode).toBe(1); + }); }); describe("filter-path normalization (review round 6)", () => { From 9aa8bf558cdb5ffabc5567bfe9cec083cdd1161c Mon Sep 17 00:00:00 2001 From: Shinrai Date: Fri, 17 Jul 2026 22:00:09 -0700 Subject: [PATCH 14/18] fix(embedded): refuse a symlinked gitlink path in sync (review round 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync ran git commands with -C on a gitlink path checked only via existsSync, so a symlinked child pointing outside the parent worktree would be fetched/checked-out out there — the same 'cd out of the worktree' risk restore and link already refuse. sync now lstats the path and reports a symlink as sync-failed (non-zero exit) before touching it, even on an unfiltered run. Test added. --- src/api/embedded/sync.mjs | 20 ++++++++++++++++++++ tests/embedded-provisioning.test.mjs | 17 +++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/api/embedded/sync.mjs b/src/api/embedded/sync.mjs index 0fb37b9..a6fa8e4 100644 --- a/src/api/embedded/sync.mjs +++ b/src/api/embedded/sync.mjs @@ -11,6 +11,8 @@ function git(args, opts = {}) { * itself is never touched; pulling it first is the caller's step. * * Per child, in order: + * - symlinked gitlink path → `sync-failed`, refusing to touch it (never run + * git through a link out of the worktree — same guard as restore/link). * - HEAD already at the pin → `in-sync` (done). * - uncommitted changes → `dirty`, left alone (that's your work). * - pin absent locally → one `git fetch origin`; still absent → @@ -67,6 +69,24 @@ export default function sync(opts = {}) { } const absChild = path.resolve(root, childPath); + + // Refuse a symlinked gitlink path before touching it: every git command + // below runs with `-C absChild`, so a symlink pointing outside the parent + // worktree would have us fetch/checkout out there — the same risk restore + // and link already refuse. lstat sees the link itself (existsSync follows + // it); a symlink here is always an anomaly, so surface it (non-zero exit) + // even on an unfiltered run. + let linkStat = null; + try { + linkStat = fs.lstatSync(absChild); + } catch { + /* missing — handled as no-repo below */ + } + if (linkStat && linkStat.isSymbolicLink()) { + results.push({ ...record, outcome: "sync-failed", note: "gitlink path is a symbolic link — refusing to touch it" }); + continue; + } + if (!fs.existsSync(path.join(absChild, ".git"))) { // A missing child is restore's job, not sync's; report it only when the // caller asked for this path explicitly (mirrors record's idiom). diff --git a/tests/embedded-provisioning.test.mjs b/tests/embedded-provisioning.test.mjs index 10fbba2..abc571e 100644 --- a/tests/embedded-provisioning.test.mjs +++ b/tests/embedded-provisioning.test.mjs @@ -816,6 +816,23 @@ describe("api.embedded.sync (day-2 pin sync)", () => { expect(results[0].outcome).toBe("sync-failed"); expect(exitCode).toBe(1); }); + + it.skipIf(!canSymlink)("refuses a symlinked gitlink path (sync-failed), never running git through the link", () => { + const { parentBare, childBare } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + // Replace the materialized gitlink dir with a symlink to a repo OUTSIDE the + // parent worktree — sync must refuse, not fetch/checkout out there. + const outside = path.join(mkTmp(), "outside-child"); + git(["clone", "--quiet", childBare, outside]); + fs.rmdirSync(path.join(fresh, "tests")); + fs.symlinkSync(outside, path.join(fresh, "tests"), "dir"); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(results[0].outcome).toBe("sync-failed"); + expect(results[0].note).toMatch(/symbolic link.*refusing/); + expect(exitCode).toBe(1); + expect(fs.lstatSync(path.join(fresh, "tests")).isSymbolicLink()).toBe(true); + }); }); describe("filter-path normalization (review round 6)", () => { From 4be400c56d4e3115b9227f7331e44a41e1e42c80 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Sat, 18 Jul 2026 10:59:06 -0700 Subject: [PATCH 15/18] fix(embedded): refuse unreadable targets, surface fetch failures (review round 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - restore/sync: a non-ENOENT lstat error (EACCES/ENOTDIR on an existing path) is now refused (restore -> unresolved, sync -> sync-failed) instead of assumed 'missing' — which could otherwise clone into, and removeClone against, a pre-existing path we can't stat. - restore: a failed verification 'git fetch' is surfaced in the pinned-mismatch note (auth/network failure is not the same as a wrong-repo convention guess). - sync: a failed fallback 'git fetch' is reported as sync-failed with stderr instead of pin-unavailable. Test added (broken child origin -> sync-failed). --- src/api/embedded/restore.mjs | 22 ++++++++++++++++++---- src/api/embedded/sync.mjs | 19 ++++++++++++++++--- tests/embedded-provisioning.test.mjs | 18 ++++++++++++++++++ 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/api/embedded/restore.mjs b/src/api/embedded/restore.mjs index fc63640..5fd8c91 100644 --- a/src/api/embedded/restore.mjs +++ b/src/api/embedded/restore.mjs @@ -93,8 +93,15 @@ export default function restore(opts = {}) { let targetStat = null; try { targetStat = fs.lstatSync(absChild); - } catch { - /* missing — clone will create it */ + } catch (err) { + // Only ENOENT means "missing — clone will create it". A non-ENOENT + // lstat error (EACCES/ENOTDIR on an existing path) must be refused, not + // assumed absent — otherwise we could clone into, and later removeClone + // against, a pre-existing path we can't even stat. + if (err.code !== "ENOENT") { + results.push({ ...record, outcome: "unresolved", note: `target unreadable (${err.code || err.message}) — refusing to touch it` }); + continue; + } } if (targetStat && targetStat.isSymbolicLink()) { results.push({ ...record, outcome: "unresolved", note: "target is a symbolic link — refusing to touch it" }); @@ -163,16 +170,23 @@ export default function restore(opts = {}) { // One fetch is attempted before giving up, in case origin's default // refspec did not include the pinned commit. let present = git(["-C", absChild, "cat-file", "-e", `${sha}^{commit}`]).code === 0; + let fetchErr = null; if (!present) { - git(["-C", absChild, "fetch", "--quiet", "origin"]); + const fetch = git(["-C", absChild, "fetch", "--quiet", "origin"]); + if (fetch.code !== 0) fetchErr = fetch.stderr || `git fetch exited ${fetch.code}`; present = git(["-C", absChild, "cat-file", "-e", `${sha}^{commit}`]).code === 0; } if (!present) { removeClone(absChild, existedBefore); + // A failed fetch (auth/network) is not the same as "wrong repo" — surface + // it so a pinned-mismatch isn't misread as a bad convention guess. + const why = fetchErr + ? `fetch from ${resolved.source} repo failed (${fetchErr})` + : `pinned ${sha.slice(0, 12)} absent in ${resolved.source} repo`; results.push({ ...record, outcome: "pinned-mismatch", - note: `pinned ${sha.slice(0, 12)} absent in ${resolved.source} repo; clone removed` + note: `${why}; clone removed` }); continue; } diff --git a/src/api/embedded/sync.mjs b/src/api/embedded/sync.mjs index a6fa8e4..7a4e3e4 100644 --- a/src/api/embedded/sync.mjs +++ b/src/api/embedded/sync.mjs @@ -79,8 +79,15 @@ export default function sync(opts = {}) { let linkStat = null; try { linkStat = fs.lstatSync(absChild); - } catch { - /* missing — handled as no-repo below */ + } catch (err) { + // Only ENOENT means "missing". A non-ENOENT lstat error (EACCES/ENOTDIR + // on an existing path) is a real failure, not an absent child — surface + // it rather than silently proceeding. + if (err.code !== "ENOENT") { + results.push({ ...record, outcome: "sync-failed", note: `gitlink path unreadable (${err.code || err.message})` }); + continue; + } + /* ENOENT — missing; handled as no-repo below */ } if (linkStat && linkStat.isSymbolicLink()) { results.push({ ...record, outcome: "sync-failed", note: "gitlink path is a symbolic link — refusing to touch it" }); @@ -127,7 +134,13 @@ export default function sync(opts = {}) { // dry run) with a note instead of fetching. let pinPresent = git(["-C", absChild, "cat-file", "-e", `${sha}^{commit}`]).code === 0; if (!pinPresent && !dryRun) { - git(["-C", absChild, "fetch", "--quiet", "origin"]); + const fetch = git(["-C", absChild, "fetch", "--quiet", "origin"]); + if (fetch.code !== 0) { + // A failed fetch (auth/network) is a real error, not "pin genuinely + // absent" — report sync-failed with stderr so it's actionable. + results.push({ ...record, outcome: "sync-failed", note: `git fetch origin failed: ${fetch.stderr || `exit ${fetch.code}`}` }); + continue; + } pinPresent = git(["-C", absChild, "cat-file", "-e", `${sha}^{commit}`]).code === 0; if (!pinPresent) { results.push({ ...record, outcome: "pin-unavailable", note: `pinned ${sha.slice(0, 12)} not found at origin after fetch` }); diff --git a/tests/embedded-provisioning.test.mjs b/tests/embedded-provisioning.test.mjs index abc571e..18a65db 100644 --- a/tests/embedded-provisioning.test.mjs +++ b/tests/embedded-provisioning.test.mjs @@ -833,6 +833,24 @@ describe("api.embedded.sync (day-2 pin sync)", () => { expect(exitCode).toBe(1); expect(fs.lstatSync(path.join(fresh, "tests")).isSymbolicLink()).toBe(true); }); + + it("reports sync-failed (not pin-unavailable) when the fallback fetch itself fails", () => { + const { work, parentBare, childSha } = makeParent({ gitlinkPath: "tests" }); + const fresh = freshClone(parentBare); + api.embedded.restore({ cwd: fresh }); + const child = path.join(fresh, "tests"); + // Break the child's origin so the fallback fetch errors out. + git(["remote", "set-url", "origin", path.join(mkTmp(), "gone.git")], child); + // A pin absent locally forces the fetch path. + const ghostSha = advanceChild(work, "tests", "ghost", { push: false }); + bumpPin(fresh, "tests", ghostSha); + + const { results, exitCode } = api.embedded.sync({ cwd: fresh }); + expect(results[0].outcome).toBe("sync-failed"); + expect(results[0].note).toMatch(/fetch origin failed/); + expect(exitCode).toBe(1); + expect(git(["rev-parse", "HEAD"], child)).toBe(childSha); // unmoved + }); }); describe("filter-path normalization (review round 6)", () => { From e35b01ceab73e7f45f5dac2d3e0edaa9cde663df Mon Sep 17 00:00:00 2001 From: Shinrai Date: Sat, 18 Jul 2026 13:13:17 -0700 Subject: [PATCH 16/18] fix(embedded): surface git-branch failure in sync; -- guard in branch attach (review round 10) - sync: a non-zero 'git branch --show-current' is now sync-failed (with stderr) instead of read as detached HEAD (branch=null), which could mask a real repo/permission error. - branch.attach: pass '--' before the branch name in 'git branch --set-upstream-to', so a registry/manifest branch name starting with '-' is never parsed as an option (matches the '--' hardening clone/add already have). --- src/api/embedded/branch.mjs | 2 +- src/api/embedded/sync.mjs | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/api/embedded/branch.mjs b/src/api/embedded/branch.mjs index d599872..8e829c9 100644 --- a/src/api/embedded/branch.mjs +++ b/src/api/embedded/branch.mjs @@ -55,7 +55,7 @@ export function infer(childDir, sha) { export function attach(childDir, branch, sha) { const checkout = git(["-C", childDir, "checkout", "--quiet", "-B", branch, sha]); if (checkout.code !== 0) return false; - git(["-C", childDir, "branch", `--set-upstream-to=origin/${branch}`, branch]); + git(["-C", childDir, "branch", `--set-upstream-to=origin/${branch}`, "--", branch]); return true; } diff --git a/src/api/embedded/sync.mjs b/src/api/embedded/sync.mjs index 7a4e3e4..5042fd7 100644 --- a/src/api/embedded/sync.mjs +++ b/src/api/embedded/sync.mjs @@ -149,7 +149,16 @@ export default function sync(opts = {}) { } if (!pinPresent && dryRun) record.note = "pin not in the local object store — a real run would fetch origin first"; - const branch = git(["-C", absChild, "branch", "--show-current"]).stdout || null; + const branchRes = git(["-C", absChild, "branch", "--show-current"]); + if (branchRes.code !== 0) { + results.push({ + ...record, + outcome: "sync-failed", + note: `could not read current branch: ${branchRes.stderr || `git branch --show-current exited ${branchRes.code}`}` + }); + continue; + } + const branch = branchRes.stdout || null; const registered = self.embedded.registry.getBranch(childPath, root); if (branch && (!registered || branch !== registered)) { From 59e1f72e25ee828c581a873425540878cca668f9 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Sat, 18 Jul 2026 14:36:41 -0700 Subject: [PATCH 17/18] fix(embedded): anchor clone to repo root; split merge-base error from divergence (review round 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - restore: run `git clone` with cwd = the parent repo root so a RELATIVE registry/manifest url (e.g. "../sibling.git") resolves deterministically against the repo rather than the Node process CWD. The destination is absolute, so only a relative source url is affected. Adds a relative-url test (fails without the anchor: the clone resolves against the runner's cwd and the child comes back unresolved). - sync: `merge-base --is-ancestor` exit 1 (HEAD not an ancestor — your work) and 128 (corrupt repo / missing objects) were both treated as "ahead" and left alone with a zero exit, masking a real git error. Now only exit 1 is "ahead"; anything else surfaces as sync-failed, matching the rev-parse / status / fetch / branch failure handling already in sync. --- src/api/embedded/restore.mjs | 6 +++++- src/api/embedded/sync.mjs | 28 ++++++++++++++++++++++++---- tests/embedded-provisioning.test.mjs | 21 +++++++++++++++++++++ 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/api/embedded/restore.mjs b/src/api/embedded/restore.mjs index 5fd8c91..3017902 100644 --- a/src/api/embedded/restore.mjs +++ b/src/api/embedded/restore.mjs @@ -159,7 +159,11 @@ export default function restore(opts = {}) { const existedBefore = fs.existsSync(absChild); // `--` ends option parsing: a URL from config/manifest/--base that starts // with "-" must never be interpreted as a git option (e.g. --upload-pack). - const clone = git(["clone", "--quiet", "--", resolved.url, absChild]); + // cwd=root anchors a RELATIVE url (e.g. "../sibling.git") to the parent repo + // root, so a restore resolves the same regardless of where the caller ran + // from. Without it git resolves the url against the Node process CWD (the + // destination is absolute, so only the source url is affected). + const clone = git(["clone", "--quiet", "--", resolved.url, absChild], { cwd: root }); if (clone.code !== 0) { if (fs.existsSync(absChild)) removeClone(absChild, existedBefore); results.push({ ...record, outcome: "unresolved", note: `clone failed: ${clone.stderr || `exit ${clone.code}`}` }); diff --git a/src/api/embedded/sync.mjs b/src/api/embedded/sync.mjs index 5042fd7..39adca2 100644 --- a/src/api/embedded/sync.mjs +++ b/src/api/embedded/sync.mjs @@ -174,10 +174,30 @@ export default function sync(opts = {}) { if (branch) { // The child LIVES on this branch (registry says so) — move the branch to // the pin, fast-forward only: HEAD must be an ancestor of the pin. - // Commits beyond the pin are your work and stay untouched. With the pin - // object absent (dry run), ancestry is unknowable — keep the optimistic - // dry-run report. - const ancestor = pinPresent ? git(["-C", absChild, "merge-base", "--is-ancestor", "HEAD", sha]).code === 0 : true; + // Commits beyond the pin are your work and stay untouched. + // merge-base --is-ancestor exit codes: 0 = HEAD IS an ancestor of the pin + // (fast-forward), 1 = NOT an ancestor (real divergence — your work), and + // anything else (128) is a genuine git error (corrupt repo, missing + // objects). Only exit 1 means "ahead"; a 128 must surface as sync-failed, + // not be mislabeled as your work and silently left alone. With the pin + // object absent (dry run) ancestry is unknowable — stay optimistic like + // the rest of the dry-run path. + let ancestor; + if (pinPresent) { + const anc = git(["-C", absChild, "merge-base", "--is-ancestor", "HEAD", sha]); + if (anc.code !== 0 && anc.code !== 1) { + results.push({ + ...record, + branch, + outcome: "sync-failed", + note: `could not test ancestry: ${anc.stderr || `merge-base --is-ancestor exited ${anc.code}`}` + }); + continue; + } + ancestor = anc.code === 0; + } else { + ancestor = true; + } if (!ancestor) { results.push({ ...record, diff --git a/tests/embedded-provisioning.test.mjs b/tests/embedded-provisioning.test.mjs index 18a65db..8c0f253 100644 --- a/tests/embedded-provisioning.test.mjs +++ b/tests/embedded-provisioning.test.mjs @@ -183,6 +183,27 @@ describe("api.embedded.restore (convention)", () => { expect(again.exitCode).toBe(0); }); + it("resolves a RELATIVE registry url against the parent repo root, not the process cwd", () => { + // childBareName differs from the gitlink path so convention CANNOT resolve — + // the relative registry url is the only resolver, isolating the clone anchor. + const { parentBare, childBare, childSha } = makeParent({ gitlinkPath: "tests", childBareName: "secret-rel" }); + const fresh = freshClone(parentBare); + // Store the child URL as a path RELATIVE to the parent repo root. Anchoring + // the clone to root makes this resolve deterministically; without the anchor + // git resolves it against the Node process CWD (the test runner) and the + // clone fails → the child would come back unresolved. + const relUrl = path.relative(fresh, childBare); + expect(path.isAbsolute(relUrl)).toBe(false); + api.embedded.registry.setUrl("tests", relUrl, fresh); + + const { results, exitCode } = api.embedded.restore({ cwd: fresh }); + expect(exitCode).toBe(0); + const rec = results.find((r) => r.path === "tests"); + expect(rec.outcome).toBe("restored"); + expect(fs.existsSync(path.join(fresh, "tests", ".git"))).toBe(true); + expect(git(["rev-parse", "HEAD"], path.join(fresh, "tests"))).toBe(childSha); + }); + it("honors --skip for a partial restore (skipped child does not fail the run)", () => { const { parentBare } = makeParent({ gitlinkPath: "tests" }); const fresh = freshClone(parentBare); From 6c037fb24771944220cbf6ded067345e5ccce163 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Sat, 18 Jul 2026 15:48:22 -0700 Subject: [PATCH 18/18] fix(embedded): surface git stderr in detached-checkout failure notes (review round 12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restore and sync reported a failed `git checkout --detach ` with only the truncated sha, dropping git's stderr/exit code — leaving a sync-failed / pinned-mismatch undiagnosable. Both notes now append `checkout.stderr` (falling back to the exit code), matching the stderr-in-note pattern the clone / rev-parse / status / fetch failures already use. Message-only enrichment; the checkout-failure branch is defensive (the pin is cat-file-verified present immediately above), so it stays guarded rather than unit-triggered. --- src/api/embedded/restore.mjs | 6 +++++- src/api/embedded/sync.mjs | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/api/embedded/restore.mjs b/src/api/embedded/restore.mjs index 3017902..b50d85b 100644 --- a/src/api/embedded/restore.mjs +++ b/src/api/embedded/restore.mjs @@ -210,7 +210,11 @@ export default function restore(opts = {}) { const checkout = git(["-C", absChild, "checkout", "--quiet", "--detach", sha]); if (checkout.code !== 0) { removeClone(absChild, existedBefore); - results.push({ ...record, outcome: "pinned-mismatch", note: `could not check out ${sha.slice(0, 12)}; clone removed` }); + results.push({ + ...record, + outcome: "pinned-mismatch", + note: `could not check out ${sha.slice(0, 12)}: ${checkout.stderr || `git checkout exited ${checkout.code}`}; clone removed` + }); continue; } } diff --git a/src/api/embedded/sync.mjs b/src/api/embedded/sync.mjs index 39adca2..41053c2 100644 --- a/src/api/embedded/sync.mjs +++ b/src/api/embedded/sync.mjs @@ -226,7 +226,11 @@ export default function sync(opts = {}) { } const checkout = git(["-C", absChild, "checkout", "--quiet", "--detach", sha]); if (checkout.code !== 0) { - results.push({ ...record, outcome: "sync-failed", note: `could not check out ${sha.slice(0, 12)}` }); + results.push({ + ...record, + outcome: "sync-failed", + note: `could not check out ${sha.slice(0, 12)}: ${checkout.stderr || `git checkout exited ${checkout.code}`}` + }); continue; } results.push({ ...record, outcome: "synced" });