feat(hooks): guard modes (precise/strict/off) + pin-publication pre-push check - #4
Merged
Merged
Conversation
…sh check, config knobs reference-transaction rewritten around what a HEAD move actually does to each child, not the move's type — a plain commit ALSO emits a HEAD transaction line (the old doc's 'commit is not caught' claim was wrong, and any dirty child froze all parent commits): - embedded.guard=precise (default): block only a move that would re-pin a DIRTY child (new pin != child HEAD — exactly when the sync hook would touch it); catches the drifted-dirty-child case a naive pin-delta rule misses - embedded.guard=strict: everything-synced policy — any dirty child blocks any move, and appends (classified by parentage against the resolved pre-move HEAD; a detach's transaction line reads null on the old side and can't be trusted) additionally require every pin current, so a parent commit can never ship a stale pin - embedded.guard=off: no guarding New pre-push hook (embedded.pushRecurse=check|on-demand|off, default check): rejects parent pushes whose newly-introduced pins are unreachable from the child's own origin (the analog of push --recurse-submodules=check, impossible with stock git absent .gitmodules registration). Verifies changed pins per new commit + full tree on ref creation only, so children-less clones still push pin-less commits; on-demand publishes the child's current branch first when it contains the pin. Settings are two-part embedded.* keys — structurally collision-free with the three-part per-child registry entries. install-hooks/print-hook-script wired for pre-push (PACKAGE_HOOK_MAP 4→5; log-count test updated for the intentional change). 13 new behavior tests drive real git operations with the hooks installed; suite 28 passing. design.md guard section rewritten + pre-push section; README knob table.
There was a problem hiding this comment.
Pull request overview
This PR extends git-embedded’s hook system to better support large multi-repo workspaces by (1) making the reference-transaction guard configurable (embedded.guard = precise|strict|off) and (2) adding a new pre-push hook to prevent publishing parent pins that reference child commits not yet reachable from the child’s origin.
Changes:
- Add guard modes to
hooks/reference-transactionwith updated decision logic based on the new commit’s pins and append-vs-jump classification. - Add
hooks/pre-pushplus installer/CLI support (install-hooksandprint-hook-script) for pin-publication verification (embedded.pushRecurse = check|on-demand|off). - Add a new Vitest behavior test suite driving real git operations, and update documentation in README/design doc.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/install-hooks.test.mjs | Updates install log-count expectation for the newly installed pre-push hook. |
| tests/hook-guards.test.mjs | Adds behavior tests for guard modes and the new pre-push pin-publication checks. |
| src/api/install/hooks.mjs | Adds pre-push to the packaged hook installation map and clarifies install docstring. |
| src/api/cli/print-hook-script.mjs | Allows printing the packaged pre-push hook script. |
| README.md | Documents new configuration knobs (embedded.guard, embedded.pushRecurse). |
| hooks/reference-transaction | Reworks guard logic to support modes and to correctly handle commit-vs-checkout plumbing behavior. |
| hooks/pre-push | New hook implementing pin-publication verification (and optional on-demand child publishing). |
| docs/design.md | Updates design doc for modes and adds pre-push rationale/mechanism details. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
… on-demand; doc fixes (review) - pre-push: fix the on-demand child-push output redirect (2>&1 >&2 sent both streams to stdout; >&2 2>&1 sends both to stderr), and return success directly after a verified child push — the pushed branch contains the pin, so it is now on origin — instead of re-consulting refs/remotes/origin, which a plain push may not have refreshed. - test: cover the on-demand auto-publish path (previously untested). - test: assert pre-push is installed AND uninstalled (install-hooks covered only the original four). - docs/design: correct the 'git commit' coverage-matrix row (a commit DOES move HEAD and IS guarded) and list on-demand in the test-file header.
…iew round 2)
- pre-push: (path, pin) pairs were serialized 'path pin' and read back with 'read path pin', so a child path with spaces misparsed and the pin-publication check verified the wrong path. Serialize pin-first ('pin path') and read 'pin path' — pin is a fixed-width sha, path is the remainder.
- both hooks: parse 'git ls-tree' by splitting on the TAB (IFS) so a spaced path stays intact (also fixes a leading-space edge the whitespace read stripped).
- both hooks: single-quote $path (and add -- to git add) in the copy-pasteable error hints so they are correct for spaced paths.
- tests: parameterize makeGuardedParent with childPath; add spaced-path cases for the guard (dirty re-pin blocked) and pre-push (published pin passes; unpublished rejected naming the full path).
…mit) git 2.54 changed what a 'git commit' emits to the reference-transaction hook: git <=2.43 sent both a 'HEAD' line and the branch ref, but 2.54 sends only refs/heads/<branch> (the ref the symref HEAD points to). The guard filtered stdin for ref == 'HEAD' only, so on git 2.54 it saw no HEAD move for a commit, exited early, and never blocked — strict-mode commit guarding silently no-op'd. That failed CI on git 2.54.0 while passing on older local git (2.43); the strict-commit tests had been red since the PR opened. Resolve HEAD's current branch (git symbolic-ref HEAD) and match the transaction ref against HEAD OR that branch, so a commit is caught on both git versions; a detached HEAD still matches the literal HEAD line. Verified against a locally-built git 2.54.0 and system git 2.43.0 — full suite green on both. (Reverts the earlier env-clearing attempt, which was a wrong diagnosis: git 2.54 does not leak GIT_DIR into this hook.)
Shinrai
force-pushed
the
fix/guard-modes-and-push-check
branch
from
July 18, 2026 15:57
a0379f3 to
a8a4e21
Compare
…orn one (review) When a child's HEAD can't be read, distinguish a legitimately UNBORN child (fresh 'git init', no commits yet — HEAD is a valid symref to a branch) from a genuinely broken/corrupt one (neither a commit nor a symref resolves). The unborn child is skipped (nothing to guard); a broken child fails closed in strict mode instead of being silently waved through. precise stays lenient and skips either way. Two supporting fixes make the detection reliable: - 'git rev-parse --verify HEAD' (not plain 'rev-parse HEAD', which echoes the literal "HEAD" on an unborn branch — that non-empty stdout would slip past the emptiness check and be misread as a dirty child). - child git commands run via a child_git() wrapper that sets GIT_CEILING_DIRECTORIES to the parent worktree root, so repo discovery can't walk UP into the parent: a child with a broken .git now fails cleanly here instead of silently resolving the PARENT repo's HEAD (a latent bug). Tests: unborn skipped; corrupt HEAD fails closed in strict, skipped in precise. Verified on git 2.43.0 and 2.54.0.
…rd push (review) - diff-tree: add --no-renames so a rename/copy raw line can't emit two tab-separated paths (old<TAB>new) into $path. - verify the whole tip's gitlink set not only on a NEW ref but also on a NON-fast-forward push (force/rewrite): a pin can be new to the remote yet unchanged within remote_sha..local_sha, so the diff-only pass would miss it. Test: a non-ff push whose pin is unchanged in the range but no longer on the child's origin is now rejected.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
hooks/pre-push:147
- The
(pin, path)re-read loop uses default IFS splitting (read -r pin path), which strips leading IFS characters frompath. If an embedded repo directory name starts with a space, the serialized line (<pin> <path>) will be parsed with the leading space dropped and the hook will look for the child in the wrong location (false “child repo is not present”). This is avoidable by reading each line as a whole (preserving whitespace) and then splitting off the fixed-width SHA prefix.
while read -r pin path; do
[ -n "$pin" ] && [ -n "$path" ] || continue
case "$checked" in *"|$path=$pin|"*) continue ;; esac
checked="$checked|$path=$pin|"
verify_pin "$path" "$pin" || block=1
The (pin, path) re-read used `read -r pin path`, whose default-IFS trimming strips a LEADING (and trailing) space from $path — so a gitlink directory whose name begins with a space is looked up at the wrong location and falsely rejected as "child repo is not present". Read each line whole (IFS= disables field-splitting) and split the pin off at the first space (a sha contains none), so the path survives verbatim. Adds a leading-space end-to-end test (fails on the old read: the pin is reported for path "leading" with the space stripped).
Shinrai
added a commit
that referenced
this pull request
Jul 19, 2026
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.
Two hook problems surfaced by real multi-repo parent use (a 26-child workspace), fixed with configurable behavior.
1. The dirty-child guard fired on plain commits — and its doc was wrong
A plain
git commitalso emits aHEADupdate line in the reference transaction (HEAD's reflog records the new commit), so the guard's "HEAD moved" filter matched every parent commit. Result: any one dirty child froze all parent commits — including pin bumps for unrelated children. The design doc claimed commits weren't caught; the plumbing says otherwise.The hook now reasons about what the move would actually do to each child, with a mode knob (
git config embedded.guard):precise(default) — block only a move that would re-pin a dirty child (new pin ≠ child HEAD — exactly whenupdate-embedded-reposwould touch it). Catches the drifted-dirty-child case a naive pin-delta rule misses; never blocks on unrelated dirt.strict— everything-synced policy: any dirty child blocks any move, and appends (classified by parentage against the resolved pre-move HEAD — a detach's transaction line reads null on the old side and can't be trusted) additionally require every pin to equal its child's current HEAD, so a parent commit can never ship a stale pin.off— no guarding.2. Nothing stopped publishing pins to unpushed child commits
A committed-but-unpushed child is clean — the old guard waved it through, and a pushed parent then dangled for every other machine (
restore→pinned-mismatch). Newpre-pushhook (embedded.pushRecurse = check|on-demand|off, defaultcheck): rejects a parent push whose newly-introduced pins are unreachable from the child's own origin — the analog ofgit push --recurse-submodules=check, which stock git can't provide without.gitmodulesregistration. Only newly-introduced pins are verified (plus the full tree on ref creation), so a clone that never restored its children still pushes pin-less commits fine.on-demandpublishes the child's current branch first when it contains the pin.Config shape
Two-part
embedded.*keys — structurally collision-free with the three-part per-child registry entries (embedded.<path>.url/.branch). Local overrides global; one-shot viagit -c.Tests
13 new behavior tests drive real git operations with the hooks installed (
tests/hook-guards.test.mjs): precise allow/block/drift cases, strict dirty/stale-pin/jump cases, off, and pre-push check/publish/off/children-less-clone/blind-pin cases.install-hookslog-count updated 4→5 for the new hook (intentional). Suite 28 passing.Docs
design.md: guard section rewritten around the transaction-line fact (correcting the wrong "commit is not caught" claim), modes documented, new pre-push section. README: knob table.🤖 Generated with Claude Code
https://claude.ai/code/session_01T9qCSCACxt2H2JG7YSDzdk