{"id":51704635,"url":"https://github.com/jackchuka/git-customs","last_synced_at":"2026-07-16T14:36:05.733Z","repository":{"id":366581220,"uuid":"1276590012","full_name":"jackchuka/git-customs","owner":"jackchuka","description":"Client-side, cross-host pre-push gate that catches PII and secrets — using an engine you choose — before anything reaches a public remote.","archived":false,"fork":false,"pushed_at":"2026-07-16T04:14:53.000Z","size":45,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-16T14:35:09.184Z","etag":null,"topics":["ai-tools","audit","git","security"],"latest_commit_sha":null,"homepage":"","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/jackchuka.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-22T05:48:32.000Z","updated_at":"2026-07-12T06:07:45.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/jackchuka/git-customs","commit_stats":null,"previous_names":["jackchuka/git-customs"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/jackchuka/git-customs","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jackchuka%2Fgit-customs","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jackchuka%2Fgit-customs/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jackchuka%2Fgit-customs/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jackchuka%2Fgit-customs/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/jackchuka","download_url":"https://codeload.github.com/jackchuka/git-customs/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/jackchuka%2Fgit-customs/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35547971,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-16T02:00:06.687Z","response_time":83,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ai-tools","audit","git","security"],"created_at":"2026-07-16T14:36:05.165Z","updated_at":"2026-07-16T14:36:05.724Z","avatar_url":"https://github.com/jackchuka.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# git-customs\n\n**A client-side, cross-host pre-push gate that catches PII and secrets — using\nan engine you choose — before anything reaches a public remote.**\n\ngit-customs is a *router + pipe*, not a scanner: it decides **which repos** to\ninspect (only public-bound pushes, on **any** host — GitHub, GitLab, Gitea,\nself-hosted), builds the **diff**, and pipes it to a command **you configure**.\nThat command can be an LLM (`claude -p \"…\"`) — which catches free-form **PII**\nthat regex scanners miss — or a deterministic tool like `gitleaks`. git-customs\nitself stays dumb and engine-agnostic.\n\nPushing to a private repo? It skips silently. Publishing a new public repo? It\nscans the whole tree once. Everything in between just works.\n\n## Why it exists (and what it is *not*)\n\nIt complements — does not replace — [GitHub push protection](https://docs.github.com/en/code-security/secret-scanning/introduction/about-push-protection).\nPush protection is excellent and you should keep it on. But it is **server-side**\n(your data already reached GitHub), **GitHub-only**, **secrets-only** (no PII),\nand limited to known token types. git-customs covers the seams push protection\nleaves:\n\n- **Client-side / pre-upload** — a flagged diff never leaves your machine, not\n  even to a server.\n- **Cross-host** — works on self-hosted GitLab/Gitea and anywhere else, where\n  push protection doesn't exist.\n- **PII, not just secrets** — point it at an LLM and it catches names, emails,\n  addresses, and IDs that pattern matchers don't.\n- **Bring your own engine** — claude, gitleaks, or any executable; not tied to\n  one vendor or service.\n\nIt is **not** org enforcement: like any local hook it is bypassable\n(`--no-verify`) and isn't team-distributed. Treat it as a personal safety net,\nwith push protection as the server-side backstop.\n\n## Install\n\nGet the binary with Homebrew:\n\n```sh\nbrew install jackchuka/tap/git-customs\n```\n\nOr with Go:\n\n```sh\ngo install github.com/jackchuka/git-customs@latest\n```\n\nThen install the hook:\n\n```sh\ngit customs install\n```\n\n`install` writes a `pre-push` hook to `~/.config/git-customs/hooks/` and points\nyour **global** `git config core.hooksPath` at it, so it runs in every repo on\nthe machine. A starter `~/.config/git-customs/config.toml` is created if absent.\n\nBecause the binary is named `git-customs` and sits on your `PATH`, git exposes\nit as a native subcommand: `git customs install`, `git customs doctor`, etc.\n\n## How a push flows\n\n1. `pre-push` fires → git-customs reads the outgoing ref updates.\n2. It resolves whether this repo should be inspected (see mapping below).\n   Skipped → the push proceeds untouched.\n3. If inspected, it builds the patch of every **outgoing commit** (`git log -p`\n   over the pushed range, or the branch's full history on a first push) and\n   pipes it to your command on **stdin**. Scanning per-commit — not just the\n   endpoint tree diff — catches a secret that was added in one commit and\n   removed in a later one but still ships in the pushed history.\n4. **Allow only if** the command exits `0` **and** its output is the all-clear\n   (empty, or your `clear_pattern`). Otherwise the push is **blocked** and the\n   command's output is shown.\n\nThis is fail-closed: a missing command, a non-zero exit, or a timeout blocks\nthe push.\n\nWhen it inspects, git-customs prints its progress to stderr so you can see it\nworking (and know which check a slow command is stuck on):\n\n```text\ngit-customs: inspecting push to github.com/acme/oss (2 checks)\ngit-customs: → gitleaks\ngit-customs: → claude\ngit-customs: ✓ clean — 2 checks passed\n```\n\nA skipped push (private repo, branch deletion) stays silent; `CUSTOMS_BYPASS=1`\nprints a one-line `bypassed` notice so a skipped gate is never a surprise.\n\n## Configuration — `~/.config/git-customs/config.toml`\n\nEach command receives the diff on stdin. ANY command works — it is not tied to\nAI. Commands are parsed with shell-style quoting, so a quoted argument may\ncontain spaces. The push is allowed only if **every** command exits 0 AND its\noutput is \"clear\" (empty, or exactly `clear_pattern`); the first that fails\nblocks the push.\n\n```toml\n# Combine engines with `commands` — they run in order, all must pass:\ncommands = [\n  \"gitleaks stdin --no-banner\",                          # deterministic secrets\n  'claude -p \"List PII (emails, phone numbers, IDs, addresses, names) in added lines of the diff on stdin. One per line. If none, output exactly: OK\"',\n]\ntimeout       = \"120s\"          # per command; exceeding it blocks (fail-closed)\nclear_pattern = \"OK\"            # empty output OR this exact string = clean\n\n[repos]\npublic  = true                  # run on public repos\nprivate = false                 # skip private repos\n\n# explicit per-repo overrides always win over the public/private alias:\n\"github.com/jackchuka/blog\"  = false   # public, but never inspect\n\"github.com/acme/secret-oss\" = true    # private, but always inspect\n\n[visibility]\nskip_hosts  = [\"github.example-corp.com\"]  # always treated as private\nskip_owners = [\"my-private-org\"]\n```\n\nThe single-command shorthand still works when you only need one engine:\n\n```toml\ncommand = 'gitleaks stdin --no-banner'   # used only when `commands` is empty\n```\n\n### Choosing an engine — reliability matters\n\ngit-customs only routes the diff; the engine decides what's a problem. **Use a\ndeterministic scanner (gitleaks) for the secret gate.** An LLM is *not* a\nreliable hard gate: it is nondeterministic (the same diff can pass on one run\nand flag on the next) and prompt-injectable (text in the diff can steer its\nverdict). Reserve any AI command for **PII**, where no good deterministic tool\nexists, and treat it as best-effort rather than a guarantee.\n\n### How visibility is decided\n\nOnly when no explicit repo entry matches: git-customs asks `gh` for the true\nvisibility of GitHub remotes, and falls back to a host heuristic\n(github.com / gitlab.com / bitbucket.org / codeberg.org are public) when `gh`\nisn't available. Anything it can't confidently classify is treated as private\nand skipped.\n\n## Overriding a block\n\n```sh\nCUSTOMS_BYPASS=1 git push     # skip inspection for this push\ngit push --no-verify          # skip all hooks\n```\n\n## Coexisting with other hooks\n\nA global `core.hooksPath` makes git read **every** hook type from one directory\nand stop looking in `.git/hooks` — which would silently disable a repo's other\nhooks. git-customs avoids that: it installs a **shim for every standard hook**.\n\n- **`pre-push`** → runs the customs gate, then chains to the hook git-customs\n  shadowed (so an existing pre-push still runs).\n- **Every other hook** (`pre-commit`, `commit-msg`, `post-checkout`, …) →\n  transparently delegates to the shadowed hook in shell, without even invoking\n  git-customs.\n\nThe shadowed hook is the one git would otherwise have run: the repo's own\n`core.hooksPath` if it had set one (recorded at install time), else\n`.git/hooks/\u003cname\u003e`. So **lefthook and husky keep working** — lefthook writes\ninto `.git/hooks/`, and a `--here` install over husky's `.husky/` chains\nthrough to it. Your pre-commit lint/format hooks fire exactly as before; only\n`pre-push` gains the public-gate.\n\nCaveat: a tool that sets its own **repo-local** `core.hooksPath` (husky does)\noverrides the global one, so git-customs won't run in that repo. Run\n`git customs doctor` to detect it, and `git customs install --here` to install\ngit-customs for that single repo as well — it records the prior `.husky/` path\nand chains every hook through to it.\n\n## Commands\n\n| Command | Purpose |\n|---|---|\n| `git customs install [--here]` | Install the hook globally (or for the current repo with `--here`) |\n| `git customs uninstall` | Remove the hook and restore prior `core.hooksPath` |\n| `git customs doctor` | Check the command is on `PATH`, the hook is active, and warn about repo-local bypass |\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjackchuka%2Fgit-customs","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjackchuka%2Fgit-customs","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjackchuka%2Fgit-customs/lists"}