Skip to content

feat: git-native commit guard — check mode, pre-commit hook, install walkthrough#1

Open
flo-kn wants to merge 4 commits into
mainfrom
test/gitignore-symlink-guard
Open

feat: git-native commit guard — check mode, pre-commit hook, install walkthrough#1
flo-kn wants to merge 4 commits into
mainfrom
test/gitignore-symlink-guard

Conversation

@flo-kn

@flo-kn flo-kn commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Closes #2.

Why

The convention rests on data folders never becoming committable. The subtle failure: after linking, a data folder is a symlink, and a .gitignore pattern with a trailing slash (data/) matches directories only — it silently stops ignoring the symlink, letting a path into private cloud storage get committed (caught by our first external user in review).

The first iteration of this PR guarded that with a test wired into consumer CI. Per the platform-independence principle (ARCHITECTURE.md, #3) and the resolution of #2, enforcement is now git-native by default, with CI as an optional, explicitly-opted-in extra.

What

  • setup-data.sh check — the single canonical guard: exits non-zero if any .datadirs folder is unignored, tracked, or staged. Runs everywhere including CI (unlike link mode). Every enforcement option below just runs this one command.
  • install-hooks.sh — now installs a pre-commit hook running the check (blocks the leak before anything leaves the machine) plus the existing post-checkout repair hook. Hooks it didn't write (husky/lefthook…) are left alone; the line to add manually is printed.
  • install.sh walkthrough — three options, interactive when a terminal is available, --enforce= flag otherwise:
    1. hooks — pre-commit hook (recommended; also the non-interactive default)
    2. ci-github / ci-gitlab — writes a CI recipe only on explicit opt-in; the recipe just runs the check
    3. none — DIY; the canonical check is printed
      The consumer test vendoring from the first iteration is dropped — the test suite is a development artifact of this repo.
  • README — new "Guarding against accidental commits" section: why the leak vector exists, the recommended default, and when CI is genuinely preferred (teams — hooks are per-machine and --no-verify-skippable; CI behind branch protection is the only guard an owner can require — plus fresh clones/ephemeral environments). Includes both CI snippets for manual copy.
  • test-setup-data.sh — 7 → 15 assertions: check pass/fail/recover (incl. the trailing-slash regression), hook install, hook blocking a leaking commit end-to-end, letting a clean commit through, leaving foreign hooks alone.
  • .github/workflows/ci.yaml — shellcheck + the test suite for this repo (exempt per ARCHITECTURE.md: it tests the tool's development).

Verification

  • shellcheck ./*.sh clean; ./test-setup-data.sh15 passed, 0 failed.
  • Fresh-repo e2e for all four --enforce modes: correct files/hooks written in each, no prompt hang without a tty (defaults to hooks).
  • Mutation check (earlier iteration): removing the gitignore-refusal guard turns the suite red.

The convention rests on data folders being unignorable-proof: after
setup-data.sh links a folder it is a symlink, and a ".gitignore" pattern
with a trailing slash ("name/") matches directories only — it silently
stops ignoring the symlink, so the link into PII-bearing cloud storage
can get committed. install.sh already writes the no-slash form; nothing
tested it.

Add test-setup-data.sh covering:
  - no-slash pattern keeps the data symlink ignored;
  - trailing-slash pattern leaks it (negative control);
  - setup-data.sh refuses a folder that isn't gitignored;
  - the happy-path link lands in the sync root and stays ignored.

Vendor the test via install.sh so every consumer inherits the guard, and
add a CI workflow (shellcheck + test). Raised by the first external user.
@flo-kn

flo-kn commented Jul 16, 2026

Copy link
Copy Markdown
Owner Author

Holding this open pending #2.

Design issue surfaced after opening: two parts of this PR couple the protection story to a CI platform, which violates the platform-independence principle now written down in ARCHITECTURE.md (#3) — the tool may assume git, nothing else.

  • The test itself is fine and stays: portable POSIX shell + git, and it correctly pins the symlink/no-trailing-slash invariant.
  • The install.sh vendoring of the test into consumer repos is the questionable layer: the test is a development artifact of this tool; consumers likely need a small git-native runtime check (pre-commit hook) instead — CI also only runs after push, which for a public repo is after the leak.
  • The CI workflow for this repo is exempt per ARCHITECTURE.md (it tests the tool's development).

Depending on how #2 resolves, the vendoring part may be walked back in favor of extending install-hooks.sh with a pre-commit guard.

…walkthrough

Resolves the enforcement design question (#2): the guard against
accidentally committing data folders must not depend on a CI platform.

- setup-data.sh gains a `check` mode: exits non-zero if any .datadirs
  folder is unignored, tracked, or staged. This is the single canonical
  check — every enforcement option just runs it. Unlike link mode it
  does not skip when CI is set.
- install-hooks.sh now installs a pre-commit hook running the check
  (blocks the leak before anything leaves the machine) alongside the
  existing post-checkout repair hook, and refuses to overwrite hooks it
  didn't write (husky/lefthook etc. — prints the line to add instead).
- install.sh walks the user through three guard options: pre-commit
  hook (recommended default, also the non-interactive fallback), a CI
  check (GitHub Actions or GitLab CI recipe, written only on explicit
  opt-in), or none. Flags: --enforce=hooks|ci-github|ci-gitlab|none.
  The consumer test vendoring is dropped — the test suite is a
  development artifact of this repo, not a consumer artifact.
- README gains "Guarding against accidental commits": why the symlink/
  trailing-slash leak exists, the recommended default, and when a CI
  check is genuinely preferred (teams, branch protection, fresh clones).
- test-setup-data.sh grows from 7 to 15 assertions: check-mode pass/
  fail/recover, hook installation, hook blocking a leaking commit,
  letting a clean one through, and leaving foreign hooks alone.
@flo-kn flo-kn changed the title test: pin the symlink gitignore invariant, vendor it downstream feat: git-native commit guard — check mode, pre-commit hook, install walkthrough Jul 16, 2026
@flo-kn

flo-kn commented Jul 16, 2026

Copy link
Copy Markdown
Owner Author

Reworked per the resolution of #2 — enforcement is now git-native by default (pre-commit hook running the new setup-data.sh check), CI is an explicit opt-in recipe, and the consumer test-vendoring is dropped. PR body rewritten to match; the hold from the earlier comment is lifted.

Review feedback from a live install walkthrough.

- install.sh: the guard prompt asked "GitHub Actions check" and "GitLab
  CI check" as sibling top-level options, mixing two decision levels.
  Now: 1) hook 2) CI check 3) skip, with a platform sub-prompt (GitHub/
  GitLab) only after choosing CI. The --enforce=ci-github|ci-gitlab
  flags are unchanged for non-interactive use.
- test-e2e-docker.sh + test/: end-to-end install test on a clean
  machine. test-setup-data.sh checks the pieces; this checks the
  experience. The container holds what a real user has — git, a sync
  client, and a project whose private docs sit in a folder gitignored
  the old directory-only way — and the test asserts the whole outcome:
  documents migrate into the sync folder, the folder returns as an
  ignored symlink, the app still reads through it, the gitignore gains
  the no-slash entry, and the pre-commit hook blocks a leak. A second
  scenario drives the interactive walkthrough through a pty, covering
  the new sub-prompt. Skips cleanly where Docker is unavailable.
- README: explain why scripts/ is committed (fresh clones and the hooks
  depend on it; it pins the reviewed version), and note --no-verify as
  the hook's by-design gap that the CI option covers.
- CI: shellcheck test/*.sh too; new e2e job.

The e2e caught a real bug on its first run: without PROJECT_DATA_SRC the
installer vendored setup-data.sh from main, so the test exercised
published code instead of the working tree.
@flo-kn

flo-kn commented Jul 16, 2026

Copy link
Copy Markdown
Owner Author

Updated from a live install walkthrough:

  • Two-level guard prompt. GitHub Actions check / GitLab CI check were siblings of hook, mixing two decision levels. Now 1) hook 2) CI check 3) skip, with the platform (GitHub/GitLab) as a sub-prompt only after choosing CI. --enforce=ci-github|ci-gitlab unchanged.
  • New test-e2e-docker.sh. test-setup-data.sh checks the pieces; this checks the experience — a container with git, a sync client, and a project whose private docs sit in a folder gitignored the old directory-only way. Asserts docs migrate, the symlink returns ignored, the app still reads through it, gitignore gains the no-slash entry, and the hook blocks a leak. A second scenario drives the walkthrough through a pty. 19 assertions, skips cleanly without Docker.
  • README: why scripts/ is committed, and --no-verify named as the hook's by-design gap that CI covers.

The e2e earned its keep immediately — first run caught that without PROJECT_DATA_SRC the installer vendors from main, i.e. the test would have exercised published code rather than the PR.

Review of this PR found the guard could fail open — silently.

install-hooks.sh reconstructed the hooks directory instead of asking
git. Two real setups broke:

- A *relative* core.hooksPath (exactly how husky v8+ and lefthook
  configure things — the tools this script claims to coexist with) is
  resolved by git against the repo root, but the script resolved it
  against the current directory. Running it from a subdirectory wrote
  the hook to a path git never reads, printed "pre-commit hook
  installed", and left the repo unguarded. Reproduced: a data symlink
  was then committed with no block.
- In a git worktree .git is a file, so "$repo_root/.git/hooks" is not a
  directory and mkdir -p aborts.

`git rev-parse --git-path hooks` answers both correctly (verified: it
returns the shared hooks dir as an absolute path from inside a
worktree), so ask git instead of guessing.

Also from the review:
- setup-data.sh: pass "--" before pathnames in check-ignore/ls-files, so
  a folder named like an option (-data) is treated as a path. It failed
  closed before (permanently un-passable check, plus raw git usage text
  leaking to the terminal), but it was still wrong.
- install.sh: match .gitignore entries literally (grep -Fxq) instead of
  building an ERE from the folder name, where a name containing "." or
  "*" could match an unrelated line and skip adding the real entry.

Tests: 15 -> 21 assertions, covering the gap that let this through —
no test installed hooks from a non-root cwd or with core.hooksPath set.
The new cases assert against git's own answer (rev-parse --git-path),
not a guessed path, and one asserts the hook actually *blocks* rather
than merely exists. Verified they fail against the old logic (5 red)
and pass against the fix.
@flo-kn

flo-kn commented Jul 17, 2026

Copy link
Copy Markdown
Owner Author

Review found a critical fail-open — fixed

A review of this PR turned up a bug where the guard silently protected nothing while reporting success. Reproduced end-to-end before fixing.

install-hooks.sh reconstructed the hooks directory instead of asking git. Two real setups broke:

  1. Relative core.hooksPath — exactly how husky v8+ and lefthook configure things, i.e. the tools this script explicitly claims to coexist with. Git resolves a relative value against the repo root; the script resolved it against the cwd. Run from a subdirectory it wrote the hook to a path git never reads, printed pre-commit hook installed, and left the repo unguarded. I confirmed a data symlink could then be committed with no block.
  2. Git worktrees.git is a file there, so $repo_root/.git/hooks isn't a directory and mkdir -p aborted.

git rev-parse --git-path hooks answers both correctly (verified: from inside a worktree it returns the shared hooks dir as an absolute path). Asking git beats guessing.

Also fixed from the same review:

  • setup-data.sh: -- before pathnames in check-ignore/ls-files, so a folder named like an option (-data) is treated as a path. It failed closed before, but the check was permanently un-passable and leaked raw git usage text to the terminal.
  • install.sh: match .gitignore entries literally (grep -Fxq) rather than building an ERE from the folder name, where a . or * in a name could match an unrelated line and skip adding the real entry.

The test gap that let it through: no assertion installed hooks from a non-root cwd or with core.hooksPath set. Now 15 → 21 assertions, asserting against git's own answer (rev-parse --git-path) rather than a guessed path, and one asserts the hook actually blocks rather than merely exists. Mutation-verified: 5 go red against the old logic, all green against the fix.

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.

Design: consumer-side guard enforcement must not depend on a CI platform

1 participant