Skip to content

feat(hooks): guard modes (precise/strict/off) + pin-publication pre-push check - #4

Merged
Shinrai merged 7 commits into
nextfrom
fix/guard-modes-and-push-check
Jul 18, 2026
Merged

feat(hooks): guard modes (precise/strict/off) + pin-publication pre-push check#4
Shinrai merged 7 commits into
nextfrom
fix/guard-modes-and-push-check

Conversation

@Shinrai

@Shinrai Shinrai commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

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 commit also emits a HEAD update 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 when update-embedded-repos would 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 (restorepinned-mismatch). New pre-push hook (embedded.pushRecurse = check|on-demand|off, default check): rejects a parent push whose newly-introduced pins are unreachable from the child's own origin — the analog of git push --recurse-submodules=check, which stock git can't provide without .gitmodules registration. 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-demand publishes 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 via git -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-hooks log-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

…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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-transaction with updated decision logic based on the new commit’s pins and append-vs-jump classification.
  • Add hooks/pre-push plus installer/CLI support (install-hooks and print-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.

Comment thread hooks/pre-push
Comment thread tests/hook-guards.test.mjs Outdated
Comment thread tests/hook-guards.test.mjs
Comment thread docs/design.md Outdated
Comment thread tests/install-hooks.test.mjs
… 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Comment thread hooks/reference-transaction Outdated
Comment thread hooks/reference-transaction
Comment thread hooks/pre-push Outdated
Comment thread hooks/pre-push
…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).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comment thread hooks/pre-push
Comment thread hooks/reference-transaction Outdated
…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
Shinrai force-pushed the fix/guard-modes-and-push-check branch from a0379f3 to a8a4e21 Compare July 18, 2026 15:57
…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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comment thread hooks/pre-push Outdated
Comment thread hooks/pre-push Outdated
…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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 from path. 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
Shinrai merged commit d5ec7be into next Jul 18, 2026
18 checks passed
@cldmv-bot
cldmv-bot Bot deleted the fix/guard-modes-and-push-check branch July 18, 2026 22:42
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants