diff --git a/README.md b/README.md index 0b5d991..5521f5b 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,17 @@ git embedded install-hooks # install hooks into this repo's .git/hooks git embedded uninstall-hooks # remove hooks installed by this CLI ``` +### Guard behavior (config knobs) + +The installed hooks read two settings (`git config`, local overrides global; one-shot override with `git -c = `): + +| Key | Values | Default | What it controls | +| ---------------------- | ----------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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. | +| `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. | + +Two-part keys like these can never collide with the per-child registry entries (`embedded..url` / `.branch`), which are always three-part. + `install-hooks` adapts to whatever's already in place: - **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. diff --git a/docs/design.md b/docs/design.md index 4d97759..7e8acb8 100644 --- a/docs/design.md +++ b/docs/design.md @@ -19,7 +19,7 @@ The hooks in this package close the registration gap without requiring a registr ## Why this matters: the URL is the leak -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. +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. 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. @@ -35,11 +35,21 @@ Avoiding `.gitmodules` is the obvious fix, but doing so loses the working-tree a - `committed` — updates already applied. - `aborted` — informational. -The hook script acts only on the `prepared` phase, where rejection is possible. It reads the proposed ref updates from stdin (one `old_sha new_sha ref` per line), filters to lines where `ref` is `HEAD` and `old_sha != new_sha` (an actual HEAD move), and walks every gitlink in the current tree checking for uncommitted changes via `git diff-index --quiet HEAD --` inside each child. If any child is dirty, the hook prints a message to stderr and exits non-zero, which aborts the parent operation. +The hook script acts only on the `prepared` phase, where rejection is possible. It reads the proposed ref updates from stdin (one `old_sha new_sha ref` per line) and filters to lines where `ref` is `HEAD` with `old_sha != new_sha` (a HEAD move). -**What it catches.** Every git command that ultimately moves HEAD goes through a reference transaction. That includes `git checkout `, `git switch `, `git reset` (any mode that moves HEAD), `git pull` (both fast-forward and rebase variants), `git merge`, `git rebase` (each step), `git bisect` (each step), `git cherry-pick`, and others. +**A plumbing fact that shapes the design:** a plain `git commit` ALSO emits a HEAD update line in the reference transaction (HEAD's reflog records the new commit), so "HEAD moved" alone cannot distinguish a commit from a checkout. An earlier revision of this document claimed commits were not caught; that was wrong, and the hook now reasons about what the move would actually do to each child instead of assuming the operation's type. Where the type matters (strict mode), append vs jump is classified by parentage: a move whose NEW commit lists the current (pre-move) HEAD among its parents is an append (commit, merge, cherry-pick step); everything else is a jump (checkout, switch, reset, bisect). The pre-move HEAD is resolved directly — the transaction line's old value reads as the null SHA on a checkout-to-SHA detach and must not be trusted for this. One known edge: switching to a branch whose tip is a direct child of the current HEAD is indistinguishable from a commit by parentage and classifies as an append. -**What it does not catch.** Operations that don't move HEAD aren't guarded, by design: `git commit` (creates a new commit but doesn't update the gitlink without explicit staging), `git checkout -- file` (file-level checkout), `git stash` itself (records stash refs, not HEAD), and so on. These don't require child-update behavior. +**Guard modes** (`git config embedded.guard`, local over global; two-part settings keys in the `embedded.*` section are structurally reserved — registry entries are always three-part `embedded..url|branch`): + +- `precise` _(default)_ — block only when a DIRTY child's HEAD differs from the pin recorded in the NEW commit: exactly the condition under which `update-embedded-repos` would try to move a child carrying uncommitted changes. A clean child never blocks; a dirty child whose pin equals its HEAD never blocks (the sync no-ops). This lets a parent evolve — including plain commits and pin bumps — while unrelated children are mid-work. +- `strict` — the everything-synced policy for workspaces that want the parent to only ever snapshot a fully-committed state. Any dirty child blocks any HEAD move, and on APPENDS every child's pin in the new commit must equal that child's current HEAD — so a parent commit can never ship a stale pin (work done in a child but not recorded in the parent). Jumps only require all-clean: their pins are expected to differ, and the post-hook sync moves the (clean) children afterwards. +- `off` — no guarding. + +One-shot override for any mode: `git -c embedded.guard= `. "Dirty" is `git diff-index --quiet HEAD` semantics — modified/staged tracked files; untracked files never count. + +**What it catches.** Every git command that updates HEAD in a reference transaction: `git commit`, `git checkout `, `git switch `, `git reset`, `git pull`, `git merge`, `git rebase` (each step), `git bisect` (each step), `git cherry-pick`, and others — with per-mode rules as above. + +**What it does not catch.** Operations that don't move HEAD: `git checkout -- file` (file-level checkout), `git stash` itself (records stash refs, not HEAD), bare index edits. These don't trigger child-update behavior, so there is nothing to guard. **Caveat about error messaging.** When the hook exits non-zero, git wraps its own message around the script's stderr output. The user sees a message like: @@ -76,40 +86,57 @@ The detached-HEAD checkout matches standard submodule behavior: parents pin spec **What it does not catch.** Two notable gaps: - `git reset --hard ` 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. + +### `pre-push` (pin publication check) + +**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. + +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. + +**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. + +**Modes** (`git config embedded.pushRecurse`, local over global): + +- `check` _(default)_ — reject the push with a "push the child first" message. +- `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. +- `off` — no verification. + +One-shot override: `git -c embedded.pushRecurse= push …`. + - `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. ## Coverage matrix -| Operation | `reference-transaction` (guard) | `update-embedded-repos` (update) | -|---|---|---| -| `git checkout ` | Refuses if any child is dirty | Updates children to new pins | -| `git switch ` | Refuses if any child is dirty | Updates children to new pins | -| `git reset --hard ` | Refuses if any child is dirty | **Gap** — does not fire `post-*` hooks | -| `git reset --soft/--mixed ` | Refuses if any child is dirty (HEAD moves) | Does not fire `post-*` hooks (HEAD-only change) | -| `git pull` (fast-forward) | Refuses if any child is dirty | Updates children | -| `git pull --rebase` | Refuses at each rebase step | Updates children after rebase completes | -| `git merge ` | Refuses if any child is dirty | Updates children via `post-merge` | -| `git rebase` | Refuses at each step | Updates children via `post-rewrite` | -| `git bisect ` | Refuses if any child is dirty | Updates children at each bisect step | -| `git cherry-pick` | Refuses if any child is dirty | Updates children via `post-checkout` | -| `git stash pop` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) | -| `git commit` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) | -| `git checkout -- file` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) | +| Operation | `reference-transaction` (guard) | `update-embedded-repos` (update) | +| ----------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| `git checkout ` | Refuses if any child is dirty | Updates children to new pins | +| `git switch ` | Refuses if any child is dirty | Updates children to new pins | +| `git reset --hard ` | Refuses if any child is dirty | **Gap** — does not fire `post-*` hooks | +| `git reset --soft/--mixed ` | Refuses if any child is dirty (HEAD moves) | Does not fire `post-*` hooks (HEAD-only change) | +| `git pull` (fast-forward) | Refuses if any child is dirty | Updates children | +| `git pull --rebase` | Refuses at each rebase step | Updates children after rebase completes | +| `git merge ` | Refuses if any child is dirty | Updates children via `post-merge` | +| `git rebase` | Refuses at each step | Updates children via `post-rewrite` | +| `git bisect ` | Refuses if any child is dirty | Updates children at each bisect step | +| `git cherry-pick` | Refuses if any child is dirty | Updates children via `post-checkout` | +| `git stash pop` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) | +| `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) | +| `git checkout -- file` | Not guarded (no HEAD move) | Not updated (no HEAD move; not needed) | ## Comparison to standard submodules -| Property | Standard submodule | Anonymous gitlink + these hooks | -|---|---|---| -| Child URL in parent | Yes, in `.gitmodules` | No | -| Tree-level pin | Gitlink | Gitlink | -| Public viewer sees | URL, path, current SHA | Just the SHA (no link to follow) | -| `git submodule update` | Works | Not used (registry-bound; hooks replace it) | -| `submodule.recurse=true` | Works | Not used (registry-bound; hooks replace it) | -| `git status` divergence | Yes | Yes | -| `git add path` infers SHA | Yes | Yes | -| `--recurse-submodules` clone | Pulls child | No-op (no registry) | -| Initial child clone | Automatic via registry | Manual or via the planned CLI | -| Dirty-child guard | Default refuses on update | Hook refuses on the HEAD move itself | +| Property | Standard submodule | Anonymous gitlink + these hooks | +| ---------------------------- | ------------------------- | ------------------------------------------- | +| Child URL in parent | Yes, in `.gitmodules` | No | +| Tree-level pin | Gitlink | Gitlink | +| Public viewer sees | URL, path, current SHA | Just the SHA (no link to follow) | +| `git submodule update` | Works | Not used (registry-bound; hooks replace it) | +| `submodule.recurse=true` | Works | Not used (registry-bound; hooks replace it) | +| `git status` divergence | Yes | Yes | +| `git add path` infers SHA | Yes | Yes | +| `--recurse-submodules` clone | Pulls child | No-op (no registry) | +| Initial child clone | Automatic via registry | Manual or via the planned CLI | +| Dirty-child guard | Default refuses on update | Hook refuses on the HEAD move itself | 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. diff --git a/hooks/pre-push b/hooks/pre-push new file mode 100755 index 0000000..6b6da0c --- /dev/null +++ b/hooks/pre-push @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# pre-push +# +# Refuses 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. +# +# This is git-embedded's analog of `git push --recurse-submodules=check` — +# stock git can't provide it here because that machinery locates children via +# .gitmodules registration, which anonymous gitlinks deliberately omit. +# +# Mode (git config embedded.pushRecurse — local overrides global; default check): +# check Verify each pin new to the remote is reachable from some +# refs/remotes/origin/* tip in the child (refreshing with one +# `git fetch origin` on a miss). Unreachable → reject the push +# with a "push the child first" message. +# on-demand Like check, but 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). Falls back to check's rejection +# when it can't. +# off No verification. +# +# One-shot override: git -c embedded.pushRecurse= push … +# +# Args: $1 = remote name, $2 = remote URL. +# Stdin: SP SP SP per ref. + +push_mode=$(git config --get embedded.pushRecurse 2>/dev/null) +case "$push_mode" in +off) exit 0 ;; +check | on-demand) ;; +*) push_mode="check" ;; +esac + +zeros="0000000000000000000000000000000000000000" + +# Is $2 (a commit SHA) reachable from any origin remote-tracking tip inside +# the child repo at $1? +reachable_from_origin() ( + cd "$1" || return 1 + for tip in $(git for-each-ref --format='%(objectname)' refs/remotes/origin 2>/dev/null); do + if git merge-base --is-ancestor "$2" "$tip" 2>/dev/null; then + return 0 + fi + done + return 1 +) + +# Verify one (path, pin). Prints the rejection message and returns 1 when the +# pin cannot be confirmed published. +verify_pin() { + local path="$1" pin="$2" + + if ! [ -d "$path/.git" ] && ! [ -f "$path/.git" ]; then + echo "git-embedded: ✗ cannot verify $path pin ${pin:0:12} — the child repo is not present here" >&2 + echo " restore it first (git embedded restore '$path'), or bypass once with -c embedded.pushRecurse=off" >&2 + return 1 + fi + + # Fast path: current remote-tracking knowledge. + reachable_from_origin "$path" "$pin" && return 0 + + # Refresh once — local refs/remotes may simply be stale. + (cd "$path" && git fetch --quiet origin 2>/dev/null) + reachable_from_origin "$path" "$pin" && return 0 + + if [ "$push_mode" = "on-demand" ]; then + # Publish the child's current branch iff it is a real branch that + # contains the pin. Never invent a ref for a detached child. + local branch + branch=$(cd "$path" && git symbolic-ref --quiet --short HEAD 2>/dev/null) + if [ -n "$branch" ] && (cd "$path" && git merge-base --is-ancestor "$pin" "$branch" 2>/dev/null); then + echo "git-embedded: pushing $path ($branch) to publish pin ${pin:0:12}…" >&2 + # `>&2 2>&1` (not `2>&1 >&2`) sends BOTH streams to stderr — order + # matters. A successful push means the pin (an ancestor of $branch, + # verified above) is now on origin, so succeed directly rather than + # re-checking refs/remotes/origin, which a plain push may not refresh. + if (cd "$path" && git push --quiet origin "$branch" >&2 2>&1); then + return 0 + fi + fi + fi + + echo "git-embedded: ✗ $path pin ${pin:0:12} is not on that child's origin" >&2 + echo " push the child first (git -C '$path' push), then retry this push" >&2 + return 1 +} + +block=0 +checked="" + +# For each pushed ref, examine every commit that is new to the remote and +# collect its gitlink pins: the pins CHANGED by each new commit (diff-tree, +# cheap) plus the full gitlink set of the tip (ls-tree) — a pin unchanged +# throughout the range is by definition still in the tip's tree, so the union +# covers every pin the remote is about to learn. +while read local_ref local_sha remote_ref remote_sha; do + [ "$local_sha" = "$zeros" ] && continue # deletion — nothing to verify + + if [ "$remote_sha" = "$zeros" ]; then + # New remote ref: bound the walk by everything already on any remote. + range_args=("$local_sha" --not --remotes) + else + range_args=("$remote_sha..$local_sha") + fi + + pins_to_check="" + + # Changed gitlink pins across the new commits. + for c in $(git rev-list "${range_args[@]}" 2>/dev/null); do + while IFS=$'\t' read -r meta path; do + set -- $meta + # diff-tree --raw: : + [ "$2" = "160000" ] || continue + new_pin="$4" + [ "$new_pin" = "$zeros" ] && continue # gitlink removed + # pin-FIRST so a $path with spaces survives the read-back below + # (pin is a fixed-width sha; path is the remainder). + pins_to_check="$pins_to_check$new_pin $path"$'\n' + # --no-renames: without it, a rename/copy raw line carries TWO tab- + # separated paths (oldnew), which would land a tabbed value in $path. + done < <(git diff-tree -r --no-commit-id --no-renames --raw "$c" 2>/dev/null) + done + + # Verify every gitlink in the tip when the remote can't derive the tree from + # the delta: a NEW ref (learning the whole tree), OR a NON-fast-forward update + # (force-push / rewritten history) — there a pin can be new to the remote yet + # unchanged within remote_sha..local_sha, so the diff pass alone would miss it. + # For an ordinary fast-forward the diff pass suffices (an unchanged pin was + # already in the remote's tree and verified when first pushed), so a clone that + # never restored its children can still push commits that touch no pin. + if [ "$remote_sha" = "$zeros" ] || ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then + while IFS=$'\t' read -r meta path; do + set -- $meta + [ "$2" = "commit" ] || continue + pins_to_check="$pins_to_check$3 $path"$'\n' + done < <(git ls-tree -r "$local_sha" 2>/dev/null) + fi + + # Verify each unique (path, pin) once. Read each line WHOLE (IFS= disables + # field-splitting), then split the pin off at the FIRST space: a plain + # `read -r pin path` strips a LEADING space from $path (default-IFS trims the + # last field's leading whitespace), so a gitlink dir whose name begins with a + # space would be looked up at the wrong location — and a trailing space would + # be dropped too. The pin is a fixed-width sha with no spaces, so the first + # space is always the pin/path boundary; everything after it (leading or + # trailing space included) is the path verbatim. + while IFS= read -r line; do + case "$line" in *" "*) ;; *) continue ;; esac # skip blank/malformed + pin=${line%% *} + path=${line#* } + [ -n "$pin" ] && [ -n "$path" ] || continue + case "$checked" in *"|$path=$pin|"*) continue ;; esac + checked="$checked|$path=$pin|" + verify_pin "$path" "$pin" || block=1 + done <<<"$pins_to_check" +done + +[ "$block" = "1" ] && exit 1 +exit 0 diff --git a/hooks/reference-transaction b/hooks/reference-transaction index 9db1c19..b90a7a1 100755 --- a/hooks/reference-transaction +++ b/hooks/reference-transaction @@ -1,54 +1,155 @@ #!/usr/bin/env bash # reference-transaction # -# Refuses HEAD-moving git operations if any embedded git repo in the parent's -# tree has uncommitted changes. Without this, a `git checkout B` in the parent -# would silently leave the child stale (with the post-checkout updater unable -# to update over dirty state), producing a confusing inconsistent state. +# Guards HEAD-moving git operations against harming embedded child repos. +# The companion update-embedded-repos hook (post-checkout/-merge/-rewrite) +# force-syncs every child to the pin recorded in the parent's new HEAD; this +# hook refuses, up-front, the moves where that sync (or the move itself) +# would confuse or destroy in-flight child work. # -# Requires git 2.28+ (released July 2020, when reference-transaction was -# introduced). +# IMPORTANT PLUMBING FACT: a plain `git commit` moves the ref HEAD points to, +# reported in the reference transaction as HEAD (git <=2.43) or as the branch +# ref refs/heads/ (git 2.54+) — the guard watches BOTH. Either way, +# "HEAD moved" alone cannot distinguish a commit from a checkout, so the mode +# logic below reasons about what the move would actually DO to each child, and +# classifies append-vs-jump by commit parentage where it matters. # -# Phases (passed as $1 by git): -# - prepared: updates queued, not yet applied. Exiting non-zero ABORTS the -# transaction. This is the only phase we act on. -# - committed: updates already applied (informational; we ignore). -# - aborted: updates rejected by some other handler (informational). +# Mode (git config embedded.guard — local overrides global; default precise): +# precise Block only when a DIRTY child's HEAD differs from the pin in the +# NEW commit — i.e. exactly when update-embedded-repos would try to +# move a child that has uncommitted changes. Clean children, and +# dirty children whose pin already equals their HEAD (the sync +# no-ops), never block. +# strict The everything-synced policy. Any dirty child blocks any HEAD +# move. Additionally, on APPENDS (commit/merge/cherry-pick — the +# new commit lists the old HEAD among its parents) every child's +# pin in the new commit must equal that child's current HEAD, so a +# parent commit can never ship stale pins. Jumps (checkout/reset) +# only require all-clean: their pins are EXPECTED to differ, and +# the post-hook sync moves the (clean) children afterwards. +# off No guarding. # -# The hook reads proposed ref updates from stdin, one per line: -# +# One-shot override: git -c embedded.guard= # -# We filter to HEAD updates with old != new (an actual HEAD move). Other ref -# updates (branch fast-forwards from fetch, stash refs, tag creation, etc.) -# don't touch the working tree and shouldn't be guarded. - -# Only the 'prepared' phase can reject the transaction. +# Requires git 2.28+ (reference-transaction hook). +# +# Phases (passed as $1 by git): only 'prepared' can reject the transaction. [ "$1" = "prepared" ] || exit 0 -# Detect whether any update in this transaction moves HEAD. -moving_head=0 +guard_mode=$(git config --get embedded.guard 2>/dev/null) +case "$guard_mode" in +off) exit 0 ;; +strict | precise) ;; +*) guard_mode="precise" ;; +esac + +# Anchor every git command we run INSIDE a child to the child's own repo: +# GIT_CEILING_DIRECTORIES stops repo discovery from walking UP into this parent, +# so a child with a broken/corrupt .git fails cleanly here (empty HEAD, no +# symref) instead of silently resolving the PARENT repo's HEAD. +parent_root=$(pwd) +child_git() ( + cd "$1" 2>/dev/null || return 1 + shift + GIT_CEILING_DIRECTORIES="$parent_root" git "$@" +) + +# Detect a HEAD move and capture its endpoints. Stdin lines: +# +# Other ref updates (fetch fast-forwards, stash refs, tags) don't touch the +# working tree and aren't guarded. +zeros="0000000000000000000000000000000000000000" +# The ref a working-tree HEAD move touches is either literal HEAD (a detached +# checkout, or a commit on a detached HEAD) or — when HEAD is on a branch — the +# BRANCH ref HEAD points to. git <=2.43 emitted a redundant HEAD line for a +# commit, so matching "HEAD" alone sufficed; git 2.54 emits ONLY the branch ref +# (refs/heads/) for a commit, so without also matching the current +# branch every commit slips past the guard. Resolve HEAD's branch and watch both. +head_ref=$(git symbolic-ref --quiet HEAD 2>/dev/null) +moving=0 +old_head="" +new_head="" while read old_sha new_sha ref; do - [ "$ref" = "HEAD" ] || continue + [ "$ref" = "HEAD" ] || { [ -n "$head_ref" ] && [ "$ref" = "$head_ref" ]; } || continue [ "$old_sha" = "$new_sha" ] && continue - moving_head=1 + moving=1 + old_head=$old_sha + new_head=$new_sha done +[ "$moving" = "1" ] || exit 0 +[ -n "$new_head" ] && [ "$new_head" != "$zeros" ] || exit 0 -# Nothing to check if HEAD is not moving. -[ "$moving_head" = "1" ] || exit 0 +# Append vs jump (strict mode only cares): an append's new commit lists the +# CURRENT (pre-move) HEAD among its parents — commit, merge, cherry-pick step. +# The pre-move HEAD is resolved directly rather than trusting the transaction +# line's old value: a checkout-to-SHA (detach) reports its HEAD line with the +# null SHA on the old side, which must NOT read as "initial commit". An unborn +# HEAD (the real initial commit) counts as an append. Known edge: switching to +# a branch whose tip is a direct child of the current HEAD is indistinguishable +# from a commit by parentage and is treated as an append; in strict mode use +# `git -c embedded.guard=precise checkout …` if that blocks a legitimate move. +is_append=0 +current_head=$(git rev-parse -q --verify "HEAD^{commit}" 2>/dev/null) +if [ -z "$current_head" ]; then + is_append=1 +else + for parent in $(git rev-list --parents -n 1 "$new_head" 2>/dev/null | cut -d' ' -f2-); do + [ "$parent" = "$current_head" ] && is_append=1 + done +fi -# Walk every gitlink in the current HEAD and check for dirty state. -# A gitlink is a tree entry with type 'commit' (mode 160000). The path is -# the directory in the parent's working tree that holds the embedded repo. -while read mode type sha path; do - [ "$type" = "commit" ] || continue +# Walk every gitlink in the NEW commit — those pins are what the post-hook +# sync will enforce after the move. Tree entries from `git ls-tree -r`: +# SP SP TAB +block=0 +# Split ls-tree output on the TAB so a child path containing spaces stays intact +# (the meta side, ` `, is space-separated and re-split below). +while IFS=$'\t' read -r meta path; do + set -- $meta + entry_type=$2 + pin=$3 + [ "$entry_type" = "commit" ] || continue [ -d "$path/.git" ] || [ -f "$path/.git" ] || continue - # Refuse if there are uncommitted changes inside the embedded repo. - if ! (cd "$path" && git diff-index --quiet HEAD --) 2>/dev/null; then - echo "git-embedded: ✗ $path has uncommitted changes" >&2 - echo " commit or stash inside $path/ before moving HEAD here" >&2 - exit 1 + # --verify: fail cleanly with EMPTY stdout when HEAD can't resolve (plain + # `rev-parse HEAD` echoes the literal "HEAD" on an unborn branch, which would + # slip past the emptiness check below and be misread as a dirty child). + child_head=$(child_git "$path" rev-parse --verify HEAD 2>/dev/null) + if [ -z "$child_head" ]; then + # HEAD is unreadable. Distinguish a legitimately UNBORN child (fresh + # `git init`, no commits yet — HEAD is still a valid symref to a branch) + # from a genuinely broken/corrupt repo (neither a commit nor a symref + # resolves). An unborn child has nothing to guard, so skip it. In strict + # mode, fail closed on a broken one rather than silently waving it through; + # precise stays lenient and skips either way. + if [ "$guard_mode" = "strict" ] && ! child_git "$path" symbolic-ref --quiet HEAD >/dev/null 2>&1; then + echo "git-embedded: ✗ $path — cannot read HEAD (missing or corrupt repo?) (embedded.guard=strict)" >&2 + echo " fix or re-restore the child at '$path'/, or bypass once with -c embedded.guard=off" >&2 + block=1 + fi + continue + fi + dirty=0 + child_git "$path" diff-index --quiet HEAD -- 2>/dev/null || dirty=1 + + if [ "$guard_mode" = "strict" ]; then + if [ "$dirty" = "1" ]; then + echo "git-embedded: ✗ $path has uncommitted changes (embedded.guard=strict)" >&2 + echo " commit or stash inside '$path'/ before moving HEAD here" >&2 + block=1 + elif [ "$is_append" = "1" ] && [ "$child_head" != "$pin" ]; then + echo "git-embedded: ✗ $path is not synced: pin ${pin:0:12} != child HEAD ${child_head:0:12} (embedded.guard=strict)" >&2 + echo " record the child's current commit (git add -- '$path') or move the child to the pin before committing the parent" >&2 + block=1 + fi + else # precise + if [ "$dirty" = "1" ] && [ "$child_head" != "$pin" ]; then + echo "git-embedded: ✗ $path has uncommitted changes and this move would re-pin it (${child_head:0:12} → ${pin:0:12})" >&2 + echo " commit or stash inside '$path'/ before moving HEAD here" >&2 + block=1 + fi fi -done < <(git ls-tree -r HEAD 2>/dev/null) +done < <(git ls-tree -r "$new_head" 2>/dev/null) +[ "$block" = "1" ] && exit 1 exit 0 diff --git a/src/api/cli/print-hook-script.mjs b/src/api/cli/print-hook-script.mjs index 508463a..45d95fa 100644 --- a/src/api/cli/print-hook-script.mjs +++ b/src/api/cli/print-hook-script.mjs @@ -6,6 +6,7 @@ const NAME_TO_SOURCE = { "post-rewrite": "update-embedded-repos", "reference-transaction": "reference-transaction", "update-embedded-repos": "update-embedded-repos", + "pre-push": "pre-push", _dispatch: "_dispatch.template", dispatcher: "_dispatch.template" }; diff --git a/src/api/install/hooks.mjs b/src/api/install/hooks.mjs index 8acf66c..b3c08c8 100644 --- a/src/api/install/hooks.mjs +++ b/src/api/install/hooks.mjs @@ -4,13 +4,14 @@ export const PACKAGE_HOOK_MAP = { "post-checkout": "update-embedded-repos", "post-merge": "update-embedded-repos", "post-rewrite": "update-embedded-repos", - "reference-transaction": "reference-transaction" + "reference-transaction": "reference-transaction", + "pre-push": "pre-push" }; /** * Install or uninstall the package's per-repo hook scripts. * - * `op === "install"` copies the four hooks; `op === "uninstall"` removes only + * `op === "install"` copies the package hooks; `op === "uninstall"` removes only * the ones recognizably owned by git-embedded (any file whose content includes * the string "git-embedded"). * diff --git a/tests/hook-guards.test.mjs b/tests/hook-guards.test.mjs new file mode 100644 index 0000000..be3fd9d --- /dev/null +++ b/tests/hook-guards.test.mjs @@ -0,0 +1,422 @@ +/** + * @Project: @cldmv/git-embedded + * @Filename: /tests/hook-guards.test.mjs + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + * + * Behavior tests for the two guard hooks, driven through REAL git operations + * with the hooks installed into the parent's .git/hooks: + * + * - reference-transaction (embedded.guard = precise | strict | off): which + * HEAD moves are allowed/blocked given each child's dirty state and the + * pins in the NEW commit. Covers the plumbing fact that a plain commit + * emits a HEAD transaction line, the precise rule (dirty + would-re-pin), + * strict's all-clean + pins-current-on-append policy, and the drifted-child + * hole a naive pin-delta rule would miss. + * + * - pre-push (embedded.pushRecurse = check | on-demand | off): parent pushes + * are rejected while a newly-pinned child commit is unreachable from the + * child's origin, allowed once the child is pushed (on-demand publishes the + * child's branch to do that automatically), and unrelated (pin-less) pushes + * from a children-less clone stay allowed. + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const hooksSrc = path.join(here, "..", "hooks"); + +const tmpRoots = []; +function mkTmp() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "git-embedded-guards-")); + tmpRoots.push(dir); + return dir; +} + +function git(args, cwd) { + const res = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (res.status !== 0) throw new Error(`git ${args.join(" ")} (cwd=${cwd}) failed: ${res.stderr || res.stdout}`); + return (res.stdout || "").trim(); +} + +/** Like git() but returns { status, stderr } for operations expected to be blocked. */ +function gitTry(args, cwd) { + const res = spawnSync("git", args, { cwd, encoding: "utf8" }); + return { status: res.status ?? 1, stderr: res.stderr || "", stdout: res.stdout || "" }; +} + +function installHook(repoDir, name) { + const dest = path.join(repoDir, ".git", "hooks", name); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(path.join(hooksSrc, name), dest); + fs.chmodSync(dest, 0o755); +} + +/** + * Parent repo with one embedded child at `tests`, pushed bares for both, and + * the requested hooks installed. Child working copy inside the parent is a + * clone of the child bare (origin wired), attached to main at c1. + */ +function makeGuardedParent({ hooks = [], childPath = "tests" } = {}) { + const work = mkTmp(); + const childBare = path.join(work, "child.git"); + git(["init", "--bare", "-b", "main", childBare]); + const childSeed = path.join(work, "child-seed"); + git(["init", "-b", "main", childSeed]); + fs.writeFileSync(path.join(childSeed, "spec.txt"), "c1"); + git(["add", "."], childSeed); + git(["commit", "-m", "c1"], childSeed); + git(["remote", "add", "origin", childBare], childSeed); + git(["push", "--quiet", "origin", "main"], childSeed); + + const parentBare = path.join(work, "parent.git"); + git(["init", "--bare", "-b", "main", parentBare]); + const parent = path.join(work, "parent"); + git(["init", "-b", "main", parent]); + fs.writeFileSync(path.join(parent, "README.md"), "parent"); + git(["add", "."], parent); + git(["commit", "-m", "parent init"], parent); + git(["clone", "--quiet", childBare, path.join(parent, childPath)], parent); + git(["add", childPath], parent); + git(["commit", "-m", `embed ${childPath}`], parent); + git(["remote", "add", "origin", parentBare], parent); + + for (const h of hooks) installHook(parent, h); + const child = path.join(parent, childPath); + return { work, parent, parentBare, child, childBare, childPath }; +} + +/** Commit inside the child (advances its HEAD; keeps it clean). */ +function childCommit(child, marker) { + fs.writeFileSync(path.join(child, "spec.txt"), marker); + git(["add", "."], child); + git(["commit", "-m", marker], child); + return git(["rev-parse", "HEAD"], child); +} + +// "Dirty" per the hooks' diff-index semantics = a MODIFIED TRACKED file. +// (Untracked files never count — same as the original guard's behavior.) +function dirtyChild(child) { + fs.writeFileSync(path.join(child, "spec.txt"), "UNCOMMITTED EDIT"); +} +function cleanChild(child) { + git(["checkout", "--", "spec.txt"], child); +} + +let originalEnv; +beforeEach(() => { + originalEnv = { ...process.env }; + // Hermetic git: no host/global config (no global hooksPath dispatcher, no + // signing), a fixed identity. + process.env.GIT_CONFIG_GLOBAL = os.platform() === "win32" ? "NUL" : "/dev/null"; + process.env.GIT_CONFIG_SYSTEM = os.platform() === "win32" ? "NUL" : "/dev/null"; + process.env.GIT_AUTHOR_NAME = "test"; + process.env.GIT_AUTHOR_EMAIL = "test@example.com"; + process.env.GIT_COMMITTER_NAME = "test"; + process.env.GIT_COMMITTER_EMAIL = "test@example.com"; +}); +afterEach(() => { + process.env = originalEnv; + while (tmpRoots.length) { + const d = tmpRoots.pop(); + try { + fs.rmSync(d, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } +}); + +describe.skipIf(process.platform === "win32")("reference-transaction guard modes", () => { + it("precise (default): a parent commit passes while a child is dirty AT its pin", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + dirtyChild(child); + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + git(["commit", "-m", "docs"], parent); // would throw if blocked + expect(git(["log", "--oneline", "-1"], parent)).toContain("docs"); + }); + + it("precise: a checkout that would re-pin a dirty child is blocked; the same checkout with the child clean passes", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + const commitA = git(["rev-parse", "HEAD"], parent); + const c2 = childCommit(child, "c2"); + git(["add", "tests"], parent); + git(["commit", "-m", "bump pin to c2"], parent); + + dirtyChild(child); // child HEAD c2; commitA pins c1 → re-pin + dirty + const blocked = gitTry(["checkout", "--quiet", commitA], parent); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/would re-pin/); + expect(git(["rev-parse", "HEAD"], child)).toBe(c2); // untouched + + cleanChild(child); + const ok = gitTry(["checkout", "--quiet", commitA], parent); + expect(ok.status).toBe(0); + }); + + it("precise: a checkout whose pin equals the dirty child's HEAD passes (sync would no-op)", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + git(["commit", "-m", "docs only"], parent); // same pin as previous commit + dirtyChild(child); // child at c1 == pin in BOTH commits + const ok = gitTry(["checkout", "--quiet", "HEAD~1"], parent); + expect(ok.status).toBe(0); + }); + + it("precise: catches the DRIFTED dirty child even when the pin is unchanged across the move", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + git(["commit", "-m", "docs only"], parent); // pin still c1 in both commits + childCommit(child, "c2"); // drift: child HEAD c2, pin (both commits) c1 + dirtyChild(child); + // pin-delta between the two parent commits is ZERO — a naive rule allows + // this; the sync would still try to move the dirty child back to c1. + const blocked = gitTry(["checkout", "--quiet", "HEAD~1"], parent); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/would re-pin/); + }); + + it("strict: any dirty child blocks a parent commit", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + git(["config", "--local", "embedded.guard", "strict"], parent); + dirtyChild(child); + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + const blocked = gitTry(["commit", "-m", "docs"], parent); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/uncommitted changes/); + }); + + it("strict: a clean child with a STALE pin blocks a parent commit until the pin is recorded", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + git(["config", "--local", "embedded.guard", "strict"], parent); + childCommit(child, "c2"); // clean, but pin (c1) is now stale + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + const blocked = gitTry(["commit", "-m", "docs without pin bump"], parent); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/not synced/); + + git(["add", "tests"], parent); // record the pin → now current + git(["commit", "-m", "docs + pin bump"], parent); + expect(git(["log", "--oneline", "-1"], parent)).toContain("pin bump"); + }); + + it("strict: a jump (checkout) with all children clean passes even though pins differ", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + const commitA = git(["rev-parse", "HEAD"], parent); + childCommit(child, "c2"); + git(["add", "tests"], parent); + git(["commit", "-m", "bump"], parent); + git(["config", "--local", "embedded.guard", "strict"], parent); + // commitA pins c1, child HEAD is c2 — clean, and a checkout is a jump, + // so the pins-current rule does not apply. + const ok = gitTry(["checkout", "--quiet", commitA], parent); + expect(ok.status).toBe(0); + }); + + it("off: dirty + drifted child blocks nothing", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + git(["config", "--local", "embedded.guard", "off"], parent); + childCommit(child, "c2"); + dirtyChild(child); + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + git(["commit", "-m", "docs"], parent); + const ok = gitTry(["checkout", "--quiet", "HEAD~1"], parent); + expect(ok.status).toBe(0); + }); + + it("precise: a spaced-path child that is dirty and would be re-pinned is blocked", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"], childPath: "my tests" }); + const commitA = git(["rev-parse", "HEAD"], parent); + childCommit(child, "c2"); + git(["add", "my tests"], parent); + git(["commit", "-m", "bump pin to c2"], parent); + dirtyChild(child); // child at c2; commitA pins c1 → re-pin + dirty + const blocked = gitTry(["checkout", "--quiet", commitA], parent); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/would re-pin/); + expect(blocked.stderr).toContain("my tests"); // full path, not split on the space + }); + + it("strict: a newly-initialized (unborn) child is skipped, not blocked", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + git(["config", "--local", "embedded.guard", "strict"], parent); + // Re-init the child so it has a valid but UNBORN HEAD (no commits yet). + fs.rmSync(path.join(child, ".git"), { recursive: true, force: true }); + git(["init", "-b", "main", child]); + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + git(["commit", "-m", "docs"], parent); // would throw if blocked + expect(git(["log", "--oneline", "-1"], parent)).toContain("docs"); + }); + + it("strict: a child with an unreadable/corrupt HEAD fails closed", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + git(["config", "--local", "embedded.guard", "strict"], parent); + // Corrupt HEAD so neither rev-parse nor symbolic-ref can resolve it. + fs.writeFileSync(path.join(child, ".git", "HEAD"), "not a ref and not a sha\n"); + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + const blocked = gitTry(["commit", "-m", "docs"], parent); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/cannot read HEAD/); + }); + + it("precise: a child with an unreadable HEAD is skipped (does not block)", () => { + const { parent, child } = makeGuardedParent({ hooks: ["reference-transaction"] }); + // precise is the default; corrupt the child HEAD. + fs.writeFileSync(path.join(child, ".git", "HEAD"), "not a ref and not a sha\n"); + fs.writeFileSync(path.join(parent, "README.md"), "v2"); + git(["add", "README.md"], parent); + git(["commit", "-m", "docs"], parent); // would throw if blocked + expect(git(["log", "--oneline", "-1"], parent)).toContain("docs"); + }); +}); + +describe.skipIf(process.platform === "win32")("pre-push pin-publication check", () => { + it("check (default): pushing a parent whose new pin IS on the child's origin passes", () => { + const { parent } = makeGuardedParent({ hooks: ["pre-push"] }); + git(["push", "--quiet", "origin", "main"], parent); // initial: pin c1 is on child origin + expect(git(["ls-remote", "origin", "main"], parent)).not.toBe(""); + }); + + it("check: a parent pinning a committed-but-UNPUSHED child commit is rejected, then passes after the child pushes", () => { + const { parent, child } = makeGuardedParent({ hooks: ["pre-push"] }); + git(["push", "--quiet", "origin", "main"], parent); + + childCommit(child, "c2"); // NOT pushed to child origin + git(["add", "tests"], parent); + git(["commit", "-m", "bump pin to unpublished c2"], parent); + + const blocked = gitTry(["push", "--quiet", "origin", "main"], parent); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/not on that child's origin/); + + git(["push", "--quiet", "origin", "main"], child); // publish the child + git(["push", "--quiet", "origin", "main"], parent); // now passes + const remoteTip = git(["ls-remote", "origin", "main"], parent).split(/\s/)[0]; + expect(remoteTip).toBe(git(["rev-parse", "HEAD"], parent)); + }); + + it("off: the same unpublished pin pushes without verification", () => { + const { parent, child } = makeGuardedParent({ hooks: ["pre-push"] }); + git(["config", "--local", "embedded.pushRecurse", "off"], parent); + git(["push", "--quiet", "origin", "main"], parent); + childCommit(child, "c2"); + git(["add", "tests"], parent); + git(["commit", "-m", "bump"], parent); + git(["push", "--quiet", "origin", "main"], parent); // would throw if blocked + }); + + it("a clone WITHOUT restored children can push commits that touch no pin", () => { + const { parent, parentBare } = makeGuardedParent({ hooks: [] }); + git(["push", "--quiet", "origin", "main"], parent); + const bareClone = path.join(mkTmp(), "clone"); + git(["clone", "--quiet", parentBare, bareClone]); + installHook(bareClone, "pre-push"); // gitlink dir is empty — no child repo + fs.writeFileSync(path.join(bareClone, "README.md"), "docs from machine B"); + git(["add", "README.md"], bareClone); + git(["commit", "-m", "docs"], bareClone); + git(["push", "--quiet", "origin", "main"], bareClone); // would throw if blocked + }); + + it("a pin CHANGE for a child that is not present locally is rejected (cannot verify)", () => { + const { parent, parentBare, child } = makeGuardedParent({ hooks: [] }); + git(["push", "--quiet", "origin", "main"], parent); + const c2 = childCommit(child, "c2"); + git(["push", "--quiet", "origin", "main"], child); // even published — can't VERIFY locally + const bareClone = path.join(mkTmp(), "clone"); + git(["clone", "--quiet", parentBare, bareClone]); + installHook(bareClone, "pre-push"); + // Hand-craft a pin bump without a child repo present. + git(["update-index", "--add", "--cacheinfo", `160000,${c2},tests`], bareClone); + git(["commit", "-m", "blind pin bump"], bareClone); + const blocked = gitTry(["push", "--quiet", "origin", "main"], bareClone); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/not present/); + }); + + it("on-demand: an unpublished pin is auto-published by pushing the child branch, then the parent push passes", () => { + const { parent, child } = makeGuardedParent({ hooks: ["pre-push"] }); + git(["config", "--local", "embedded.pushRecurse", "on-demand"], parent); + git(["push", "--quiet", "origin", "main"], parent); // initial: pin c1 already published + + const c2 = childCommit(child, "c2"); // committed but NOT pushed to the child's origin + git(["add", "tests"], parent); + git(["commit", "-m", "bump pin to c2 (unpublished)"], parent); + + // on-demand publishes the child's current branch (main, which contains c2) + // as a side effect, so the parent push is then allowed. + git(["push", "--quiet", "origin", "main"], parent); // would throw if blocked + + // The child's c2 was pushed to its origin by the hook. + expect(git(["ls-remote", "origin", "main"], child).split(/\s/)[0]).toBe(c2); + // And the parent push landed. + expect(git(["ls-remote", "origin", "main"], parent).split(/\s/)[0]).toBe(git(["rev-parse", "HEAD"], parent)); + }); + + it("check: a child whose gitlink path contains spaces is verified correctly (published pin passes)", () => { + const { parent } = makeGuardedParent({ hooks: ["pre-push"], childPath: "my tests" }); + // Pin c1 is already on the child's origin. The old path-first serialization + // misparsed "my tests" into path "my" and wrongly rejected this push. + git(["push", "--quiet", "origin", "main"], parent); // would throw if blocked + expect(git(["ls-remote", "origin", "main"], parent)).not.toBe(""); + }); + + it("check: an unpublished pin for a spaced-path child is rejected naming the full path", () => { + const { parent, child } = makeGuardedParent({ hooks: ["pre-push"], childPath: "my tests" }); + git(["push", "--quiet", "origin", "main"], parent); + childCommit(child, "c2"); // committed, NOT pushed to the child's origin + git(["add", "my tests"], parent); + git(["commit", "-m", "bump pin to unpublished c2"], parent); + const blocked = gitTry(["push", "--quiet", "origin", "main"], parent); + expect(blocked.status).not.toBe(0); + // Old bug: misparsed to path "my" → "not present"; fixed: real path + cause. + expect(blocked.stderr).toMatch(/not on that child's origin/); + expect(blocked.stderr).toContain("my tests"); + }); + + it("check: a child whose gitlink path STARTS with a space passes when its pin is published (leading-space edge)", () => { + const { parent } = makeGuardedParent({ hooks: ["pre-push"], childPath: " leading" }); + // Pin c1 is already on the child's origin, so this push must pass. A plain + // `read -r pin path` re-read strips the LEADING space, so verify_pin would + // look up "leading" (which doesn't exist) and falsely reject; the whole-line + // read preserves " leading" and the published pin verifies. + git(["push", "--quiet", "origin", "main"], parent); // would throw if blocked + expect(git(["ls-remote", "origin", "main"], parent)).not.toBe(""); + }); + + it("check: a non-fast-forward push re-verifies the whole tip (a pin unchanged in the range but now unreachable is caught)", () => { + const { parent, child } = makeGuardedParent({ hooks: ["pre-push"] }); + const c1 = git(["rev-parse", "HEAD"], child); + // Publish c2, pin it, and land it on the parent's origin (verified new-ref push). + childCommit(child, "c2"); + git(["push", "--quiet", "origin", "main"], child); // publish c2 + git(["add", "tests"], parent); + git(["commit", "-m", "M: pin c2"], parent); + const M = git(["rev-parse", "HEAD"], parent); + fs.writeFileSync(path.join(parent, "README.md"), "y"); + git(["add", "README.md"], parent); + git(["commit", "-m", "Y"], parent); // P1 = M -> Y (tests@c2, verified) + git(["push", "--quiet", "origin", "main"], parent); + // The child's origin now loses c2 (rewound to c1) — c2 is unreachable there again. + git(["push", "--quiet", "--force", "origin", `${c1}:main`], child); + // Diverge the parent from M with a commit that does NOT touch the pin. + git(["reset", "--hard", M], parent); + git(["commit", "--allow-empty", "-m", "Z: diverged, pin unchanged"], parent); // P2 = M -> Z + // Non-fast-forward push: tests@c2 is unchanged in P1..P2 (so the diff-only pass + // misses it) and no longer on the child's origin — the full-tip pass must reject. + const blocked = gitTry(["push", "--quiet", "--force", "origin", "main"], parent); + expect(blocked.status).not.toBe(0); + expect(blocked.stderr).toMatch(/not on that child's origin/); + }); +}); diff --git a/tests/install-hooks.test.mjs b/tests/install-hooks.test.mjs index 93bfbf6..2b3e558 100644 --- a/tests/install-hooks.test.mjs +++ b/tests/install-hooks.test.mjs @@ -39,7 +39,7 @@ beforeAll(async () => { }); describe("api.install.hooks", () => { - it("installs the four package hooks and skips foreign existing files", async () => { + it("installs the package hooks and skips foreign existing files", async () => { const gitDir = mkTmp(); fs.mkdirSync(path.join(gitDir, "hooks")); // Pre-existing foreign hook should be left alone. @@ -51,11 +51,12 @@ describe("api.install.hooks", () => { expect(installed).toContain("post-merge"); expect(installed).toContain("post-rewrite"); expect(installed).toContain("reference-transaction"); + expect(installed).toContain("pre-push"); expect(installed).not.toContain("post-checkout"); const skipped = Array.from(out.skipped).map((s) => s.name); expect(skipped).toContain("post-checkout"); - for (const name of ["post-merge", "post-rewrite", "reference-transaction"]) { + for (const name of ["post-merge", "post-rewrite", "reference-transaction", "pre-push"]) { const body = fs.readFileSync(path.join(gitDir, "hooks", name), "utf8"); expect(body.startsWith("#!/usr/bin/env bash")).toBe(true); expect(body).toContain("git-embedded"); @@ -77,7 +78,7 @@ describe("api.install.hooks", () => { await api.install.hooks("install", gitDir); const entries = await api.log.read(); const ours = entries.filter((e) => e.op === "install-repo-hook" && e.path.startsWith(gitDir)); - expect(ours.length).toBe(4); + expect(ours.length).toBe(5); expect(fs.existsSync(api.log.path())).toBe(true); }); }); @@ -89,7 +90,7 @@ describe("api.install.hooks uninstall", () => { fs.writeFileSync(path.join(gitDir, "hooks", "pre-commit"), "#!/bin/sh\necho foreign\n"); const out = await api.install.hooks("uninstall", gitDir); const removed = Array.from(out.removed); - for (const hook of ["post-checkout", "post-merge", "post-rewrite", "reference-transaction"]) { + for (const hook of ["post-checkout", "post-merge", "post-rewrite", "reference-transaction", "pre-push"]) { expect(removed).toContain(hook); } expect(fs.existsSync(path.join(gitDir, "hooks", "pre-commit"))).toBe(true);