feat: restore/record/export provisioning commands (the "planned CLI") - #2
Merged
Conversation
Provision embedded children on a fresh clone without ever committing a
child URL. New `embedded` engine namespace resolves each gitlink's clone
URL strictest-source-first — local-config registry, manifest (--from),
--base, then origin convention — and SHA-verifies every clone so a wrong
convention guess fails closed (the clone is removed, never a pre-existing
dir) instead of planting the wrong code.
- embedded/gitlinks.mjs enumerate mode-160000 gitlinks from HEAD
- embedded/registry.mjs embedded.<path>.url/.branch in the parent's
LOCAL config (never committed)
- embedded/manifest.mjs JSON transfer-file read/build/serialize
- embedded/resolve.mjs the 4-layer resolution precedence
- embedded/restore.mjs clone + SHA-verify + detach-checkout engine
- embedded/record.mjs record present children into the registry
CLI leaves:
- restore [paths...] [--from] [--base] [--skip] [--dry-run]
outcomes: restored|already-present|unresolved|pinned-mismatch|skipped;
exits non-zero if any child is unresolved or pinned-mismatch
- record [paths...]
- export [-o <file>] [--scan] (appends -o path to .git/info/exclude)
A fresh clone of a parent materializes each gitlink as an empty directory, so `link` refusing any existing path made it unusable to fill one in. It now clones into a missing OR empty target and refuses only a non-empty directory. After staging, it records the child's URL + branch into the parent's local registry (same as `record`), so a later restore/export already knows the child.
Covers restore-by-convention (clone + pin checkout + registry write + day-2 already-present), obscured-child unresolved -> link -> present, pinned-mismatch (clone removed, non-zero exit), record/export round-trip through a manifest on a second machine, --skip partial restore, and the link empty-dir fix. Real git repos in tmp with hermetic config. Suite: 22 passing (15 pre-existing + 7 new).
README gains a "Restoring embedded children (machine-B bootstrap)" section — clone parent -> `git embedded restore` -> done — plus the resolution precedence, SHA-verification, obscured-child handling, and the export/record manifest workflow with a loud "never commit the manifest" warning. design.md's coverage matrix now marks initial child clone as covered by `git embedded restore`, and a new "Provisioning" section documents the security rationale (convention is a resolution default, never a disclosure; SHA-verification; obscured children resolvable only via the config/manifest layers). Full suite: 22 passing (15 pre-existing + 7 new).
There was a problem hiding this comment.
Pull request overview
Adds the “planned CLI” provisioning workflow to @cldmv/git-embedded, allowing a fresh clone of a parent repo to reconstruct embedded children (without committing child URLs) via local per-clone registry, optional manifest transfer, explicit base URL, and a convention-derived fallback.
Changes:
- Introduces restore/resolve engines to clone missing gitlink children, SHA-verify pins, detach-checkout pinned commits, and persist resolved URLs to local config.
- Adds local-config registry + manifest (read/build/serialize) + record engine, and wires new CLI commands:
restore,record,export. - Updates
linkto accept empty gitlink directories and record URL/branch into the local registry; updates docs and adds provisioning tests.
Reviewed changes
Copilot reviewed 13 out of 14 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/embedded-provisioning.test.mjs | New Vitest coverage for restore/link/record/export provisioning flows. |
| src/api/embedded/restore.mjs | New restore engine: resolve URL, clone, SHA-verify, detach-checkout, and record URL. |
| src/api/embedded/resolve.mjs | New layered URL resolver (local-config → manifest → base → convention). |
| src/api/embedded/registry.mjs | New per-clone local-config registry read/write/scan helpers. |
| src/api/embedded/record.mjs | New engine to record present child origins/branches into the registry. |
| src/api/embedded/manifest.mjs | New manifest transfer format helpers (read/build/serialize). |
| src/api/embedded/gitlinks.mjs | New gitlink enumerator (git ls-tree -r HEAD mode 160000). |
| src/api/cli/restore.mjs | New git-embedded restore command wrapper/reporting. |
| src/api/cli/record.mjs | New git-embedded record command wrapper/reporting. |
| src/api/cli/export.mjs | New git-embedded export command wrapper + .git/info/exclude courtesy. |
| src/api/cli/link.mjs | link accepts empty gitlink dirs and now records into the local registry. |
| README.md | Documents restore/record/export behavior, resolution layers, and security model. |
| docs/design.md | Updates coverage matrix + adds provisioning design section and command reference. |
| .gitignore | Ignores tmp/. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Address the three Copilot review findings on #2: - restore: refuse any pre-existing target that is not an empty directory (a file or a non-empty dir is user data — never clone into it, never remove it; previously a failed clone could wipe pre-existing contents via removeClone) - link: isNonEmpty treats a file (ENOTDIR) or unreadable dir as blocking instead of acceptable; only ENOENT passes - manifest.read: gate version === 1 with a clear error instead of accepting unknown formats silently Adds 4 guard tests (existing tests untouched); suite 26 passing.
Address the second Copilot review round on #2: - restore/link: lstat-based target checks — a symlink at a gitlink path (even to an empty dir) is refused instead of cloned through (writing outside the repo); broken symlinks are caught too (existsSync missed them) - link: guard folded into blocksClone(target) called unconditionally, so a dangling link no longer slips past the existsSync pre-check - conventionUrl: scp-style origins with the repo at the path root (git@host:parent.git) now derive the sibling after the last ':' — matching the documented scp support Adds 4 tests (scp-root dry-run, symlink, broken symlink, link symlink refusal); existing tests untouched; suite 30 passing.
Windows can't create symlinks without Developer Mode/elevation, so the three cases that CREATE links to prove the guards skip where creation is denied (probe at suite setup); creations get an explicit 'dir' type for Windows correctness when they do run. The guards themselves are lstat-based, need no symlink rights, and stay exercised on POSIX CI. Suite 30 passing.
Review round 3: the summary line counted failures inside 'unchanged' (results.length - restored) — each outcome now lands in exactly one bucket (restored / unchanged=already-present / skipped when present / failed), verified live: '0 restored, 0 unchanged, 1 skipped, 0 failed.' design.md's provisioning section said 'three optional layers' while implementing four sources — now lists local config → manifest → explicit --base → convention, matching README and the resolver.
- manifest.read rejects an array children value (typeof [] === 'object' slipped through) - manifest.build uses a null-prototype children map so a '__proto__' child path is a plain key, never a prototype mutation - resolve's manifest layer reads own properties only (Object.hasOwn — also correct for null-proto maps) - README: link's refusal wording now matches the implementation (non-empty dir, file, symlink, unreadable — not 'only a non-empty directory') - test symlink probe cleans its temp dir in finally (no leak when the probe fails, e.g. Windows without Developer Mode) Two tests added; suite 32 passing.
…to the branch tree so push-event CI fires)
…iew round 5)
- restore/link/registry: every git call that receives a resolved URL or user path now passes `--` first, so a value starting with '-' (e.g. --upload-pack=...) is a repo argument, never an option — injection test proves the payload is not executed
- link: normalizes the target to the repo-root-relative slash-normalized gitlink path before recording ('./tests' and 'tests/' record as 'tests', matching restore/gitlinks/export), and refuses a target outside the worktree with exit 2
Three tests added; suite 35 passing.
Shinrai
force-pushed
the
feat/provisioning-cli
branch
from
July 18, 2026 04:27
60819f5 to
9496c09
Compare
sync ran git commands with -C on a gitlink path checked only via existsSync, so a symlinked child pointing outside the parent worktree would be fetched/checked-out out there — the same 'cd out of the worktree' risk restore and link already refuse. sync now lstats the path and reports a symlink as sync-failed (non-zero exit) before touching it, even on an unfiltered run. Test added.
…iew round 9) - restore/sync: a non-ENOENT lstat error (EACCES/ENOTDIR on an existing path) is now refused (restore -> unresolved, sync -> sync-failed) instead of assumed 'missing' — which could otherwise clone into, and removeClone against, a pre-existing path we can't stat. - restore: a failed verification 'git fetch' is surfaced in the pinned-mismatch note (auth/network failure is not the same as a wrong-repo convention guess). - sync: a failed fallback 'git fetch' is reported as sync-failed with stderr instead of pin-unavailable. Test added (broken child origin -> sync-failed).
… attach (review round 10) - sync: a non-zero 'git branch --show-current' is now sync-failed (with stderr) instead of read as detached HEAD (branch=null), which could mask a real repo/permission error. - branch.attach: pass '--' before the branch name in 'git branch --set-upstream-to', so a registry/manifest branch name starting with '-' is never parsed as an option (matches the '--' hardening clone/add already have).
… divergence (review round 11) - restore: run `git clone` with cwd = the parent repo root so a RELATIVE registry/manifest url (e.g. "../sibling.git") resolves deterministically against the repo rather than the Node process CWD. The destination is absolute, so only a relative source url is affected. Adds a relative-url test (fails without the anchor: the clone resolves against the runner's cwd and the child comes back unresolved). - sync: `merge-base --is-ancestor` exit 1 (HEAD not an ancestor — your work) and 128 (corrupt repo / missing objects) were both treated as "ahead" and left alone with a zero exit, masking a real git error. Now only exit 1 is "ahead"; anything else surfaces as sync-failed, matching the rev-parse / status / fetch / branch failure handling already in sync.
…(review round 12) restore and sync reported a failed `git checkout --detach <pin>` with only the truncated sha, dropping git's stderr/exit code — leaving a sync-failed / pinned-mismatch undiagnosable. Both notes now append `checkout.stderr` (falling back to the exit code), matching the stderr-in-note pattern the clone / rev-parse / status / fetch failures already use. Message-only enrichment; the checkout-failure branch is defensive (the pin is cat-file-verified present immediately above), so it stays guarded rather than unit-triggered.
Brings the released 1.1.0 line into the branch: the guard-modes + pre-push hook work (#4), the version bump, and the feature-pr workflow. Only docs/design.md conflicted. Resolved by keeping this branch's provisioning-aware wording — the `git reset --hard` gap now points at `git embedded sync`, and the "Initial child clone" row points at `git embedded restore` — and splicing in next's two genuinely-new pieces: the `### pre-push` design subsection and the guard-modes-aware `git commit` row of the coverage matrix. Full suite green (78 tests).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🚀 What's Changed
💥 Breaking Changes
No breaking changes
✨ Features
🐛 Bug Fixes
📦 Dependencies
No dependency updates
🔧 Other Changes
👥 Contributors