@cldmv/git-embedded 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -43,6 +43,17 @@ git embedded install-hooks # install hooks into this repo's .git/hooks
43
43
  git embedded uninstall-hooks # remove hooks installed by this CLI
44
44
  ```
45
45
 
46
+ ### Guard behavior (config knobs)
47
+
48
+ The installed hooks read two settings (`git config`, local overrides global; one-shot override with `git -c <key>=<value> <command>`):
49
+
50
+ | Key | Values | Default | What it controls |
51
+ | ---------------------- | ----------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
52
+ | `embedded.guard` | `precise` · `strict` · `off` | `precise` | When HEAD moves are blocked. `precise` blocks only a move that would re-pin a child with uncommitted changes. `strict` is the everything-synced policy: any dirty child blocks any move, and a parent commit is refused while any child's pin is stale (child HEAD not recorded) — for workspaces where the parent must always snapshot a fully-committed, fully-recorded state. |
53
+ | `embedded.pushRecurse` | `check` · `on-demand` · `off` | `check` | Whether a parent push verifies that newly-pinned child commits are reachable from each child's origin. `check` rejects with a "push the child first" message; `on-demand` tries pushing the child's current branch first. Prevents publishing a parent whose pins dangle for every other machine. |
54
+
55
+ Two-part keys like these can never collide with the per-child registry entries (`embedded.<path>.url` / `.branch`), which are always three-part.
56
+
46
57
  `install-hooks` adapts to whatever's already in place:
47
58
 
48
59
  - **Nothing configured** — offers to install a small dispatcher script at `~/.config/git/hooks/_dispatch`, link every standard hook name to it, and set `git config --global core.hooksPath` to that directory. Then drops this package's hook scripts into the repo's `.git/hooks/`. The dispatcher chains to per-repo hooks, so every other repo on the machine keeps working as before.
@@ -86,6 +97,88 @@ git config advice.addEmbeddedRepo false
86
97
 
87
98
  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.
88
99
 
100
+ `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).
101
+
102
+ ## Restoring embedded children (machine-B bootstrap)
103
+
104
+ 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.
105
+
106
+ ```bash
107
+ git clone <parent-url> myproject
108
+ cd myproject
109
+ git embedded restore # clone every embedded child and check out its pinned SHA
110
+ ```
111
+
112
+ `restore` resolves each child's clone URL from up to four **optional** sources, strictest first, stopping at the first that yields a URL:
113
+
114
+ 1. **Local config registry** — `embedded.<path>.url` (and `embedded.<path>.branch`, see below) in _this clone's_ `.git/config`. Per-clone, never committed. Written automatically after a successful restore, and by `record` / `link`.
115
+ 2. **Manifest file** (`--from <file>`) — a JSON transfer file carried out-of-band (never committed). See `export` below.
116
+ 3. **`--base <url-base>`** — derives `<url-base>/<basename>.git` for each child.
117
+ 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 `<basename>.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.
118
+
119
+ 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.
120
+
121
+ 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.
122
+
123
+ ### Branch-aware checkout
124
+
125
+ 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.<path>.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/<branch>` 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.
126
+
127
+ **Partial restore is the normal case.** A public contributor without access to a private child simply skips it:
128
+
129
+ ```bash
130
+ git embedded restore --skip tests # comma-separate several: --skip tests,vendor/foo
131
+ ```
132
+
133
+ ### Obscured children
134
+
135
+ 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:
136
+
137
+ ```bash
138
+ git embedded link tests git@example.com:org/private-tests.git
139
+ # ...or, if the child is already present on disk:
140
+ git embedded record
141
+ ```
142
+
143
+ ### Sharing URLs between machines: `export` / `record`
144
+
145
+ `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:
146
+
147
+ ```bash
148
+ git embedded export --scan -o children.json # record present children, then write the manifest
149
+ ```
150
+
151
+ On the other machine:
152
+
153
+ ```bash
154
+ git clone <parent-url> myproject && cd myproject
155
+ git embedded restore --from children.json
156
+ ```
157
+
158
+ > **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.
159
+
160
+ ## Day-2: syncing pins
161
+
162
+ 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:
163
+
164
+ ```bash
165
+ git pull
166
+ git embedded sync
167
+ ```
168
+
169
+ Per child, sync is deliberately conservative — a clean child follows the pin, anything that looks like your work is reported and left alone:
170
+
171
+ - **already at the pin** — nothing to do (`in-sync`).
172
+ - **uncommitted changes** — left alone (`dirty`).
173
+ - **on the registered branch** (`embedded.<path>.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).
174
+ - **on any other branch** — left alone (`unregistered-branch`).
175
+ - **detached**, clean — snapped to the pin, staying detached (`synced`).
176
+ - **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.
177
+
178
+ 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`.
179
+
180
+ 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.
181
+
89
182
  ## Manual install (no CLI)
90
183
 
91
184
  If you'd rather wire things up by hand:
File without changes
package/docs/design.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Design: hooks for embedded git repositories
2
2
 
3
- 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.
3
+ 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.
4
4
 
5
5
  ## Background: gitlinks, submodules, and the registration gap
6
6
 
@@ -19,7 +19,7 @@ The hooks in this package close the registration gap without requiring a registr
19
19
 
20
20
  ## Why this matters: the URL is the leak
21
21
 
22
- For most submodule use cases, the URL in `.gitmodules` is uncontroversial — the parent is open and the child is open, the URL is just a convenience for `clone --recurse-submodules`. For a parent that wants to hide the *existence* of a private child repo, the `.gitmodules` URL is the leak. Anyone who can read the public parent can read `.gitmodules`, see the URL of the private child, and at minimum learn that a private resource exists at that location.
22
+ For most submodule use cases, the URL in `.gitmodules` is uncontroversial — the parent is open and the child is open, the URL is just a convenience for `clone --recurse-submodules`. For a parent that wants to hide the _existence_ of a private child repo, the `.gitmodules` URL is the leak. Anyone who can read the public parent can read `.gitmodules`, see the URL of the private child, and at minimum learn that a private resource exists at that location.
23
23
 
24
24
  Avoiding `.gitmodules` is the obvious fix, but doing so loses the working-tree automation. This package restores the automation while keeping the parent free of URL data.
25
25
 
@@ -69,50 +69,121 @@ For each gitlink path, the script:
69
69
  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).
70
70
  5. Runs `git checkout --detach <sha>` inside the child.
71
71
 
72
- 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.
72
+ 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)).
73
73
 
74
74
  **What it catches.** Together, the three hook names cover essentially every checkout-flavored parent operation. See the coverage matrix below.
75
75
 
76
76
  **What it does not catch.** Two notable gaps:
77
77
 
78
- - `git reset --hard <commit>` 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.
78
+ - `git reset --hard <commit>` 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.
79
79
  - `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.
80
80
 
81
+ ### `pre-push` (pin publication check)
82
+
83
+ **Purpose.** Refuse to push parent commits whose gitlink pins reference child commits that are not reachable from the child's own origin. Without this, a parent that pins a committed-but-unpushed child publishes a dangling pointer: every other machine's `git embedded restore` fails on that child with `pinned-mismatch`, because the child's origin has never seen the commit. The dirty-state guard cannot catch this — a committed-but-unpushed child is clean.
84
+
85
+ This is git-embedded's analog of `git push --recurse-submodules=check`; stock git cannot provide it here because that machinery locates children via `.gitmodules` registration, which anonymous gitlinks deliberately omit — the same registration gap the other hooks close for checkout.
86
+
87
+ **Mechanism.** For each pushed ref, the hook collects the gitlink pins the remote is about to learn: the pins _changed_ by each commit new to the remote (`git diff-tree`, cheap), plus — only when the remote ref is being _created_ — every gitlink in the tip's tree. Each unique `(path, pin)` is verified inside the child working copy: reachable from some `refs/remotes/origin/*` tip, with one `git fetch origin` refresh on a miss so stale tracking refs don't produce false rejections. A pin change for a child that is not present in the working tree is rejected (it cannot be verified). Because only _newly-introduced_ pins are checked on existing-ref updates, a clone that never restored its children can still push commits that touch no pin.
88
+
89
+ **Modes** (`git config embedded.pushRecurse`, local over global):
90
+
91
+ - `check` _(default)_ — reject the push with a "push the child first" message.
92
+ - `on-demand` — first try to publish the pin by pushing the child's CURRENT branch (only when that branch contains the pin and the child is not detached), then fall back to `check`'s rejection. Opt-in because implicitly pushing a child branch as a side effect of a parent push is surprising.
93
+ - `off` — no verification.
94
+
95
+ One-shot override: `git -c embedded.pushRecurse=<mode> push …`.
96
+
81
97
  ## Coverage matrix
82
98
 
83
- | Operation | `reference-transaction` (guard) | `update-embedded-repos` (update) |
84
- |---|---|---|
85
- | `git checkout <ref>` | Refuses if any child is dirty | Updates children to new pins |
86
- | `git switch <branch>` | Refuses if any child is dirty | Updates children to new pins |
87
- | `git reset --hard <commit>` | Refuses if any child is dirty | **Gap** — does not fire `post-*` hooks |
88
- | `git reset --soft/--mixed <commit>` | Refuses if any child is dirty (HEAD moves) | Does not fire `post-*` hooks (HEAD-only change) |
89
- | `git pull` (fast-forward) | Refuses if any child is dirty | Updates children |
90
- | `git pull --rebase` | Refuses at each rebase step | Updates children after rebase completes |
91
- | `git merge <commit>` | Refuses if any child is dirty | Updates children via `post-merge` |
92
- | `git rebase` | Refuses at each step | Updates children via `post-rewrite` |
93
- | `git bisect <good/bad/run>` | Refuses if any child is dirty | Updates children at each bisect step |
94
- | `git cherry-pick` | Refuses if any child is dirty | Updates children via `post-checkout` |
95
- | `git stash pop` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) |
96
- | `git commit` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) |
97
- | `git checkout -- file` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) |
99
+ | Operation | `reference-transaction` (guard) | `update-embedded-repos` (update) |
100
+ | ----------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------- |
101
+ | `git checkout <ref>` | Refuses if any child is dirty | Updates children to new pins |
102
+ | `git switch <branch>` | Refuses if any child is dirty | Updates children to new pins |
103
+ | `git reset --hard <commit>` | Refuses if any child is dirty | **Gap** — run `git embedded sync` after |
104
+ | `git reset --soft/--mixed <commit>` | Refuses if any child is dirty (HEAD moves) | Does not fire `post-*` hooks (HEAD-only change) |
105
+ | `git pull` (fast-forward) | Refuses if any child is dirty | Updates children |
106
+ | `git pull --rebase` | Refuses at each rebase step | Updates children after rebase completes |
107
+ | `git merge <commit>` | Refuses if any child is dirty | Updates children via `post-merge` |
108
+ | `git rebase` | Refuses at each step | Updates children via `post-rewrite` |
109
+ | `git bisect <good/bad/run>` | Refuses if any child is dirty | Updates children at each bisect step |
110
+ | `git cherry-pick` | Refuses if any child is dirty | Updates children via `post-checkout` |
111
+ | `git stash pop` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) |
112
+ | `git commit` | Refuses (precise: a dirty child it would re-pin; strict: any dirty child or stale pin) | Not updated (records current pins; no `post-*` hook) |
113
+ | `git checkout -- file` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) |
98
114
 
99
115
  ## Comparison to standard submodules
100
116
 
101
- | Property | Standard submodule | Anonymous gitlink + these hooks |
102
- |---|---|---|
103
- | Child URL in parent | Yes, in `.gitmodules` | No |
104
- | Tree-level pin | Gitlink | Gitlink |
105
- | Public viewer sees | URL, path, current SHA | Just the SHA (no link to follow) |
106
- | `git submodule update` | Works | Not used (registry-bound; hooks replace it) |
107
- | `submodule.recurse=true` | Works | Not used (registry-bound; hooks replace it) |
108
- | `git status` divergence | Yes | Yes |
109
- | `git add path` infers SHA | Yes | Yes |
110
- | `--recurse-submodules` clone | Pulls child | No-op (no registry) |
111
- | Initial child clone | Automatic via registry | Manual or via the planned CLI |
112
- | Dirty-child guard | Default refuses on update | Hook refuses on the HEAD move itself |
117
+ | Property | Standard submodule | Anonymous gitlink + these hooks |
118
+ | ---------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------- |
119
+ | Child URL in parent | Yes, in `.gitmodules` | No |
120
+ | Tree-level pin | Gitlink | Gitlink |
121
+ | Public viewer sees | URL, path, current SHA | Just the SHA (no link to follow) |
122
+ | `git submodule update` | Works | Not used (registry-bound; hooks replace it) |
123
+ | `submodule.recurse=true` | Works | Not used (registry-bound; hooks replace it) |
124
+ | `git status` divergence | Yes | Yes |
125
+ | `git add path` infers SHA | Yes | Yes |
126
+ | `--recurse-submodules` clone | Pulls child | No-op (no registry) |
127
+ | Initial child clone | Automatic via registry | `git embedded restore` (SHA-verified; see [Provisioning](#provisioning-restoring-embedded-children)) |
128
+ | Dirty-child guard | Default refuses on update | Hook refuses on the HEAD move itself |
113
129
 
114
130
  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.
115
131
 
132
+ ## Provisioning: restoring embedded children
133
+
134
+ 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.
135
+
136
+ `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.
137
+
138
+ ### URL knowledge lives in four optional sources
139
+
140
+ URL knowledge is never in the committed tree. It can only come from one of four optional sources, tried strictest-first at resolve time:
141
+
142
+ 1. **Local config registry** — `embedded.<path>.url` / `embedded.<path>.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.
143
+ 2. **Manifest** — a JSON transfer file (`{ "version": 1, "children": { "<path>": { "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.
144
+ 3. **Explicit base** — `--base <url-base>` derives `<url-base>/<basename>.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.
145
+ 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(<path>) + ".git"`.
146
+
147
+ ### Why convention discloses nothing
148
+
149
+ 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.
150
+
151
+ ### SHA verification makes wrong guesses fail closed
152
+
153
+ After every clone, the parent's pinned SHA must exist in the cloned child (`git cat-file -e <sha>^{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.
154
+
155
+ 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.
156
+
157
+ ### Branch-aware checkout
158
+
159
+ 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.<path>.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 <branch> <sha>`), upstream tracking is set to `origin/<branch>` 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.
160
+
161
+ One implementation detail is load-bearing: containing branches are listed with **full refnames** (`refs/remotes/origin/<name>`). 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).
162
+
163
+ ### Day-2 pin sync
164
+
165
+ 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:
166
+
167
+ | Child state | Disposition |
168
+ | -------------------------------------------------- | ----------------------------------------------------------------------------------------- |
169
+ | HEAD already at the pin | `in-sync` — nothing to do |
170
+ | Uncommitted changes | `dirty` — left alone (your work) |
171
+ | Pin absent after one `git fetch origin` | `pin-unavailable` — reported, non-zero exit |
172
+ | On the **registered** branch, HEAD ancestor of pin | `synced` — branch moved to the pin (`checkout -B`, fast-forward only), upstream refreshed |
173
+ | On the registered branch, commits beyond the pin | `ahead` — left alone (your work) |
174
+ | On any **unregistered** branch | `unregistered-branch` — left alone (reported) |
175
+ | Detached, clean | `synced` — detached to the pin |
176
+
177
+ 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.
178
+
179
+ ### The commands
180
+
181
+ - `restore [paths…] [--from <manifest>] [--base <url-base>] [--skip <paths>] [--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`.
182
+ - `sync [paths…] [--skip <paths>] [--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`.
183
+ - `record [paths…]` — write each present child's `remote.origin.url` and current branch into the registry.
184
+ - `export [-o <file>] [--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.
185
+ - `link <path> <url>` — clone a child into a missing or empty gitlink directory, stage the gitlink, and record its URL and branch.
186
+
116
187
  ## Implementation notes
117
188
 
118
189
  - 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.
package/hooks/pre-push ADDED
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env bash
2
+ # pre-push
3
+ #
4
+ # Refuses to push parent commits whose gitlink pins reference child commits
5
+ # that are NOT reachable from the child's own origin. Without this, a parent
6
+ # that pins a committed-but-unpushed child publishes a dangling pointer:
7
+ # every other machine's `git embedded restore` fails on that child with
8
+ # pinned-mismatch, because the child's origin has never seen the commit.
9
+ #
10
+ # This is git-embedded's analog of `git push --recurse-submodules=check` —
11
+ # stock git can't provide it here because that machinery locates children via
12
+ # .gitmodules registration, which anonymous gitlinks deliberately omit.
13
+ #
14
+ # Mode (git config embedded.pushRecurse — local overrides global; default check):
15
+ # check Verify each pin new to the remote is reachable from some
16
+ # refs/remotes/origin/* tip in the child (refreshing with one
17
+ # `git fetch origin` on a miss). Unreachable → reject the push
18
+ # with a "push the child first" message.
19
+ # on-demand Like check, but first try to publish the pin by pushing the
20
+ # child's CURRENT branch (only when that branch contains the pin
21
+ # and the child is not detached). Falls back to check's rejection
22
+ # when it can't.
23
+ # off No verification.
24
+ #
25
+ # One-shot override: git -c embedded.pushRecurse=<mode> push …
26
+ #
27
+ # Args: $1 = remote name, $2 = remote URL.
28
+ # Stdin: <local_ref> SP <local_sha> SP <remote_ref> SP <remote_sha> per ref.
29
+
30
+ push_mode=$(git config --get embedded.pushRecurse 2>/dev/null)
31
+ case "$push_mode" in
32
+ off) exit 0 ;;
33
+ check | on-demand) ;;
34
+ *) push_mode="check" ;;
35
+ esac
36
+
37
+ zeros="0000000000000000000000000000000000000000"
38
+
39
+ # Is $2 (a commit SHA) reachable from any origin remote-tracking tip inside
40
+ # the child repo at $1?
41
+ reachable_from_origin() (
42
+ cd "$1" || return 1
43
+ for tip in $(git for-each-ref --format='%(objectname)' refs/remotes/origin 2>/dev/null); do
44
+ if git merge-base --is-ancestor "$2" "$tip" 2>/dev/null; then
45
+ return 0
46
+ fi
47
+ done
48
+ return 1
49
+ )
50
+
51
+ # Verify one (path, pin). Prints the rejection message and returns 1 when the
52
+ # pin cannot be confirmed published.
53
+ verify_pin() {
54
+ local path="$1" pin="$2"
55
+
56
+ if ! [ -d "$path/.git" ] && ! [ -f "$path/.git" ]; then
57
+ echo "git-embedded: ✗ cannot verify $path pin ${pin:0:12} — the child repo is not present here" >&2
58
+ echo " restore it first (git embedded restore '$path'), or bypass once with -c embedded.pushRecurse=off" >&2
59
+ return 1
60
+ fi
61
+
62
+ # Fast path: current remote-tracking knowledge.
63
+ reachable_from_origin "$path" "$pin" && return 0
64
+
65
+ # Refresh once — local refs/remotes may simply be stale.
66
+ (cd "$path" && git fetch --quiet origin 2>/dev/null)
67
+ reachable_from_origin "$path" "$pin" && return 0
68
+
69
+ if [ "$push_mode" = "on-demand" ]; then
70
+ # Publish the child's current branch iff it is a real branch that
71
+ # contains the pin. Never invent a ref for a detached child.
72
+ local branch
73
+ branch=$(cd "$path" && git symbolic-ref --quiet --short HEAD 2>/dev/null)
74
+ if [ -n "$branch" ] && (cd "$path" && git merge-base --is-ancestor "$pin" "$branch" 2>/dev/null); then
75
+ echo "git-embedded: pushing $path ($branch) to publish pin ${pin:0:12}…" >&2
76
+ # `>&2 2>&1` (not `2>&1 >&2`) sends BOTH streams to stderr — order
77
+ # matters. A successful push means the pin (an ancestor of $branch,
78
+ # verified above) is now on origin, so succeed directly rather than
79
+ # re-checking refs/remotes/origin, which a plain push may not refresh.
80
+ if (cd "$path" && git push --quiet origin "$branch" >&2 2>&1); then
81
+ return 0
82
+ fi
83
+ fi
84
+ fi
85
+
86
+ echo "git-embedded: ✗ $path pin ${pin:0:12} is not on that child's origin" >&2
87
+ echo " push the child first (git -C '$path' push), then retry this push" >&2
88
+ return 1
89
+ }
90
+
91
+ block=0
92
+ checked=""
93
+
94
+ # For each pushed ref, examine every commit that is new to the remote and
95
+ # collect its gitlink pins: the pins CHANGED by each new commit (diff-tree,
96
+ # cheap) plus the full gitlink set of the tip (ls-tree) — a pin unchanged
97
+ # throughout the range is by definition still in the tip's tree, so the union
98
+ # covers every pin the remote is about to learn.
99
+ while read local_ref local_sha remote_ref remote_sha; do
100
+ [ "$local_sha" = "$zeros" ] && continue # deletion — nothing to verify
101
+
102
+ if [ "$remote_sha" = "$zeros" ]; then
103
+ # New remote ref: bound the walk by everything already on any remote.
104
+ range_args=("$local_sha" --not --remotes)
105
+ else
106
+ range_args=("$remote_sha..$local_sha")
107
+ fi
108
+
109
+ pins_to_check=""
110
+
111
+ # Changed gitlink pins across the new commits.
112
+ for c in $(git rev-list "${range_args[@]}" 2>/dev/null); do
113
+ while IFS=$'\t' read -r meta path; do
114
+ set -- $meta
115
+ # diff-tree --raw: :<old_mode> <new_mode> <old_sha> <new_sha> <status>
116
+ [ "$2" = "160000" ] || continue
117
+ new_pin="$4"
118
+ [ "$new_pin" = "$zeros" ] && continue # gitlink removed
119
+ # pin-FIRST so a $path with spaces survives the read-back below
120
+ # (pin is a fixed-width sha; path is the remainder).
121
+ pins_to_check="$pins_to_check$new_pin $path"$'\n'
122
+ # --no-renames: without it, a rename/copy raw line carries TWO tab-
123
+ # separated paths (old<TAB>new), which would land a tabbed value in $path.
124
+ done < <(git diff-tree -r --no-commit-id --no-renames --raw "$c" 2>/dev/null)
125
+ done
126
+
127
+ # Verify every gitlink in the tip when the remote can't derive the tree from
128
+ # the delta: a NEW ref (learning the whole tree), OR a NON-fast-forward update
129
+ # (force-push / rewritten history) — there a pin can be new to the remote yet
130
+ # unchanged within remote_sha..local_sha, so the diff pass alone would miss it.
131
+ # For an ordinary fast-forward the diff pass suffices (an unchanged pin was
132
+ # already in the remote's tree and verified when first pushed), so a clone that
133
+ # never restored its children can still push commits that touch no pin.
134
+ if [ "$remote_sha" = "$zeros" ] || ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then
135
+ while IFS=$'\t' read -r meta path; do
136
+ set -- $meta
137
+ [ "$2" = "commit" ] || continue
138
+ pins_to_check="$pins_to_check$3 $path"$'\n'
139
+ done < <(git ls-tree -r "$local_sha" 2>/dev/null)
140
+ fi
141
+
142
+ # Verify each unique (path, pin) once. Read each line WHOLE (IFS= disables
143
+ # field-splitting), then split the pin off at the FIRST space: a plain
144
+ # `read -r pin path` strips a LEADING space from $path (default-IFS trims the
145
+ # last field's leading whitespace), so a gitlink dir whose name begins with a
146
+ # space would be looked up at the wrong location — and a trailing space would
147
+ # be dropped too. The pin is a fixed-width sha with no spaces, so the first
148
+ # space is always the pin/path boundary; everything after it (leading or
149
+ # trailing space included) is the path verbatim.
150
+ while IFS= read -r line; do
151
+ case "$line" in *" "*) ;; *) continue ;; esac # skip blank/malformed
152
+ pin=${line%% *}
153
+ path=${line#* }
154
+ [ -n "$pin" ] && [ -n "$path" ] || continue
155
+ case "$checked" in *"|$path=$pin|"*) continue ;; esac
156
+ checked="$checked|$path=$pin|"
157
+ verify_pin "$path" "$pin" || block=1
158
+ done <<<"$pins_to_check"
159
+ done
160
+
161
+ [ "$block" = "1" ] && exit 1
162
+ exit 0