{"id":49431682,"url":"https://github.com/go-to-k/markgate","last_synced_at":"2026-04-29T13:05:26.558Z","repository":{"id":353090795,"uuid":"1217695308","full_name":"go-to-k/markgate","owner":"go-to-k","description":"State-cached commit gate for hook managers — skip checks when nothing changed since they last passed.","archived":false,"fork":false,"pushed_at":"2026-04-22T12:00:40.000Z","size":35,"stargazers_count":0,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-22T13:26:00.905Z","etag":null,"topics":["claude","claude-code","cli","coding-agent","git-hook","git-hooks","go","husky","lefthook","pre-commit","pre-commit-hook","pre-commit-hooks"],"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/go-to-k.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","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-04-22T06:06:18.000Z","updated_at":"2026-04-22T12:00:30.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/go-to-k/markgate","commit_stats":null,"previous_names":["go-to-k/markgate"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/go-to-k/markgate","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/go-to-k%2Fmarkgate","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/go-to-k%2Fmarkgate/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/go-to-k%2Fmarkgate/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/go-to-k%2Fmarkgate/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/go-to-k","download_url":"https://codeload.github.com/go-to-k/markgate/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/go-to-k%2Fmarkgate/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32426632,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-29T12:24:25.982Z","status":"ssl_error","status_checked_at":"2026-04-29T12:24:24.439Z","response_time":110,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"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":["claude","claude-code","cli","coding-agent","git-hook","git-hooks","go","husky","lefthook","pre-commit","pre-commit-hook","pre-commit-hooks"],"created_at":"2026-04-29T13:05:25.776Z","updated_at":"2026-04-29T13:05:26.551Z","avatar_url":"https://github.com/go-to-k.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# markgate\n\nA zero-config verification-state cache for hook managers (Claude\nCode, husky, lefthook, pre-commit, bare `.git/hooks/*`) that fires\nthe hook **only when your AI coding agent forgot to run the check\nitself** — duplicate runs exit in milliseconds.\n\n## Why this exists\n\nYou tell your coding agent to run `/check` (test, lint, build, doc\nconsistency) before committing. **Sometimes it forgets** — context\nloss, token pressure, hurry — and commits anyway.\n\n![Agent forgets the check and commits anyway](docs/images/forgetting-check.png)\n\nSo you add a pre-commit hook to enforce the check. Now every commit\nruns the check twice, once by the agent, once by the hook. Heavy\nchecks slow the dev loop; light ones still add up.\n\n![Skill and hook double-run the check](docs/images/duplicate-execution.png)\n\nPulling the check out of the agent and leaving it only in the hook\nisn't the answer — you can't run it before you're ready to commit.\nPer-edit hooks aren't either — they pay the cost on every keystroke.\n\n`markgate` resolves the dilemma: keeping both the check site and\nthe hook in place, **the hook fires only when the agent forgets**.\nWhen the agent ran the check properly, **the hook is skipped** — no\nduplicate execution.\n\n![markgate fires the hook only when the agent forgot](docs/images/markgate-resolves.png)\n\nAdoption is one line — prefix your check command:\n\n```diff\n- pnpm build\n+ markgate run -- pnpm build\n```\n\nDrop the same line into the hook, and you're done.\n\n## Install\n\n\u003e **Note:** `markgate` is meant to run inside a git repository.\n\n### Homebrew (macOS / Linux)\n\n```sh\nbrew install go-to-k/tap/markgate\n```\n\n### Shell script (macOS / Linux / Windows with Git Bash)\n\n```sh\n# Latest\ncurl -fsSL https://raw.githubusercontent.com/go-to-k/markgate/main/install.sh | bash\n\n# Pin a version\ncurl -fsSL https://raw.githubusercontent.com/go-to-k/markgate/main/install.sh | bash -s -- v0.1.0\n```\n\n### mise\n\nPin a version per repo via [`.mise.toml`](https://mise.jdx.dev/configuration.html):\n\n```toml\n[tools]\n\"ubi:go-to-k/markgate\" = \"0.2.0\"\n```\n\nOr one-shot:\n\n```sh\nmise use \"ubi:go-to-k/markgate@0.2.0\"\n```\n\n### `go install`\n\n```sh\ngo install github.com/go-to-k/markgate/cmd/markgate@latest\n```\n\n### Prebuilt binaries\n\nLinux / macOS / Windows archives (amd64 / arm64 / 386) — see\n[GitHub Releases](https://github.com/go-to-k/markgate/releases).\n\n## Basic setup\n\nThe simplest shape: prefix the check command with `markgate run --`\nin **both** the place that runs it and the hook that enforces it —\nthe same one line goes in both spots.\n\nIn your check site (a `/check` skill, build script, Make target, …):\n\n```diff\n- pnpm build\n+ markgate run -- pnpm build\n```\n\nIn your Claude Code `PreToolUse` hook on `git commit*`:\n\n```diff\n// .claude/settings.json\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Bash\",\n        \"if\": \"Bash(git commit*)\",\n        \"hooks\": [\n-         { \"type\": \"command\", \"command\": \"pnpm build\" }\n+         { \"type\": \"command\", \"command\": \"markgate run -- pnpm build\" }\n        ]\n      }\n    ]\n  }\n}\n```\n\n**That's it.** When the agent already ran the check, the hook is\nskipped — duplicate runs exit in milliseconds. When the agent\nforgets, the hook runs `pnpm build` itself before the commit goes\nthrough. Either way, `pnpm build` runs at most once per repo state.\n\nFor other hook managers (husky, lefthook, pre-commit framework), the\nshape is identical — see [Drop into your hook manager](#drop-into-your-hook-manager).\n\nFor setups where the hook should **only verify** (the check lives\nelsewhere — multi-step script, CI job, separate skill), see\n[Gate pattern](#gate-pattern-set--verify).\n\n## Gate pattern: `set` + `verify`\n\n`markgate run -- \u003ccmd\u003e` is the shortest path, but it requires the\nhook to know how to run the check. When the check is **multi-step,\nlives in a different process, or you want the hook to be a pure\ngate**, split `run` into its two halves:\n\n- `markgate set` — record the current state as a marker (after the\n  check passes elsewhere)\n- `markgate verify` — exit 0 if the marker matches, 1 if not\n\n```sh\n# Wherever the check actually runs (skill, build script, CI, Make):\npnpm typecheck\npnpm lint:fix\npnpm build\n\n# Record the pass; markgate's only addition\nmarkgate set\n```\n\nThen the hook only verifies the marker:\n\n```json\n// .claude/settings.json\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Bash\",\n        \"if\": \"Bash(git commit*)\",\n        \"hooks\": [\n          { \"type\": \"command\", \"command\": \"markgate verify\" }\n        ]\n      }\n    ]\n  }\n}\n```\n\nWhen this fits better than `run`:\n\n- **Multi-step checks** — with `run`, you'd duplicate the chain\n  (typecheck → lint → build → test) in the hook. Split keeps the\n  chain in the script; the hook stays a single `markgate verify`.\n- **Pure gate hook** — the hook fails fast on un-verified commits\n  without running the check itself, handing control back to the\n  agent which can re-run the check on its own without further\n  prompting.\n- **Commit-then-push** — commit hook: `pnpm build \u0026\u0026 markgate set`;\n  push hook: `markgate verify`. Both hooks see the same marker, so\n  push skips re-running when nothing has changed since the commit.\n\n## How it works\n\nWhen `markgate run -- \u003ccmd\u003e` is invoked:\n\n1. It computes a **hash** of the current repo state.\n2. If a saved marker matches, `\u003ccmd\u003e` is skipped (exit 0\n   immediately).\n3. Otherwise `\u003ccmd\u003e` runs. On success, the hash is saved as the new\n   marker. On failure, the marker is left untouched.\n\n(For the split shape, `markgate set` writes step 3's marker;\n`markgate verify` does step 2's match check.)\n\n```sh\n# First run — nothing cached yet, so `pnpm build` runs and the pass is cached.\n$ markgate run -- pnpm build\nbuilding...\npassed in 7.2s\n\n# Second run — nothing changed since the last success: instant skip.\n$ markgate run -- pnpm build\n\n# After you edit a file — cache is stale, `pnpm build` runs again.\n$ echo '// fix typo' \u003e\u003e src/foo.ts\n$ markgate run -- pnpm build\nbuilding...\npassed in 7.1s\n```\n\nThe marker is a small JSON file under `.git/markgate/`, one per\ngate (the file name matches the gate name, e.g. `default.json`).\nNot committed, not tracked, isolated per worktree. With\n`--state-dir \u003cdir\u003e`, `MARKGATE_STATE_DIR=\u003cdir\u003e`, or `state_dir:`\nin `.markgate.yml`, markers go to `\u003cdir\u003e/` instead — see [Sharing\nmarkers](#sharing-markers-across-machines-ci--teammates). The\non-disk JSON layout is an implementation detail; don't parse it.\n\n## Enforcing AI checks that aren't commands\n\nHooks can only execute commands, so on their own they enforce only **mechanical** checks (lint, tests, build). Reviews that need AI judgment — docs consistency with src, naming consistency with existing symbols, \"does the PR description match the diff?\" — can't be reduced to a command. The mechanical layer can spot a typo or a bad import, but \"are these docs still in sync with what the code does?\" isn't a regex. Without markgate, hooks can't gate on these.\n\nmarkgate gives the hook a grip. The AI skill that performs the review ends in `markgate set`; the hook runs `markgate verify`. When the agent forgets the skill, the marker is stale, the hook blocks, and the agent is pointed back at the skill.\n\n```sh\n# At the end of /check-docs (Claude Code skill body):\nmarkgate set\n\n# In a pre-commit hook (.claude/settings.json, PreToolUse on git commit*):\nmarkgate verify || { echo \"Run /check-docs before committing.\" \u003e\u00262; exit 1; }\n```\n\nWhy the agent can't trivially bypass it: `markgate set` lives at the end of the skill body, so an agent told to run `/check-docs` would have to skip the skill *and* call `markgate set` directly — more work than just running the skill. The skill is the discipline; the hook is the enforcement.\n\nTo narrow the trigger so the marker invalidates only when specific files change — e.g. re-judge docs only when `docs/**` or `src/**` move — see [Scoped gates](#scoped-gates) below.\n\n## Scoped gates\n\n`markgate` works zero-config — what [Basic setup](#basic-setup)\nshows covers most pre-commit cases. When you want finer control,\ndrop a `.markgate.yml` at the repo root (`markgate init` writes\none):\n\n- **Targeted files** — limit a gate to a specific set of files via\n  [`hash: files`](#hashing-strategies-git-tree-vs-files) + `include`\n  globs, so unrelated commits don't invalidate the marker\n- **Multiple gates** — define independent named gates in one repo,\n  each tracking its own scope (e.g. one for pre-commit, one for\n  pre-PR)\n\nCombined, these give you **scoped gates**: \"re-run this check only\nwhen these files change.\"\n\n### `.markgate.yml`\n\nLives at `$(git rev-parse --show-toplevel)/.markgate.yml` (no\nparent-dir walking).\n\n`markgate init` writes a starter file at the repo root:\n\n```sh\nmarkgate init          # writes .markgate.yml at the repo root\nmarkgate init --force  # overwrite an existing one\n```\n\nThe generated file enables the `default` gate with `git-tree` hash,\nplus commented-out examples (an `exclude` list on `git-tree` and a\n`files`-type gate) — uncomment what you need.\n\nPer-gate fields:\n\n| field | purpose |\n| --- | --- |\n| `hash` | `git-tree` (default) or `files` |\n| `include` | glob list; required for `hash: files` |\n| `exclude` | glob list |\n| `state_dir` | optional override of marker storage location — see [Sharing markers](#sharing-markers-across-machines-ci--teammates) |\n\nExample:\n\n```yaml\ngates:\n  default:\n    hash: git-tree\n    exclude:\n      - \"vendor/**\"\n      - \"node_modules/**\"\n\n  pre-pr:\n    hash: files\n    include:\n      - \"docs/**\"\n      - \"README.md\"\n    exclude:\n      - \"**/*.txt\"\n```\n\nEach gate's key (the YAML map key — `default`, `pre-pr` above) must\nmatch `[a-z0-9][a-z0-9-]*` (kebab-case ASCII). `default` is what\n`markgate set` / `verify` use when no key argument is given:\n\n```sh\nmarkgate set               # same as `markgate set default`\nmarkgate set pre-pr        # a second, independent gate\n```\n\n### Hashing strategies: `git-tree` vs `files`\n\nThe `hash` field above picks one of two strategies:\n\n| aspect | `git-tree` (default) | `files` |\n| --- | --- | --- |\n| What it hashes | `HEAD` + diff-vs-HEAD ∪ untracked-not-ignored | whatever matches your `include` globs |\n| `HEAD` in the hash? | **Yes** | **No** |\n| Commits invalidate the marker? | Yes | Only if they touch in-scope files |\n| `.gitignore` respected? | Yes (automatic) | No — scope is explicit |\n| Needs config? | No | Yes (`include` required) |\n\nWhen to use which:\n\n- **`git-tree`** = \"re-verify on *any* repo change\". Broad gates\n  (pre-commit running lint/test/build). Add `exclude` patterns to\n  skip `vendor/`, `node_modules/`, etc. — HEAD-aware invalidation\n  is kept.\n- **`files`** = \"re-verify *only* when these paths change, ignore\n  other commits\". Narrow gates (docs consistency, vuln scan rooted\n  on a lockfile, coverage for one sub-tree).\n\nRule of thumb: start with `git-tree` (add `exclude` if needed).\nReach for `files` only when you specifically want the \"ignore\ncommits that don't touch these paths\" semantics.\n\n## Use cases\n\nEach section follows the same shape: **Scope** (what triggers\nre-verify — a [`hash`](#hashing-strategies-git-tree-vs-files)\nstrategy) → **Commands** (what goes in your shell / hook). All\nexamples below use scoped `files`-hash gates defined in\n[`.markgate.yml`](#markgateyml) at the repo root, and the\n[Gate pattern](#gate-pattern-set--verify) shape above. (For the\nbroad whole-repo `git-tree` shape with no config, see\n[Basic setup](#basic-setup).)\n\n### 1. Pre-PR: docs consistency\n\n**Scope**: only `docs/` and `README.md`. Code-only commits don't\ninvalidate the marker.\n\n```yaml\n# .markgate.yml\ngates:\n  pre-pr:\n    hash: files\n    include:\n      - \"docs/**\"\n      - \"README.md\"\n```\n\n**Commands**:\n\n```sh\n./scripts/check-docs \u0026\u0026 markgate set pre-pr\n\n# Before `gh pr create`:\nmarkgate verify pre-pr || {\n  echo \"Docs are out of date. Run check-docs.\" \u003e\u00262\n  exit 1\n}\n```\n\n### 2. Pre-image-push: vulnerability scan freshness\n\n**Scope**: only files that actually affect the image (Dockerfile +\nlockfiles).\n\n```yaml\ngates:\n  pre-image-push:\n    hash: files\n    include:\n      - \"Dockerfile\"\n      - \"package.json\"\n      - \"package-lock.json\"\n```\n\n**Commands**:\n\n```sh\ntrivy image ... \u0026\u0026 markgate set pre-image-push\n\n# In your `docker push` wrapper:\nmarkgate verify pre-image-push || exit 1\n```\n\n### 3. Pre-push: coverage report freshness\n\n**Scope**: just source and tests.\n\n```yaml\ngates:\n  pre-push:\n    hash: files\n    include:\n      - \"src/**\"\n      - \"tests/**\"\n```\n\n**Commands**:\n\n```sh\ngo test -cover \u0026\u0026 markgate set pre-push\n\n# In .git/hooks/pre-push:\nmarkgate verify pre-push || exit 1\n```\n\n### 4. Pre-commit: isolate a slow check with its own scoped gate\n\n**Scope**: two gates on the same `git commit` event. `check` covers code artifacts; `docs` covers code **and** documentation. Source files appear in both `include` lists on purpose — a src edit invalidates both gates (forcing both checks), while a tests-only edit invalidates only `check` and a docs-only edit invalidates only `docs`.\n\nUseful when one pre-commit check is much slower than the others — typically an LLM-judged \"are the docs still consistent with src?\" review. Bundling it into the fast code check would force every tests-only or bug-fix commit to pay the doc-review cost. Splitting it into its own scoped gate means each edit only pays for the scope it actually invalidated.\n\n```yaml\n# .markgate.yml\ngates:\n  check:\n    hash: files\n    include:\n      - \"src/**\"\n      - \"tests/**\"\n      - \"package.json\"\n  docs:\n    hash: files\n    include:\n      - \"src/**\"        # src edits invalidate docs too — see matrix below\n      - \"docs/**\"\n      - \"README.md\"\n```\n\nInvalidation matrix:\n\n| edit                         | `check` | `docs` | re-runs needed          |\n|------------------------------|---------|--------|-------------------------|\n| `tests/**` only              | stale   | fresh  | fast code check only    |\n| `docs/**` / `README.md` only | fresh   | stale  | slow docs check only    |\n| `src/**`                     | stale   | stale  | both                    |\n| outside both scopes          | fresh   | fresh  | neither — commit passes |\n\nThe last row is what makes the idiom scale: edits that land in neither `include` list (CI config, editor settings, hook scripts, tooling dotfiles) keep both markers fresh, so a hook verifying both stays silent when nothing relevant moved. That's only possible because each gate owns its own scope — `hash: files` + per-gate `include` is the primitive that makes it work.\n\n**Commands**:\n\n```sh\n# Fast code check (src / tests / config):\npnpm typecheck \u0026\u0026 pnpm lint \u0026\u0026 pnpm build \u0026\u0026 markgate set check\n\n# Slow docs consistency check (src / docs / README):\n./scripts/check-docs \u0026\u0026 markgate set docs\n\n# One pre-commit hook verifies both; the failing gate names itself:\nmarkgate verify check || { echo \"run the code check\" \u003e\u00262; exit 1; }\nmarkgate verify docs  || { echo \"run the docs check\" \u003e\u00262; exit 1; }\n```\n\nA working wire-up lives in [go-to-k/cdkd](https://github.com/go-to-k/cdkd):\n\n- [`.markgate.yml`](https://github.com/go-to-k/cdkd/blob/main/.markgate.yml) — gate definitions.\n- [`.claude/hooks/check-gate.sh`](https://github.com/go-to-k/cdkd/blob/main/.claude/hooks/check-gate.sh) — pre-commit hook that runs `markgate verify` for each gate.\n- [`/check`](https://github.com/go-to-k/cdkd/blob/main/.claude/skills/check/SKILL.md) and [`/check-docs`](https://github.com/go-to-k/cdkd/blob/main/.claude/skills/check-docs/SKILL.md) skills produce the markers (the latter has a diff-based short-circuit to keep the LLM cost low on internal src edits).\n\n## Drop into your hook manager\n\nSubstitute `pnpm build` with your verification command. Use\n`markgate run --` when the hook itself runs the check, or\n`markgate verify` when it sits in front of a separate `markgate set`\n(see [Gate pattern](#gate-pattern-set--verify)).\n\n**husky** — `.husky/pre-commit`:\n\n```sh\nmarkgate run -- pnpm build\n```\n\n**lefthook** — `lefthook.yml`:\n\n```yaml\npre-commit:\n  commands:\n    check:\n      run: markgate run -- pnpm build\n```\n\n**pre-commit framework** — `.pre-commit-config.yaml`:\n\n```yaml\nrepos:\n  - repo: local\n    hooks:\n      - id: markgate-check\n        name: markgate check\n        entry: markgate run -- pnpm build\n        language: system\n        pass_filenames: false\n```\n\n**Claude Code (PreToolUse)** — `.claude/settings.json`:\n\n```json\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"Bash\",\n        \"if\": \"Bash(git commit*)\",\n        \"hooks\": [\n          { \"type\": \"command\", \"command\": \"markgate verify\" }\n        ]\n      }\n    ]\n  }\n}\n```\n\nIn your `/check` skill: `pnpm build \u0026\u0026 markgate set`. See\n[Gate pattern](#gate-pattern-set--verify) for the full flow.\n\n## Command model\n\n### `markgate run -- \u003ccmd\u003e` (one-shot)\n\nCollapses verify → run → set into one invocation (see\n[How it works](#how-it-works) for the mechanism). stdio is passed\nthrough; `SIGINT` / `SIGTERM` are forwarded to `\u003ccmd\u003e`. On `\u003ccmd\u003e`\nfailure, the marker is **not** updated and `\u003ccmd\u003e`'s exit code is\nreturned as-is.\n\n### `markgate set` / `markgate verify` (split)\n\nThe two halves of `run`. See\n[Gate pattern](#gate-pattern-set--verify) for when to use the split\nshape.\n\n```sh\npnpm build \u0026\u0026 markgate set    # record state on success\nmarkgate verify || pnpm build # short-circuit if marker fresh, else re-run\n```\n\n### Exit codes\n\nExit codes follow the `grep` / `diff` convention, so `||` composes\nnaturally:\n\n| exit | meaning                                                   |\n| ---- | --------------------------------------------------------- |\n| 0    | verified — state matches the marker, safe to skip         |\n| 1    | not verified — no marker, or state differs                |\n| 2    | error — not in a repo, bad config, bad key, etc.          |\n\n## CLI reference\n\n```text\nmarkgate set    [key]              Record the current state hash.\nmarkgate verify [key]              Exit 0 match, 1 mismatch, 2 error.\nmarkgate status [key]              Show marker + match status.\nmarkgate clear  [key]              Delete the marker (idempotent).\nmarkgate run    [key] -- \u003ccmd\u003e...  Sugar for verify + \u003ccmd\u003e + set.\nmarkgate init                      Write a starter .markgate.yml.\nmarkgate version                   Print the version.\n```\n\n### Per-invocation overrides\n\n`set` / `verify` / `status` / `clear` / `run` each accept these flags,\nso one-off scopes don't need a `.markgate.yml`:\n\n```text\n--hash git-tree|files    Override hash type for this call.\n--include \u003cglob\u003e         Repeatable. Override the gate's include list.\n--exclude \u003cglob\u003e         Repeatable. Override the gate's exclude list.\n--state-dir \u003cpath\u003e       Directory to store marker files. Takes\n                         precedence over MARKGATE_STATE_DIR env and\n                         state_dir: in .markgate.yml. Default:\n                         \u003cgit-dir\u003e/markgate. See \"Sharing markers\".\n```\n\nFlag syntax is identical across hash types. With `--hash files`,\n`--include` is required. Example — exclude `vendor/` without any\nconfig file:\n\n```sh\nmarkgate run --exclude 'vendor/**' -- pnpm build\n```\n\n### Environment variables\n\n```text\nMARKGATE_STATE_DIR       Marker storage directory. Same effect as\n                         --state-dir and state_dir: in config.\n                         Precedence: --state-dir \u003e this env \u003e\n                         state_dir: in .markgate.yml \u003e default.\n```\n\n## Sharing markers across machines (CI / teammates)\n\nBy default, markers live under `.git/markgate/` — strictly local. If\nthat's all you need, skip this section; the [use cases above](#use-cases)\nall work with the default.\n\nRead on if you want a check to **skip in CI (or on a teammate's\nmachine) based on a run that already happened elsewhere**. Typical\nwins: coverage, vulnerability scan, e2e, image build — expensive\nand deterministic, redundant to re-run. Trust model differs by\npattern (see [Two patterns at a glance](#two-patterns-at-a-glance)\nbelow); pick the one that matches your trust assumptions.\n\n### Specifying a non-default location\n\nThree sources, in precedence order (flag beats env beats config):\n\n```text\n--state-dir \u003cdir\u003e           # per-invocation flag\nMARKGATE_STATE_DIR=\u003cdir\u003e    # environment variable\nstate_dir: \u003cdir\u003e            # in .markgate.yml, per gate\n```\n\nThe marker is written at `\u003cdir\u003e/\u003ckey\u003e.json` (no extra `markgate/`\nsubdirectory). Relative paths resolve against the repo top-level, so\nthe location is stable regardless of cwd — identical on every machine\nthat checks out the repo.\n\n### Two patterns at a glance\n\nBoth use `--state-dir` / `state_dir`; the difference is whether the\nmarker is **committed** to the repo.\n\n| aspect | **A. Not committed** (CI cache / artifact) | **B. Committed** |\n| --- | --- | --- |\n| Marker in the repo? | No (typically gitignored, or outside the repo) | Yes, tracked in git |\n| Works with hash type | `git-tree` or `files` | **`files` only** — committing with `git-tree` breaks: the commit changes HEAD → digest is instantly stale |\n| Local → CI sharing | Needs CI cache / artifact / shared volume | Just `git push` |\n| Tamper surface | Whoever can write to the cache | Whoever has commit access |\n| Extra infra | CI cache provider (e.g. `actions/cache`, `actions/upload-artifact`) | None — git is enough |\n| Best for | CI-internal reuse across runs; teams already on remote cache infra | Zero-infra local→CI sharing for `files`-hash gates (coverage, scans) |\n\n### A. Not committed (CI cache / artifact)\n\nStore the marker somewhere CI can pick it up, but keep it out of git.\n`.markgate-cache/` at the repo root is a conventional choice; any\npath outside `.git/` works. (If you'd rather commit the marker into\ngit so CI sees it without any cache layer, skip to\n[Pattern B](#b-committed-files-hash) — that's a different shape, not\na variant of this one.)\n\n#### Step 1. Add the state dir to `.gitignore`\n\n**This is a required setup step on `hash: git-tree`, not optional\nhygiene.** Do this *before* your first `markgate run`:\n\n```gitignore\n# .gitignore — add the state dir you chose\n/.markgate-cache/\n```\n\nYou can skip this only if:\n\n- the state dir is **outside the repo** (e.g. `$RUNNER_TEMP/mg`,\n  `/tmp/mg`, `$HOME/.cache/markgate`), **or**\n- you're on `hash: files` (gitignore then becomes hygiene, not\n  required — see why below).\n\n\u003cdetails\u003e\n\u003csummary\u003eWhy it's required on \u003ccode\u003ehash: git-tree\u003c/code\u003e (click to expand)\u003c/summary\u003e\n\nThe `git-tree` digest hashes `HEAD + diff-vs-HEAD ∪\nuntracked-not-ignored`. The saved marker file is itself an untracked\nfile, so without gitignore:\n\n1. `markgate run` computes **digest_1** (before the marker exists)\n   and saves the marker with digest_1.\n2. The saved marker file now exists as untracked-not-ignored.\n3. The next `markgate verify` computes **digest_2**, which *includes*\n   the marker file. digest_2 ≠ digest_1 → mismatch → the check\n   re-runs every time.\n\nThe feature is defeated on the first verify, before any commit.\nGitignoring the state dir keeps the marker out of the digest.\n\n`hash: files` sidesteps this: the marker is only in the digest if an\n`include` glob matches it, which it normally won't. That's why\ngitignore is optional on `files`.\n\n\u003c/details\u003e\n\n#### Step 2. Wire up CI\n\n**Across runs of the same workflow** — `actions/cache`, extending\nthe `pre-image-push` gate from [Use case 2](#2-pre-image-push-vulnerability-scan-freshness):\n\n```yaml\n# .github/workflows/scan.yml\njobs:\n  scan:\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/cache@v4\n        with:\n          path: .markgate-cache\n          key: markgate-scan-${{ github.sha }}\n          restore-keys: |\n            markgate-scan-\n      - run: markgate run pre-image-push --state-dir .markgate-cache -- trivy fs .\n```\n\n**Across jobs within one workflow** — `actions/upload-artifact` →\n`actions/download-artifact`. A setup job runs the expensive check\nonce; matrix jobs on the same commit download the marker and skip.\n(`expensive` below is a placeholder key — define it in your\n`.markgate.yml` using the [Use cases](#use-cases) as templates, or\npass `--include` / `--hash` via CLI flags.)\n\n```yaml\njobs:\n  verify:\n    steps:\n      - uses: actions/checkout@v4\n      - run: markgate run expensive --state-dir .markgate-cache -- make expensive-check\n      - uses: actions/upload-artifact@v4\n        with:\n          name: markgate-state\n          path: .markgate-cache\n\n  fan-out:\n    needs: verify\n    strategy:\n      matrix:\n        os: [ubuntu-latest, macos-latest, windows-latest]\n    runs-on: ${{ matrix.os }}\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/download-artifact@v4\n        with:\n          name: markgate-state\n          path: .markgate-cache\n      - run: markgate verify expensive --state-dir .markgate-cache || make expensive-check\n```\n\n### B. Committed (files hash)\n\nKeep the state directory **tracked in git** and commit the marker with\nthe code. Works only with `hash: files`: `git-tree` would change HEAD\non the commit and invalidate the marker it just wrote.\n\nTypical fit: coverage reports, image vulnerability scans — expensive,\ndeterministic, and already re-running them on every push is waste\nwhen nothing in scope changed.\n\nCoverage example, extending the pre-push gate from [Use case 3](#3-pre-push-coverage-report-freshness):\n\n```yaml\n# .markgate.yml\ngates:\n  coverage:\n    hash: files\n    include:\n      - \"src/**\"\n      - \"tests/**\"\n    state_dir: .markgate-state\n```\n\n```sh\n# Locally, after a successful coverage run:\nmarkgate run coverage -- go test -cover ./...\ngit add .markgate-state/coverage.json\ngit commit -m \"bump coverage marker\"\ngit push\n\n# In CI (already sees the committed marker):\nmarkgate verify coverage || go test -cover ./...\n```\n\nTrust model: anyone with commit access can forge a skip. Use committed\nmarkers where commit-access already implies trust in the signal.\n\n### Caveats\n\n- **Worktree isolation is lost** when the dir is shared across\n  worktrees pointing at the same location. The default `.git/`-based\n  layout preserves isolation; `--state-dir` does not.\n- **Relative paths** resolve from the repo top-level, not cwd, so\n  hook-invoked commands land in the same place regardless of where\n  they run from.\n- **Signing is not yet implemented** — markers are unsigned JSON.\n  Tamper resistance depends on who can write to the directory (cache /\n  repo).\n\n## FAQ\n\n- **Why not just `git status` in the hook?** `git status` tells you\n  the tree is clean, not \"did the check pass against this exact\n  state.\" `markgate` records the success itself, so a passed check\n  stays valid across hook invocations until something moves.\n- **Does it work in git worktrees?** Yes. Markers live under each\n  worktree's own `.git/` dir, so they don't leak across worktrees.\n  (This isolation is lost if you point `--state-dir` at a shared\n  location.)\n- **Do I need to gitignore anything?** No for the default layout —\n  markers are under `.git/`. If you use `--state-dir` pointing inside\n  the repo, gitignore that directory.\n- **What if I don't want HEAD in the hash?** Use\n  [`hash: files`](#hashing-strategies-git-tree-vs-files) for that\n  gate.\n- **Does `files` respect `.gitignore`?** No. `files` is explicit\n  scope by design. Use `git-tree` when you want `.gitignore`-aware\n  behavior. (See [Hashing strategies](#hashing-strategies-git-tree-vs-files).)\n- **Can markers be shared across machines / CI?** Yes, via\n  `--state-dir`, `MARKGATE_STATE_DIR`, or `state_dir:` in\n  `.markgate.yml`. See\n  [Sharing markers](#sharing-markers-across-machines-ci--teammates) for patterns\n  and trust considerations.\n- **Can the marker be tampered with?** Yes — it's a JSON file under\n  `.git/` (or wherever `--state-dir` points). Trust whoever can write\n  to that location. Signed markers are still a future consideration.\n\n## License\n\nMIT. See [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fgo-to-k%2Fmarkgate","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fgo-to-k%2Fmarkgate","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fgo-to-k%2Fmarkgate/lists"}