Skip to content

Four seams that reported a pass over something they never looked at - #4

Merged
HackingGate merged 4 commits into
mainfrom
fix/pre-push-seams
Aug 12, 2026
Merged

Four seams that reported a pass over something they never looked at#4
HackingGate merged 4 commits into
mainfrom
fix/pre-push-seams

Conversation

@HackingGate

@HackingGate HackingGate commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Four defects, each reproduced against a built binary before the fix and each
covered by a test that fails without it. Three of the four are the same failure
this codebase writes essays about elsewhere: a check that could not look, or did
not look, reporting as one that did.

They arrived together from a review of the engine, so they are one branch, but
the commits are independent and can be split if that reads better.

guard::scope::keep_blobs deadlocked on any push past ~1,500 objects

It wrote every candidate sha into git cat-file --batch-check and only drained
stdout afterwards. --batch-check answers each object as it reads it, so it
fills its 64 KiB stdout pipe and stops reading stdin; the parent is blocked on a
full stdin pipe, the child on a stdout pipe nobody is draining. git push
stops, with no output and no exit code.

Measured boundary: 1,503 objects completes, 2,003 hangs. Every tree-wide guard
at pre-push reaches it in a repository of any age.

audit::blob_shas and selection::not_text_paths each grew a writer thread to
avoid this, and audit.rs even names the thirteen-hundredth object in a
comment. Two call sites learned it and the third kept the version that hangs,
which is why this is one shared git::blob_shas rather than a third copy of
the fix.

prevent-ai-author and prevent-unusual-unicode judged the wrong message

message_text never consulted the stage, so both read .git/COMMIT_EDITMSG
everywhere. Under git commit that is the right file, which is what made the
mistake survivable and therefore permanent. At pre-push it is whatever the last
commit wrote.

A marker recorded by git commit-tree, a rebase, a cherry-pick, git am,
--no-verify, or a fast-forward out of a hookless clone therefore reached the
remote with 1 guard(s) passed, exit 0. It failed the other way too: a stale
COMMIT_EDITMSG from a refused attempt refused a push that published nothing
wrong.

no-private-repo-names-in-files already reads the pushed range for exactly this
reason. These two now use the same scope::pushed_messages, and a finding names
the commit instead of a path the guard never opened.

A not-text path was read as a glob, not a name

overrides_for handed each git check-attr answer to the glob builder as
!{path}. Those are literal filenames, and three characters turned one into
something else:

  • data{1,2}.bin became an alternation, so data1.bin and data2.bin — neither
    declared — were dropped from every content rule and named in no report.
  • page[1].html became a character class that does not match its own name, so a
    file declared not-text was searched and listed as skipped in the same
    output. That is the invented finding selection's own doc comment says must
    not happen.
  • capture[1.bin is an unclosed class: a parse error that took the whole run to
    exit 2 with nothing reported.

No such filename exists in the fleet today, which is the only reason this is
third rather than first.

Scoping a guard killed the content scan for the whole repository

[rule.files] on a guard built-in is the supported way to narrow one —
guard::scope::in_file_scope reads it, and config deliberately exempts
built-ins from the refusal that would reject the keys. scan aborted on it
anyway, exit 2 for the whole tree, with a diagnosis that was not true of the
rule it named: the keys are not read by nothing, they are read at the other
seam.

git.hooks decides which seam owns the rule. A guard that names a hook runs
there; one that names no hook is run by nothing at either seam, so the refusal
still stands for that case — passing over it would report a check that did not
happen as one that did.

Verification

  • cargo test — 262 tests, all suites green. Nine new, and each was confirmed to
    fail against the code before its fix.
  • The deadlock test asks about 4000 objects on a thread with a 60s deadline, for
    the reason selection's existing one gives: a test that proves a deadlock is
    gone has to fail when it is not, and one that hangs names nothing.
  • cargo clippy --all-targets clean under the crate's own profile;
    cargo fmt --check clean.
  • python3 -m pytest tests/ — 110 passed, 227 subtests.
  • The repository's own hooks pass on every commit here, and uphold scan over
    this tree is clean.

Docs: the prevent-ai-author and prevent-unusual-unicode rows in
docs/REFERENCE.md said "a commit message" and now say which ones.

Summary by CodeRabbit

  • New Features

    • Pre-push message checks now review every commit message included in the push.
    • Improved handling of filenames containing glob characters during scans.
    • Content scans now correctly respect hook-scoped rules.
  • Bug Fixes

    • Prevented incorrect file exclusions and scan failures for unusual filenames.
    • Improved diagnostics for rules configured without a supported hook.
    • Added reliable Git object handling for large sets of files.
  • Documentation

    • Updated the guard reference to clarify pre-push message coverage.

`keep_blobs` wrote every candidate sha into `git cat-file --batch-check` and
only drained stdout afterwards. `--batch-check` answers each object as it reads
it, so it fills its 64 KiB stdout pipe somewhere past the fifteen-hundredth and
stops reading stdin; the parent is then blocked writing to a full stdin pipe
while the child is blocked writing to a stdout pipe nobody is draining. Neither
moves again -- no output, no exit code, `git push` simply stops. Measured
boundary: 1,503 objects completes, 2,003 hangs, and the guards that run at
pre-push are the tree-wide ones every repository past that size reaches.

`audit::blob_shas` and `selection::not_text_paths` each grew a writer thread to
avoid exactly this, and audit.rs carries a comment naming the thirteen-hundredth
object. Two of the three call sites learned it and the third kept the version
that hangs, which is the argument for one copy rather than a third fix: the
pumping, the closed stdin and the exit-status refusal now live in
`git::blob_shas`, and both callers ask it.

Covered by `git::tests::several_thousand_objects_are_asked_about_without_deadlocking`,
which asks about 4000 objects on a thread with a deadline -- a test that proves
a deadlock is gone has to fail when it is not, and one that hangs names nothing.
`message_text` never consulted the stage, so `prevent-ai-author` and
`prevent-unusual-unicode` read `.git/COMMIT_EDITMSG` at every stage they run at.
Under `git commit` that is the right file, which is what made the mistake
survivable and therefore permanent. At `pre-push` it is whatever the last commit
happened to write -- usually clean, and never the commits being pushed.

So a marker recorded by `git commit-tree`, a rebase, a cherry-pick, `git am`,
`--no-verify`, or a fast-forward carrying somebody else's commit in from a
hookless clone reached the remote with the guard reporting `1 guard(s) passed`,
exit 0. It failed in the other direction too: a stale `COMMIT_EDITMSG` left by a
refused attempt refused a push that published nothing wrong.

`no-private-repo-names-in-files` already reads the pushed range for this exact
reason, and `scope::pushed_messages` is the function it uses. These two guards
were the ones still asking the wrong file; they ask that one now, and a finding
names the commit rather than a path the guard never opened.

Three tests, each failing before this: a marker in a pushed commit behind a
later clean one, an invisible character in the same shape, and the inverse where
a stale `COMMIT_EDITMSG` must not refuse a clean push.
`overrides_for` handed each `git check-attr` answer to the glob builder as
`!{path}`. The globs above it are author-written and their metacharacters are
meant; these are literal filenames, and three different characters turned one
into something else.

`data{1,2}.bin` became an alternation, so `data1.bin` and `data2.bin` -- neither
declared anything -- were removed from every content rule in the policy and
named in no report. `page[1].html` became a character class that does not match
its own literal name, so a file declared not-text was searched AND listed as
skipped in the same output, which is the invented finding this module's own doc
comment says must not happen. `capture[1.bin` is an unclosed class: a parse
error that took the whole run to exit 2 with no rule having reported anything.

`globset::escape` before the `!`. Two tests, one per direction: that a declared
path with metacharacters excludes only itself, and that an unclosed class in a
filename is not a malformed glob.
`[rule.files]` on a guard built-in is the supported way to narrow one --
`guard::scope::in_file_scope` reads it, and `config` exempts built-ins from the
refusal that would otherwise reject the keys. The scan aborted on it anyway,
returning exit 2 for the whole repository with a diagnosis that was not true of
the rule it named: the keys are not read by nothing, they are read at the other
seam. Scoping one guard switched off every content rule in the policy.

`git.hooks` answers which seam owns the rule. A guard built-in that names a hook
runs there and this scan is not its seam to fail from; one that names no hook is
run by nothing at either seam, so its `files.*` really is read by nothing and
the refusal stands -- passing over that would report a check that did not happen
as one that did.

Both sides are tested: a scoped guard beside a pattern rule now reports the
pattern rule's finding, and a guard built-in with `files.*` and no hook is still
exit 2.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change centralizes Git blob lookup, expands message guards to pre-push commit ranges, updates scan handling for hook rules, and escapes not-text paths before creating exclusions. Documentation and regression tests cover the updated behavior.

Changes

Guard and scanning behavior

Layer / File(s) Summary
Shared Git blob lookup
src/git.rs, src/audit.rs, src/guard/scope.rs
blob_shas performs concurrent Git batch I/O, handles failures and empty input, and replaces duplicate blob lookup implementations. Tests cover 4,000 objects and empty input.
Pre-push message guards
src/guard/message.rs, tests/guard_recovered_halves.rs, docs/REFERENCE.md
Pre-push guards inspect all pushed commit messages. Attribution and Unicode checks return the first refusal. Fixtures and reference text describe the behavior.
Repository scan guard scope
src/scan.rs, tests/scan_cli.rs
Repository scans skip built-in rules associated with hooks and report a policy error for built-ins with no file or hook behavior.
Literal not-text path selection
src/selection.rs
Not-text paths are escaped as literal glob patterns. Tests cover alternation, character classes, and unclosed classes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PrePushHook
  participant message_subjects
  participant PushedCommits
  participant MessageGuards
  PrePushHook->>message_subjects: collect messages for pushed refs
  message_subjects->>PushedCommits: read commit messages
  PushedCommits-->>message_subjects: return labeled messages
  message_subjects-->>MessageGuards: provide selected messages
  MessageGuards-->>PrePushHook: return first refusal or success
Loading

Possibly related PRs

  • HackingGate/uphold#3: Adds related pre-push commit-message handling and shared Git blob-scanning changes.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title uses a metaphor and does not identify the four defects or the affected pre-push and scan behavior. Use a concise title that names the primary fixes, such as pre-push message guards, blob deadlock handling, path escaping, and guard scan scoping.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pre-push-seams

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.44262% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.19%. Comparing base (0a2da20) to head (934aeb5).

Files with missing lines Patch % Lines
src/git.rs 90.17% 11 Missing ⚠️
src/guard/message.rs 96.42% 1 Missing ⚠️

❌ Your patch status has failed because the patch coverage (93.44%) is below the target coverage (100.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files
@@            Coverage Diff             @@
##             main       #4      +/-   ##
==========================================
+ Coverage   86.01%   87.19%   +1.17%     
==========================================
  Files          22       22              
  Lines        6527     6605      +78     
==========================================
+ Hits         5614     5759     +145     
+ Misses        913      846      -67     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/selection.rs (1)

676-714: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the closed character-class case.

This test declares only data{1,2}.bin. The comments describe page[1].html, but the test does not create or assert that path. Add page[1].html and a near-match such as page1.html. Verify that only the declared path is excluded.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/selection.rs` around lines 676 - 714, Extend the test
a_not_text_path_holding_glob_metacharacters_excludes_only_itself to create
page[1].html as a declared not-text path and page1.html as an undeclared
near-match, then assert the declared literal path is excluded while page1.html
remains selected. Keep the existing data{1,2}.bin and other undeclared-path
assertions intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/selection.rs`:
- Around line 243-255: Replace globset::escape in the not_text path handling
with gitignore-compatible escaping that preserves trailing whitespace and
escapes backslashes before passing the pattern to OverrideBuilder::add. Update
the relevant selection tests to cover literal paths ending in spaces and
backslashes, ensuring they neither trim nor produce DanglingEscape errors.

---

Nitpick comments:
In `@src/selection.rs`:
- Around line 676-714: Extend the test
a_not_text_path_holding_glob_metacharacters_excludes_only_itself to create
page[1].html as a declared not-text path and page1.html as an undeclared
near-match, then assert the declared literal path is excluded while page1.html
remains selected. Keep the existing data{1,2}.bin and other undeclared-path
assertions intact.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b9a9faca-ad17-4cf6-b92f-7119111a7ed3

📥 Commits

Reviewing files that changed from the base of the PR and between 0a2da20 and 934aeb5.

📒 Files selected for processing (9)
  • docs/REFERENCE.md
  • src/audit.rs
  • src/git.rs
  • src/guard/message.rs
  • src/guard/scope.rs
  • src/scan.rs
  • src/selection.rs
  • tests/guard_recovered_halves.rs
  • tests/scan_cli.rs

Comment thread src/selection.rs
Comment on lines +243 to +255
// Escaped, because these are PATHS and not patterns. The globs above are
// author-written and their metacharacters are meant; these come back from
// `git check-attr` and are literal names, so a tracked file called
// `page[1].html` or `data{1,2}.bin` was read as a character class or an
// alternation. Each way it went wrong is worse than the last: the class did
// not match its own name, so a file declared not-text was searched AND
// listed as skipped in the same report; the alternation matched two files
// nobody declared, removing them from every rule silently; and an unclosed
// class was a parse error that took the whole run to exit 2.
for path in not_text {
let literal = globset::escape(path);
builder
.add(&format!("!{path}"))
.add(&format!("!{literal}"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 3 'name = "(ignore|globset)"|ignore\s*=|globset\s*=' \
  Cargo.toml Cargo.lock || true

rg -n -C 6 'not_text_paths|globset::escape|OverrideBuilder' --glob '*.rs' .

Repository: HackingGate/uphold

Length of output: 7170


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- selection.rs ---'
sed -n '220,260p' src/selection.rs

printf '%s\n' '--- local cargo sources ---'
find "${CARGO_HOME:-$HOME/.cargo}/registry/src" -maxdepth 3 -type f \
  \( -path '*/ignore-0.4.33/src/overrides.rs' -o -path '*/globset-0.4.20/src/lib.rs' \) \
  -print 2>/dev/null || true

printf '%s\n' '--- pinned dependency source references ---'
curl -fsSL https://raw.githubusercontent.com/BurntSushi/ripgrep/ignore-0.4.33/ignore/src/overrides.rs \
  | sed -n '1,260p' || true
curl -fsSL https://raw.githubusercontent.com/BurntSushi/ripgrep/globset-0.4.20/src/lib.rs \
  | rg -n -C 8 'pub fn escape|fn escape' || true

Repository: HackingGate/uphold

Length of output: 2521


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

curl -fsSL https://crates.io/api/v1/crates/ignore/0.4.33/download \
  -o "$tmp/ignore.tgz"
curl -fsSL https://crates.io/api/v1/crates/globset/0.4.20/download \
  -o "$tmp/globset.tgz"

tar -xzf "$tmp/ignore.tgz" -C "$tmp"
tar -xzf "$tmp/globset.tgz" -C "$tmp"

printf '%s\n' '--- ignore overrides parser ---'
rg -n -C 8 'pub fn add|trim|trailing|backslash|escape' \
  "$tmp/ignore-0.4.33/src/overrides.rs" \
  "$tmp/ignore-0.4.33/src/gitignore.rs"

printf '%s\n' '--- globset escape implementation ---'
rg -n -C 12 'pub fn escape|fn escape' \
  "$tmp/globset-0.4.20/src"

printf '%s\n' '--- relevant tests ---'
rg -n -C 6 'trailing|backslash|escape|space|OverrideBuilder' \
  "$tmp/ignore-0.4.33/src" "$tmp/globset-0.4.20/src" | head -n 240

Repository: HackingGate/uphold

Length of output: 207


🌐 Web query:

ignore 0.4.33 overrides.rs OverrideBuilder add trailing whitespace backslash source

💡 Result:

There is no functionality in the ignore crate's OverrideBuilder (as found in overrides.rs) to "add back" trailing whitespace or interpret trailing backslashes specifically for override globs [1][2][3]. The OverrideBuilder provides a mechanism to specify override globs for file matching, with semantics identical to a line in a.gitignore file [4][5][2]. In this context: 1. Override Globs: The add method in OverrideBuilder simply adds a glob string to an underlying GitignoreBuilder [1][2][3]. These globs follow standard gitignore rules, where whitespace and backslashes are generally treated as literal parts of the pattern or path, unless they have special shell-like or glob-specific meaning (e.g., escaping a character) [5][2]. 2. Trailing Backslashes: In standard glob patterns (and gitignore files), a trailing backslash can be used to escape a character. If you intended to imply a "line continuation" or similar behavior often found in other contexts (like CommonMark or shell scripts), it is not a documented feature of the ignore crate's glob parsing [5][6]. 3. Trailing Whitespace: Because these globs are matched against file paths, trailing whitespace in a glob pattern is treated literally as part of the path matching criteria [5][2]. If your environment (such as an IDE or editor) strips trailing whitespace from your source code, you may need to use escape sequences like \x20 in your Rust string literal to ensure the whitespace is preserved in the compiled binary [7]. If you are encountering issues with trailing whitespace being stripped, it is likely occurring at the editor or source-control level rather than within the OverrideBuilder logic itself [7]. You can bypass this by using character escapes in your Rust code [7].

Citations:


🌐 Web query:

globset 0.4.20 escape source implementation

💡 Result:

In the globset crate (version 0.4.20), the escape function is designed to sanitize glob patterns by wrapping meta-characters in bracket expressions [1][2]. The implementation works by iterating through the input string and identifying characters that have special meaning in glob syntax [1]. When one of these characters is encountered, it is surrounded by square brackets to force the glob parser to treat it as a literal character [1][3]. The characters targeted by this function are:? * [ ] { } The source code implementation is as follows [1]: pub fn escape(s: &str) -> String { let mut escaped = String::with_capacity(s.len); for c in s.chars { match c { '?' | '*' | '[' | ']' | '{' | '}' => { escaped.push('['); escaped.push(c); escaped.push(']'); } c => { escaped.push(c); } } } escaped } Note that the exclamation mark (!) is not escaped by this function because it is only considered a meta-character when used inside brackets for character classes [1]. This escaping mechanism was originally adopted from the glob crate to provide a consistent way to handle literals [4]. In addition to this function, the GlobBuilder struct provides a backslash_escape method, which can be toggled to allow backslashes to escape meta-characters, depending on the platform's conventions [5][6].

Citations:


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

for url in \
  'https://docs.rs/crate/ignore/0.4.33/source/src/overrides.rs' \
  'https://docs.rs/crate/ignore/0.4.33/source/src/gitignore.rs' \
  'https://docs.rs/crate/globset/0.4.20/source/src/lib.rs' \
  'https://raw.githubusercontent.com/BurntSushi/ripgrep/57c190d5/crates/ignore/src/overrides.rs' \
  'https://raw.githubusercontent.com/BurntSushi/ripgrep/57c190d5/crates/ignore/src/gitignore.rs' \
  'https://raw.githubusercontent.com/BurntSushi/ripgrep/57c190d5/crates/globset/src/lib.rs'
do
  name="$(printf '%s' "$url" | tr '/:' '__')"
  curl -fsSL "$url" -o "$tmp/$name" || true
done

printf '%s\n' '--- fetched files ---'
find "$tmp" -type f -printf '%f %s bytes\n'

printf '%s\n' '--- parser implementation references ---'
rg -n -C 12 'pub fn add|add_line|trim|trailing|backslash|escape' "$tmp" \
  | head -n 400

Repository: HackingGate/uphold

Length of output: 50374


Encode gitignore metacharacters for literal paths.

ignore 0.4.33 trims trailing whitespace in OverrideBuilder::add. It also enables backslash escaping. globset::escape does not escape backslashes. Therefore, a path such as payload.bin is reduced to payload.bin, while a path ending in \ causes a DanglingEscape error. Encode paths with gitignore-compatible escaping and add regression tests for trailing spaces and backslashes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/selection.rs` around lines 243 - 255, Replace globset::escape in the
not_text path handling with gitignore-compatible escaping that preserves
trailing whitespace and escapes backslashes before passing the pattern to
OverrideBuilder::add. Update the relevant selection tests to cover literal paths
ending in spaces and backslashes, ensuring they neither trim nor produce
DanglingEscape errors.

@HackingGate
HackingGate merged commit 7549745 into main Aug 12, 2026
12 checks passed
@HackingGate
HackingGate deleted the fix/pre-push-seams branch August 12, 2026 11:51
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