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/README.md b/README.md index 5521f5b..3cc7cdf 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,88 @@ 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 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) + +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` (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: 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. + +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 +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. + +## 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 7e8acb8..42739be 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 @@ -35,21 +35,11 @@ Avoiding `.gitmodules` is the obvious fix, but doing so loses the working-tree a - `committed` — updates already applied. - `aborted` — informational. -The hook script acts only on the `prepared` phase, where rejection is possible. It reads the proposed ref updates from stdin (one `old_sha new_sha ref` per line) and filters to lines where `ref` is `HEAD` with `old_sha != new_sha` (a HEAD move). +The hook script acts only on the `prepared` phase, where rejection is possible. It reads the proposed ref updates from stdin (one `old_sha new_sha ref` per line), filters to lines where `ref` is `HEAD` and `old_sha != new_sha` (an actual HEAD move), and walks every gitlink in the current tree checking for uncommitted changes via `git diff-index --quiet HEAD --` inside each child. If any child is dirty, the hook prints a message to stderr and exits non-zero, which aborts the parent operation. -**A plumbing fact that shapes the design:** a plain `git commit` ALSO emits a HEAD update line in the reference transaction (HEAD's reflog records the new commit), so "HEAD moved" alone cannot distinguish a commit from a checkout. An earlier revision of this document claimed commits were not caught; that was wrong, and the hook now reasons about what the move would actually do to each child instead of assuming the operation's type. Where the type matters (strict mode), append vs jump is classified by parentage: a move whose NEW commit lists the current (pre-move) HEAD among its parents is an append (commit, merge, cherry-pick step); everything else is a jump (checkout, switch, reset, bisect). The pre-move HEAD is resolved directly — the transaction line's old value reads as the null SHA on a checkout-to-SHA detach and must not be trusted for this. One known edge: switching to a branch whose tip is a direct child of the current HEAD is indistinguishable from a commit by parentage and classifies as an append. +**What it catches.** Every git command that ultimately moves HEAD goes through a reference transaction. That includes `git checkout `, `git switch `, `git reset` (any mode that moves HEAD), `git pull` (both fast-forward and rebase variants), `git merge`, `git rebase` (each step), `git bisect` (each step), `git cherry-pick`, and others. -**Guard modes** (`git config embedded.guard`, local over global; two-part settings keys in the `embedded.*` section are structurally reserved — registry entries are always three-part `embedded..url|branch`): - -- `precise` _(default)_ — block only when a DIRTY child's HEAD differs from the pin recorded in the NEW commit: exactly the condition under which `update-embedded-repos` would try to move a child carrying uncommitted changes. A clean child never blocks; a dirty child whose pin equals its HEAD never blocks (the sync no-ops). This lets a parent evolve — including plain commits and pin bumps — while unrelated children are mid-work. -- `strict` — the everything-synced policy for workspaces that want the parent to only ever snapshot a fully-committed state. Any dirty child blocks any HEAD move, and on APPENDS every child's pin in the new commit must equal that child's current HEAD — so a parent commit can never ship a stale pin (work done in a child but not recorded in the parent). Jumps only require all-clean: their pins are expected to differ, and the post-hook sync moves the (clean) children afterwards. -- `off` — no guarding. - -One-shot override for any mode: `git -c embedded.guard= `. "Dirty" is `git diff-index --quiet HEAD` semantics — modified/staged tracked files; untracked files never count. - -**What it catches.** Every git command that updates HEAD in a reference transaction: `git commit`, `git checkout `, `git switch `, `git reset`, `git pull`, `git merge`, `git rebase` (each step), `git bisect` (each step), `git cherry-pick`, and others — with per-mode rules as above. - -**What it does not catch.** Operations that don't move HEAD: `git checkout -- file` (file-level checkout), `git stash` itself (records stash refs, not HEAD), bare index edits. These don't trigger child-update behavior, so there is nothing to guard. +**What it does not catch.** Operations that don't move HEAD aren't guarded, by design: `git commit` (creates a new commit but doesn't update the gitlink without explicit staging), `git checkout -- file` (file-level checkout), `git stash` itself (records stash refs, not HEAD), and so on. These don't require child-update behavior. **Caveat about error messaging.** When the hook exits non-zero, git wraps its own message around the script's stderr output. The user sees a message like: @@ -79,13 +69,14 @@ 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. ### `pre-push` (pin publication check) @@ -103,15 +94,13 @@ This is git-embedded's analog of `git push --recurse-submodules=check`; stock gi One-shot override: `git -c embedded.pushRecurse= push …`. -- `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 | 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 --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 | @@ -125,21 +114,76 @@ One-shot override: `git -c embedded.pushRecurse= push …`. ## 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 | Manual or via the planned CLI | -| 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. + +`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 four optional sources + +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`. 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"`. + +### 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. + +### 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, 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 (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 - 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. 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/link.mjs b/src/api/cli/link.mjs index 3e4c9db..d535084 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,28 +14,73 @@ export const spec = { ] }; +/** + * 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 blocksClone(target) { + const { fs } = context; + let st; + try { + st = fs.lstatSync(target); + } catch (err) { + // 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, path } = context; - if (fs.existsSync(localPath)) { - self.report.error(`${localPath} already exists. Remove it or pick a different path before linking.`); + // 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); } - self.report.plain(`Cloning ${remoteUrl} into ${localPath}…`); - const clone = spawnSync("git", ["clone", remoteUrl, localPath], { stdio: "inherit" }); + 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 ${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); } - self.report.success(`Staged gitlink at ${localPath} (no .gitmodules entry written).`); + // Record the URL + branch into the parent's LOCAL config registry (never + // 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 ${rel} (no .gitmodules entry written).`); self.report.plain("Commit when ready: git commit -m 'embed '"); } 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..a0714c9 --- /dev/null +++ b/src/api/cli/restore.mjs @@ -0,0 +1,74 @@ +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. 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)"], + ["--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})${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}` : ""}`, + "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); + } + + // 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"}, ${unchanged} unchanged${skipped ? `, ${skipped} skipped` : ""}, ${failed} failed.` + ); + process.exit(exitCode); +} + +export default { spec, run }; 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..8e829c9 --- /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/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..711f567 --- /dev/null +++ b/src/api/embedded/manifest.mjs @@ -0,0 +1,71 @@ +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 || 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. + if (obj.version !== 1) { + throw new Error(`manifest ${abs} has unsupported version ${JSON.stringify(obj.version)} (expected 1)`); + } + 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) { + // 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 }; + 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..3e57727 --- /dev/null +++ b/src/api/embedded/record.mjs @@ -0,0 +1,46 @@ +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; + // 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 = []; + 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..c28cba9 --- /dev/null +++ b/src/api/embedded/registry.mjs @@ -0,0 +1,119 @@ +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()) { + // `--` so a value starting with "-" is never parsed as a git option. + 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..3c5e96a --- /dev/null +++ b/src/api/embedded/resolve.mjs @@ -0,0 +1,87 @@ +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 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 + * @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 `${trimmed.slice(0, idx)}/${basename(childPath)}.git`; + const colon = trimmed.lastIndexOf(":"); + if (colon < 0) return null; + return `${trimmed.slice(0, colon)}:${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). + // 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. + 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..b50d85b --- /dev/null +++ b/src/api/embedded/restore.mjs @@ -0,0 +1,231 @@ +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. + * + * 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. + * + * @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; + + // 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 = []; + + for (const { path: childPath, sha } of links) { + if (wantSet && !wantSet.has(childPath)) continue; + + const record = { path: childPath, sha, url: null, source: null, branch: null, note: null }; + + if (skipSet.has(childPath)) { + results.push({ ...record, outcome: "skipped" }); + continue; + } + + const absChild = path.resolve(root, childPath); + + // 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 (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" }); + 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.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` }); + 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; + } + + // 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; + } + + 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). + // 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}`}` }); + 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; + let fetchErr = null; + if (!present) { + 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: `${why}; 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)}: ${checkout.stderr || `git checkout exited ${checkout.code}`}; clone removed` + }); + continue; + } + } + + // 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" }); + } + + const exitCode = results.some((r) => r.outcome === "unresolved" || r.outcome === "pinned-mismatch") ? 1 : 0; + return { results, exitCode }; +} diff --git a/src/api/embedded/sync.mjs b/src/api/embedded/sync.mjs new file mode 100644 index 0000000..41053c2 --- /dev/null +++ b/src/api/embedded/sync.mjs @@ -0,0 +1,241 @@ +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: + * - 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 → + * `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 `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 + * @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); + + // 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 (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" }); + 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). + if (wantSet) results.push({ ...record, outcome: "no-repo", note: "not present on disk — run restore" }); + continue; + } + + 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) { + 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; + } + + // 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) { + 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` }); + continue; + } + } + if (!pinPresent && dryRun) record.note = "pin not in the local object store — a real run would fetch origin first"; + + 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)) { + 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. + // 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, + 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)}: ${checkout.stderr || `git checkout exited ${checkout.code}`}` + }); + 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 new file mode 100644 index 0000000..8c0f253 --- /dev/null +++ b/tests/embedded-provisioning.test.mjs @@ -0,0 +1,893 @@ +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; +} + +// 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 = (() => { + let dir = null; + try { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "git-embedded-symlink-probe-")); + fs.symlinkSync(dir, path.join(dir, "probe"), "dir"); + 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 }); + } +})(); + +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; +} + +/** + * 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; + +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("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); + 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); + }); + + 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)", () => { + 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\)/); + }); +}); + +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/); + }); +}); + +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.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"), "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); + // 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.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"), "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); + 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); + 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"), "dir"); + expect(() => api.cli.link.run("linked", childBare)).toThrow(/process\.exit\(2\)/); + 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"); + }); +}); + +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); + }); +}); + +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); + }); + + 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); + }); + + 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); + }); + + 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)", () => { + 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"); + }); +});