@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 +93 -0
- package/bin/git-embedded.mjs +0 -0
- package/docs/design.md +102 -31
- package/hooks/pre-push +162 -0
- package/hooks/reference-transaction +136 -35
- package/hooks/update-embedded-repos +0 -0
- package/package.json +8 -5
- package/src/api/cli/export.mjs +71 -0
- package/src/api/cli/init.mjs +1 -0
- package/src/api/cli/install-hooks.mjs +1 -0
- package/src/api/cli/link.mjs +55 -8
- package/src/api/cli/print-hook-script.mjs +1 -0
- package/src/api/cli/record.mjs +36 -0
- package/src/api/cli/restore.mjs +74 -0
- package/src/api/cli/sync.mjs +70 -0
- package/src/api/commander/custom-help.mjs +7 -1
- package/src/api/embedded/branch.mjs +62 -0
- package/src/api/embedded/gitlinks.mjs +45 -0
- package/src/api/embedded/manifest.mjs +71 -0
- package/src/api/embedded/record.mjs +46 -0
- package/src/api/embedded/registry.mjs +119 -0
- package/src/api/embedded/resolve.mjs +87 -0
- package/src/api/embedded/restore.mjs +242 -0
- package/src/api/embedded/sync.mjs +255 -0
- package/src/api/git.mjs +3 -0
- package/src/api/install/hooks.mjs +3 -2
- package/src/api/report.mjs +3 -0
- package/src/lib/elevate-windows-child.mjs +0 -0
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:
|
package/bin/git-embedded.mjs
CHANGED
|
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
|
|
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
|
|
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
|
|
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
|
|
84
|
-
|
|
85
|
-
| `git checkout <ref>`
|
|
86
|
-
| `git switch <branch>`
|
|
87
|
-
| `git reset --hard <commit>`
|
|
88
|
-
| `git reset --soft/--mixed <commit>` | Refuses if any child is dirty (HEAD moves)
|
|
89
|
-
| `git pull` (fast-forward)
|
|
90
|
-
| `git pull --rebase`
|
|
91
|
-
| `git merge <commit>`
|
|
92
|
-
| `git rebase`
|
|
93
|
-
| `git bisect <good/bad/run>`
|
|
94
|
-
| `git cherry-pick`
|
|
95
|
-
| `git stash pop`
|
|
96
|
-
| `git commit`
|
|
97
|
-
| `git checkout -- file`
|
|
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
|
|
102
|
-
|
|
103
|
-
| Child URL in parent
|
|
104
|
-
| Tree-level pin
|
|
105
|
-
| Public viewer sees
|
|
106
|
-
| `git submodule update`
|
|
107
|
-
| `submodule.recurse=true`
|
|
108
|
-
| `git status` divergence
|
|
109
|
-
| `git add path` infers SHA
|
|
110
|
-
| `--recurse-submodules` clone | Pulls child
|
|
111
|
-
| Initial child clone
|
|
112
|
-
| Dirty-child guard
|
|
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
|