markgate is the mechanical enforcement layer between an AI
coding agent and its hooks — ensure non-duplicate runs,
enforce non-command tasks (e.g., LLM review), and aggregate
multi-task verdicts into one.
Coding agents forget — context loss, token pressure, hurry.
Wiring hooks to reliably run a required task (a check (lint, test,
build), an LLM-judged review, a code-generation step, or any
operation with a pass/fail outcome) runs into three recurring
challenges. markgate addresses these with three patterns,
each backed by one of two primitives — markgate run
(one-shot) or markgate set + markgate verify (the Gate
pattern).
| Pattern | Goal | Mechanism |
|---|---|---|
| 1 | Ensure non-duplicate runs | markgate run |
| 2 | Enforce non-command tasks | markgate set + markgate verify |
| 3 | Scope each task & aggregate the verdict | .markgate.yml with composes |
You tell your coding agent to run /check (test, lint, build, doc
consistency) before committing. Sometimes it forgets — context
loss, token pressure, hurry — and commits anyway.
So you add a pre-commit hook to enforce the check. Now every commit runs the check twice, once by the agent, once by the hook. Heavy checks slow the dev loop; light ones still add up.
Pulling the check out of the agent and leaving it only in the hook isn't the answer — you can't run it before you're ready to commit. Per-edit hooks aren't either — they pay the cost on every edit.
markgate run resolves the dilemma: keeping both the check site and
the hook in place, the hook re-runs the check only when the agent
forgot. When the agent ran the check properly, the hook becomes
a near-instant no-op — no duplicate execution.
Adoption is one line — prefix your check command in both the place that runs it and the hook that enforces it:
- pnpm build
+ markgate run -- pnpm buildIn your Claude Code PreToolUse hook on git commit*:
// .claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"if": "Bash(git commit*)",
"hooks": [
- { "type": "command", "command": "pnpm build" }
+ { "type": "command", "command": "markgate run -- pnpm build" }
]
}
]
}
}For other hook managers (husky, lefthook, pre-commit framework), the shape is identical — see Drop into your hook manager.
Some tasks aren't commands. "Did /check-docs find docs out of
sync with src?" "Did /investigate-aws find anything wrong?" An
LLM-led skill can work through these — judging, investigating, or
updating step by step. You want the agent's own session to do
this work, where its built-up context (conversation history, open
files, prior decisions) is already in play. A hook can't reach
into that session, and shelling out to claude -p only spawns a
fresh one with no access to the agent's state. So when the agent
forgets or skips the task, the hook has no grip on whether it
actually happened.
markgate set + markgate verify give the hook a grip by splitting
the run. The skill — wherever it naturally lives, like /check-docs,
/investigate-aws, or any agent-driven step — ends with markgate set,
which writes a small marker recording the pass. The hook calls
markgate verify to read it. The hook still can't run the skill
itself, but it can refuse to proceed unless the marker confirms it
ran.
The agent implements, runs /check-docs, the marker passes. But
after another code edit, you'd want /check-docs to run again —
and if the agent forgets, the marker no longer matches the new
code, so the hook blocks until /check-docs runs again.
Adoption is one line on each side:
# At the end of /check-docs (or any agent-driven step):
markgate set
# In a pre-commit hook (.claude/settings.json, PreToolUse on git commit*):
markgate verify || { echo "Run /check-docs before committing." >&2; exit 1; }As tasks accumulate — code check on src/**, docs review on
docs/**, vuln scan on package-lock.json — you want each one to
fire only when its own files change. The default whole-repo
hash doesn't allow that: a code-only edit invalidates the docs
marker, a docs-only edit invalidates the vuln-scan marker, so the
hook re-fires tasks that nothing relevant moved. And lining up N
markgate verify calls in the hook clutters the config in
proportion to how many tasks you add.
With .markgate.yml (created by markgate init), each task gets
its own scoped gate (its own include globs), and the hook
verifies a parent gate that ANDs them all via composes:. The
code check fires when src/** moves; the docs review fires when
docs/** moves. Edits outside every scope (CI config, editor
settings) invalidate nothing — the hook stays silent.
# .markgate.yml
gates:
check:
hash: files
include: ["src/**", "tests/**"]
docs:
hash: files
include: ["src/**", "docs/**", "README.md"]
pre-commit:
composes: [check, docs]Once each task owns its scope, the hook needs just one verify — when every child is fresh, the parent passes.
Adoption:
# Each task freshens its own marker, wherever it lives:
pnpm build && markgate set check
# Inside the /check-docs skill body (LLM-led — see Pattern 2):
markgate set docs
# One verify in the hook covers both:
markgate verify pre-commit || { markgate status pre-commit >&2; exit 1; }markgate run can't write an aggregate gate: run executes a single
command, and an aggregate gate has none. Aggregate verify is
split-only.
See Use case 4
for the invalidation matrix and a real-world wire-up,
Use case 5
for the aggregate composes shape on top of it, and
Gate dependencies for the
strict variant (requires) that refuses set on a stale child.
Each section follows the same shape: Scope (what triggers
re-verify — a hash
strategy) → Commands (what goes in your shell / hook). All
examples below use scoped files-hash gates defined in
.markgate.yml at the repo root, and the
set + verify shape
above. (For the broad whole-repo git-tree shape with no config,
see Pattern 1.)
Scope: only docs/ and README.md. Code-only commits don't
invalidate the marker.
# .markgate.yml
gates:
pre-pr:
hash: files
include:
- "docs/**"
- "README.md"Commands:
# Inside the /check-docs skill body (LLM-led — see Pattern 2):
markgate set pre-pr
# Before `gh pr create`:
markgate verify pre-pr || {
echo "Docs are out of date. Run check-docs." >&2
exit 1
}Scope: only files that actually affect the image (Dockerfile + lockfiles).
gates:
pre-image-push:
hash: files
include:
- "Dockerfile"
- "package.json"
- "package-lock.json"Commands:
trivy image ... && markgate set pre-image-push
# In your `docker push` wrapper:
markgate verify pre-image-push || exit 1Scope: just source and tests.
gates:
pre-push:
hash: files
include:
- "src/**"
- "tests/**"Commands:
go test -cover && markgate set pre-push
# In .git/hooks/pre-push:
markgate verify pre-push || exit 1Scope: 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.
Useful 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.
# .markgate.yml
gates:
check:
hash: files
include:
- "src/**"
- "tests/**"
- "package.json"
docs:
hash: files
include:
- "src/**" # src edits invalidate docs too — see matrix below
- "docs/**"
- "README.md"Invalidation matrix:
| edit | check |
docs |
re-runs needed |
|---|---|---|---|
tests/** only |
stale | fresh | fast code check only |
docs/** / README.md only |
fresh | stale | slow docs check only |
src/** |
stale | stale | both |
| outside both scopes | fresh | fresh | neither — commit passes |
The 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.
Commands:
# Fast code check (src / tests / config):
pnpm typecheck && pnpm lint && pnpm build && markgate set check
# Slow docs consistency check (src / docs / README) — inside the /check-docs
# skill body (LLM-led — see Pattern 2):
markgate set docs
# One pre-commit hook verifies both; the failing gate names itself:
markgate verify check || { echo "run the code check" >&2; exit 1; }
markgate verify docs || { echo "run the docs check" >&2; exit 1; }A working wire-up lives in go-to-k/cdkd:
.markgate.yml— gate definitions..claude/hooks/check-gate.sh— pre-commit hook that runsmarkgate verifyfor each gate./checkand/check-docsskills produce the markers (the latter has a diff-based short-circuit to keep the LLM cost low on internal src edits).
Scope: a parent gate that ANDs the freshness of its children. No own include: — the parent has no scope of its own, so its verdict is purely "every child is fresh."
Builds on use case 4. There, the hook had to call markgate verify once per child to surface a per-gate error. When the hook only needs one verdict ("can this commit proceed?"), a parent that composes the children collapses that into a single call.
# .markgate.yml — adds `pre-commit` on top of use case 4's gates
gates:
check:
hash: files
include:
- "src/**"
- "tests/**"
- "package.json"
docs:
hash: files
include:
- "src/**"
- "docs/**"
- "README.md"
pre-commit:
composes: [check, docs]Commands:
# Each child is set as its own check finishes (same as use case 4):
pnpm typecheck && pnpm lint && pnpm build && markgate set check
# Inside the /check-docs skill body (LLM-led — see Pattern 2):
markgate set docs
# One verify covers both:
markgate verify pre-commit || {
markgate status pre-commit >&2 # names the stale child in the note column
exit 1
}markgate set pre-commit is unconditional — the parent records its marker even if a child is stale. That's the right default for summary gates that observe child state.
Strict variant (requires) — same verify propagation, but markgate set <parent> is refused (exit 2) when any child is stale, and the error names the offending child. Reach for it when the parent gate represents a declaration that should be refused unless its dependencies are fresh — like pr-ready requiring check and docs, or merge-ok requiring all CI checks. See Gate dependencies for the full shape.
When markgate run -- <cmd> is invoked:
- It computes a hash of the current repo state.
- If a saved marker matches,
<cmd>is skipped (exit 0 immediately). - Otherwise
<cmd>runs. On success, the hash is saved as the new marker. On failure, the marker is left untouched.
(For the split shape, markgate set writes step 3's marker;
markgate verify does step 2's match check.)
# First run — nothing cached yet, so `pnpm build` runs and the pass is cached.
$ markgate run -- pnpm build
building...
passed in 7.2s
# Second run — nothing changed since the last success: instant skip.
$ markgate run -- pnpm build
# After you edit a file — cache is stale, `pnpm build` runs again.
$ echo '// fix typo' >> src/foo.ts
$ markgate run -- pnpm build
building...
passed in 7.1sThe marker is a small JSON file under .git/markgate/, one per
gate (the file name matches the gate name, e.g. default.json).
Not committed, not tracked, isolated per worktree. With
--state-dir <dir>, MARKGATE_STATE_DIR=<dir>, or state_dir:
in .markgate.yml, markers go to <dir>/ instead — see Sharing
markers. The
on-disk JSON layout is an implementation detail; don't parse it.
Note:
markgateis meant to run inside a git repository.
brew install go-to-k/tap/markgate# Latest
curl -fsSL https://raw.githubusercontent.com/go-to-k/markgate/main/install.sh | bash
# Pin a version
curl -fsSL https://raw.githubusercontent.com/go-to-k/markgate/main/install.sh | bash -s -- v0.1.0Pin a version per repo via .mise.toml:
[tools]
"ubi:go-to-k/markgate" = "0.2.0"Or one-shot:
mise use "ubi:go-to-k/markgate@0.2.0"go install github.com/go-to-k/markgate/cmd/markgate@latestLinux / macOS / Windows archives (amd64 / arm64 / 386) — see GitHub Releases.
Substitute pnpm build with your verification command. Use
markgate run -- when the hook itself runs the check, or
markgate verify when it sits in front of a separate markgate set
(see Pattern 2).
husky — .husky/pre-commit:
markgate run -- pnpm buildlefthook — lefthook.yml:
pre-commit:
commands:
check:
run: markgate run -- pnpm buildpre-commit framework — .pre-commit-config.yaml:
repos:
- repo: local
hooks:
- id: markgate-check
name: markgate check
entry: markgate run -- pnpm build
language: system
pass_filenames: falseClaude Code (PreToolUse) — .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"if": "Bash(git commit*)",
"hooks": [
{ "type": "command", "command": "markgate verify" }
]
}
]
}
}In your /check skill: pnpm build && markgate set. See
Pattern 2 for the
full flow.
Lives at $(git rev-parse --show-toplevel)/.markgate.yml (no
parent-dir walking).
markgate init writes a starter file at the repo root:
markgate init # writes .markgate.yml at the repo root
markgate init --force # overwrite an existing oneThe generated file enables the default gate with git-tree hash,
plus commented-out examples (an exclude list on git-tree and a
files-type gate) — uncomment what you need.
Per-gate fields:
| field | purpose |
|---|---|
hash |
git-tree (default) or files |
include |
glob list; required for hash: files |
exclude |
glob list |
state_dir |
optional override of marker storage location — see Sharing markers |
ttl |
optional wall-clock expiry for the marker — see Wall-clock expiry (ttl) |
composes |
child gate keys whose freshness is ANDed into this one — see Gate dependencies |
requires |
like composes, but set of this gate is refused unless every required child is fresh — see Gate dependencies |
Example:
gates:
default:
hash: git-tree
exclude:
- "vendor/**"
- "node_modules/**"
pre-pr:
hash: files
include:
- "docs/**"
- "README.md"
exclude:
- "**/*.txt"Each gate's key (the YAML map key — default, pre-pr above) must
match [a-z0-9][a-z0-9-]* (kebab-case ASCII). default is what
markgate set / verify use when no key argument is given:
markgate set # same as `markgate set default`
markgate set pre-pr # a second, independent gateThe hash field above picks one of two strategies:
| aspect | git-tree (default) |
files |
|---|---|---|
| What it hashes | HEAD + diff-vs-HEAD ∪ untracked-not-ignored |
whatever matches your include globs |
HEAD in the hash? |
Yes | No |
| Commits invalidate the marker? | Yes | Only if they touch in-scope files |
.gitignore respected? |
Yes (automatic) | No — scope is explicit |
| Needs config? | No | Yes (include required) |
When to use which:
git-tree= "re-verify on any repo change". Broad gates (pre-commit running lint/test/build). Addexcludepatterns to skipvendor/,node_modules/, etc. — HEAD-aware invalidation is kept.files= "re-verify only when these paths change, ignore other commits". Narrow gates (docs consistency, vuln scan rooted on a lockfile, coverage for one sub-tree).
Rule of thumb: start with git-tree (add exclude if needed).
Reach for files only when you specifically want the "ignore
commits that don't touch these paths" semantics.
ttl, composes, and requires are optional — the basic
gate pattern works without them. Skip the rest of this section
unless you hit one of the patterns above.
By default, a marker stays valid until something in the gate's scope changes. Some checks verify against state outside the repo that drifts on its own — a real-cloud destroy test that depends on AWS behaviour, a vulnerability database that gains new CVEs, an SDK that's revved upstream. For those, "nothing in the repo changed" isn't enough; you also want the marker to expire after a fixed amount of wall-clock time.
ttl: adds that expiry, per gate:
gates:
integ-destroy:
hash: git-tree
ttl: 7dWhen ttl is set, markgate verify (and the verify pre-flight inside
markgate run) treats the marker as a mismatch (exit 1) once
now - marker.created_at > ttl, even if the digest still matches.
markgate set always writes a fresh marker, so the countdown
restarts on every successful run. Omitting ttl (the default)
preserves existing behaviour exactly — markers never expire on time
alone.
Duration syntax is time.ParseDuration extended with d and w:
| unit | meaning |
|---|---|
s |
seconds |
m |
minutes (Go-standard, not months) |
h |
hours |
d |
days (24h) |
w |
weeks (168h) |
Mixed units compose: 1h30m, 1d12h, 2w3d. Months (mo) and
years (y) are intentionally not supported — month length is
ambiguous (28-31 days) and year length varies with leap years, so
neither rounds to a fixed duration. Use d/w for stable expiries.
A gate can declare child gates whose freshness is ANDed into its own. Two shapes are available:
composes(loose) —verifyof the parent is mismatch when any child (recursively) is mismatch.setof the parent is unconditional: marking the parent doesn't care whether children are fresh.requires(strict) — sameverifypropagation, andsetof the parent is refused (exit 2) unless every required child is fresh. The error names the offending child.
A gate may use one keyword but not both (config load error). Cycles and references to undeclared gates are also load errors.
gates:
# composes: parent fails verify if any composed child is stale,
# but `markgate set verify-pr` is always allowed.
verify-pr:
composes: [check, docs]
# requires: same propagation plus `markgate set pr-ready` is
# refused unless every required child is fresh. The pr-ready
# gate declares its own `include:`, since its marker captures
# the state being declared "ready".
pr-ready:
hash: files
include: ["src/**", "docs/**"]
requires: [check, docs]
check:
hash: files
include: ["src/**", "tests/**"]
docs:
hash: files
include: ["docs/**", "README.md"]If the parent declares its own include:, the parent's digest is
computed and ANDed with children — both must match. If the parent
omits include: (and only has composes/requires), there is no
own scope: the parent's freshness is purely the AND of its
children. This is the right default — without it, a parent gate
without include: would inherit the git-tree default and become
almost always stale.
A markgate set <parent> on a deps-only gate still records a
marker, so markgate clear <parent> keeps working as the user
expects.
- Reach for
composeswhen the parent is a summary gate that records "all the pieces I care about are currently fresh." Useful forverify-prshaped gates that combine independent checks; you set each child gate as that check finishes, and the parent's verdict tracks them automatically. - Reach for
requireswhen the parent gate represents a declaration that should be refused unless its dependencies are demonstrably fresh. Thesetitself is the declaration moment (e.g.,markgate set pr-readyaftercheck/docshave passed) — refusing tosetprevents the declaration from being recorded. - If unsure, start with
composes. It's the looser of the two and doesn't changesetsemantics; you can promote torequiresonce you know you wantsetto refuse.
Gates with composes: are typically deps-only (no include:) —
they exist purely to aggregate the verdicts of their dependencies.
Gates with requires: typically declare their own include:
since their marker captures the state being declared (so the
declaration can later be verified against the current state).
markgate set [key] Record the current state hash.
markgate verify [key] Exit 0 match, 1 mismatch (incl. ttl
expiry), 2 error.
markgate status [key] Show marker + match status (bare:
list every known gate).
markgate clear [key] Delete the marker (idempotent).
markgate run [key] -- <cmd>... Sugar for verify + <cmd> + set.
markgate init Write a starter .markgate.yml.
markgate config lint Warn on dead include/exclude globs,
unknown fields, and every rule that
would make `markgate run` exit 2
(unknown hash, ttl parse, undeclared
composes/requires refs, cycles).
Exit 0 clean, 1 warnings, 2 error.
--json emits an array of
{path, severity, message}.
markgate version Print the version.
markgate completion <shell> Emit a completion script (bash / zsh / fish / powershell).
markgate run passes stdio through and forwards SIGINT / SIGTERM
to <cmd>. On <cmd> failure, the marker is not updated and
<cmd>'s exit code is returned as-is.
verify, status, and run accept --explain / -e to print the
files currently in scope to stderr (with --json for a structured
form on stdout). See Debugging a stale gate.
When a gate sets
ttl:,verifyis no longer a pure function of the file tree — it also depends on the wall clock, returning mismatch oncenow - marker.created_at > ttleven if the digest still matches.
markgate run --explain --jsonis only stdout-clean on the skip path (when the gate matches). On mismatch the child runs withStdout = os.Stdout, so its output concatenates after the JSON object andjqwill choke. Use plain--explain(text form, stderr) when you want explain output alongside a real run, or compose withmarkgate verify <key> --explain --jsonahead of the child.
Exit codes follow the grep / diff convention, so || composes
naturally:
| exit | meaning |
|---|---|
| 0 | verified — state matches the marker, safe to skip |
| 1 | not verified — no marker, state differs, or TTL expired |
| 2 | error — not in a repo, bad config, bad key, etc. |
Without a [key], markgate status prints one row per known gate —
the union of gates: keys in .markgate.yml and marker files in the
state directory:
$ markgate status
KEY STATE AGE NOTE
check match 3m ago -
docs mismatch 1h ago digest differs
integ-destroy match 2d ago -
verify-pr no marker - (configured)
extra-gate match 5m ago (unconfigured)
Notes:
(configured)— gate is in.markgate.ymlbut no marker exists yet (run the check ormarkgate set <key>).(unconfigured)— a marker file is present but the gate isn't in.markgate.yml(stale from a renamed / deleted gate, or written by a script that bypassed the config).child <key> is stale— this gatecomposes/requiresthe named child, and the child's own row is mismatch. The bare list recurses through dependencies, so the parent's verdict here always agrees withmarkgate verify <parent>.
Exit code: 0 if every row matches, 1 if any row is mismatched or
missing a marker, 2 on internal error.
--json emits a machine-readable array (one object per row) using
the same state / note vocabulary as the table; markgate status <key> --json emits a single object with the same shape.
Behavior change in v0.x:
markgate status(no key) used to operate on thedefaultkey. It now lists every gate. Usemarkgate status defaultto keep the old single-key behavior.statusdeviates fromset/verify/clear's "no-arg = default key" rule on purpose: it's an introspection command (thinkgit status), so the bare form is the overview, not a shortcut to one specific gate.
set / verify / status / clear / run each accept these flags,
so one-off scopes don't need a .markgate.yml:
--hash git-tree|files Override hash type for this call.
--include <glob> Repeatable. Override the gate's include list.
--exclude <glob> Repeatable. Override the gate's exclude list.
--state-dir <path> Directory to store marker files. Takes
precedence over MARKGATE_STATE_DIR env and
state_dir: in .markgate.yml. Default:
<git-dir>/markgate. See "Sharing markers".
verify / status / run additionally accept a debug flag:
--explain, -e Print the in-scope file list to stderr ahead
of normal output. Does NOT change exit codes.
See "Debugging a stale gate" below.
--json With --explain: emit a single JSON object on
stdout instead of the text scope listing.
(--json without --explain is an error.)
Flag syntax is identical across hash types. With --hash files,
--include is required. Example — exclude vendor/ without any
config file:
markgate run --exclude 'vendor/**' -- pnpm build--explain lists the files currently in scope for the active
hasher (git-tree or files) after --include / --exclude
filtering. It is not a diff against the marker — markgate stores
only a single SHA-256, so "files that changed since set" cannot be
reconstructed post-hoc. What you see is the candidate set the hasher
would fold into the digest right now; if the wrong files appear (or
expected ones are missing), your globs are misconfigured.
$ markgate verify check -e
scope:
go.mod
internal/cli/helper.go
internal/cli/status.go
internal/state/state.go
state: mismatchThe state line uses one of match, mismatch, no marker — the
same vocabulary as the JSON form below. The exit code is unchanged
(0 / 1 / 2), so --explain is safe to leave on inside a hook while
debugging.
--explain --json emits a single object on stdout instead, suitable
for piping into jq:
{
"key": "check",
"scope": ["go.mod", "internal/cli/helper.go"],
"hasher": "git-tree",
"state": "mismatch"
}MARKGATE_STATE_DIR Marker storage directory. Same effect as
--state-dir and state_dir: in config.
Precedence: --state-dir > this env >
state_dir: in .markgate.yml > default.
markgate completion <shell> prints a completion script for bash,
zsh, fish, or powershell. Pipe it into the location your shell
loads.
# Bash (current session)
source <(markgate completion bash)
# Bash (persistent)
markgate completion bash > /etc/bash_completion.d/markgate
# Zsh — write into a directory on $fpath, e.g.
markgate completion zsh > "${fpath[1]}/_markgate"
# Fish
markgate completion fish > ~/.config/fish/completions/markgate.fish
# PowerShell
markgate completion powershell | Out-String | Invoke-ExpressionOnce installed, the gate-key positions on set / verify / status /
clear / run complete from the gates: map in .markgate.yml at
the repo top-level. With no .markgate.yml present, completion stays
silent — it never scans the marker directory or runs the gate.
By default, markers live under .git/markgate/ — strictly local. If
that's all you need, skip this section; the use cases above
all work with the default.
Read on if you want a check to skip in CI (or on a teammate's machine) based on a run that already happened elsewhere. Typical wins: coverage, vulnerability scan, e2e, image build — expensive and deterministic, redundant to re-run. Trust model differs by pattern (see Two patterns at a glance below); pick the one that matches your trust assumptions.
Three sources, in precedence order (flag beats env beats config):
--state-dir <dir> # per-invocation flag
MARKGATE_STATE_DIR=<dir> # environment variable
state_dir: <dir> # in .markgate.yml, per gate
The marker is written at <dir>/<key>.json (no extra markgate/
subdirectory). Relative paths resolve against the repo top-level, so
the location is stable regardless of cwd — identical on every machine
that checks out the repo.
Bare markgate status honors
the same precedence: it walks <dir>/ (with the override applied)
and lists every <key>.json it finds, alongside the gates: keys
in .markgate.yml.
Both use --state-dir / state_dir; the difference is whether the
marker is committed to the repo.
| aspect | A. Not committed (CI cache / artifact) | B. Committed |
|---|---|---|
| Marker in the repo? | No (typically gitignored, or outside the repo) | Yes, tracked in git |
| Works with hash type | git-tree or files |
files only — committing with git-tree breaks: the commit changes HEAD → digest is instantly stale |
| Local → CI sharing | Needs CI cache / artifact / shared volume | Just git push |
| Tamper surface | Whoever can write to the cache | Whoever has commit access |
| Extra infra | CI cache provider (e.g. actions/cache, actions/upload-artifact) |
None — git is enough |
| Best for | CI-internal reuse across runs; teams already on remote cache infra | Zero-infra local→CI sharing for files-hash gates (coverage, scans) |
Store the marker somewhere CI can pick it up, but keep it out of git.
.markgate-cache/ at the repo root is a conventional choice; any
path outside .git/ works. (If you'd rather commit the marker into
git so CI sees it without any cache layer, skip to
Pattern B — that's a different shape, not
a variant of this one.)
This is a required setup step on hash: git-tree, not optional
hygiene. Do this before your first markgate run:
# .gitignore — add the state dir you chose
/.markgate-cache/You can skip this only if:
- the state dir is outside the repo (e.g.
$RUNNER_TEMP/mg,/tmp/mg,$HOME/.cache/markgate), or - you're on
hash: files(gitignore then becomes hygiene, not required — see why below).
Why it's required on hash: git-tree (click to expand)
The git-tree digest hashes HEAD + diff-vs-HEAD ∪ untracked-not-ignored. The saved marker file is itself an untracked
file, so without gitignore:
markgate runcomputes digest_1 (before the marker exists) and saves the marker with digest_1.- The saved marker file now exists as untracked-not-ignored.
- The next
markgate verifycomputes digest_2, which includes the marker file. digest_2 ≠ digest_1 → mismatch → the check re-runs every time.
The feature is defeated on the first verify, before any commit. Gitignoring the state dir keeps the marker out of the digest.
hash: files sidesteps this: the marker is only in the digest if an
include glob matches it, which it normally won't. That's why
gitignore is optional on files.
Across runs of the same workflow — actions/cache, extending
the pre-image-push gate from Use case 2:
# .github/workflows/scan.yml
jobs:
scan:
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: .markgate-cache
key: markgate-scan-${{ github.sha }}
restore-keys: |
markgate-scan-
- run: markgate run pre-image-push --state-dir .markgate-cache -- trivy fs .Across jobs within one workflow — actions/upload-artifact →
actions/download-artifact. A setup job runs the expensive check
once; matrix jobs on the same commit download the marker and skip.
(expensive below is a placeholder key — define it in your
.markgate.yml using the Use cases as templates, or
pass --include / --hash via CLI flags.)
jobs:
verify:
steps:
- uses: actions/checkout@v4
- run: markgate run expensive --state-dir .markgate-cache -- make expensive-check
- uses: actions/upload-artifact@v4
with:
name: markgate-state
path: .markgate-cache
fan-out:
needs: verify
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: markgate-state
path: .markgate-cache
- run: markgate verify expensive --state-dir .markgate-cache || make expensive-checkKeep the state directory tracked in git and commit the marker with
the code. Works only with hash: files: git-tree would change HEAD
on the commit and invalidate the marker it just wrote.
Typical fit: coverage reports, image vulnerability scans — expensive, deterministic, and already re-running them on every push is waste when nothing in scope changed.
Coverage example, extending the pre-push gate from Use case 3:
# .markgate.yml
gates:
coverage:
hash: files
include:
- "src/**"
- "tests/**"
state_dir: .markgate-state# Locally, after a successful coverage run:
markgate run coverage -- go test -cover ./...
git add .markgate-state/coverage.json
git commit -m "bump coverage marker"
git push
# In CI (already sees the committed marker):
markgate verify coverage || go test -cover ./...Trust model: anyone with commit access can forge a skip. Use committed markers where commit-access already implies trust in the signal.
- Worktree isolation is lost when the dir is shared across
worktrees pointing at the same location. The default
.git/-based layout preserves isolation;--state-dirdoes not. - Relative paths resolve from the repo top-level, not cwd, so hook-invoked commands land in the same place regardless of where they run from.
- Signing is not yet implemented — markers are unsigned JSON. Tamper resistance depends on who can write to the directory (cache / repo).
- Why not just
git statusin the hook?git statustells you the tree is clean, not "did the check pass against this exact state."markgaterecords the success itself, so a passed check stays valid across hook invocations until something moves. - Does it work in git worktrees? Yes. Markers live under each
worktree's own
.git/dir, so they don't leak across worktrees. (This isolation is lost if you point--state-dirat a shared location.) - Do I need to gitignore anything? No for the default layout —
markers are under
.git/. If you use--state-dirpointing inside the repo, gitignore that directory. - What if I don't want HEAD in the hash? Use
hash: filesfor that gate. - Does
filesrespect.gitignore? No.filesis explicit scope by design. Usegit-treewhen you want.gitignore-aware behavior. (See Hashing strategies.) - Can markers be shared across machines / CI? Yes, via
--state-dir,MARKGATE_STATE_DIR, orstate_dir:in.markgate.yml. See Sharing markers for patterns and trust considerations. - Can the marker be tampered with? Yes — it's a JSON file under
.git/(or wherever--state-dirpoints). Trust whoever can write to that location. Signed markers are still a future consideration. - My check verifies external state (cloud APIs, vuln DB, …) — how
do I force re-runs even when the repo is unchanged? Add
ttl:to the gate. The marker is treated as a mismatch once it's older than the TTL, even if the digest still matches. - Why isn't
1mo(months) a valid TTL? Month length is ambiguous (28-31 days) and would makenow - created_at > 1monon-deterministic. Use30dor4wto be explicit. Same reasoning rules out1y. (See Wall-clock expiry.) - My gate keeps re-running. How do I debug it? Run
markgate verify <key> --explain(or-e). It lists the files currently in scope on stderr, so you can see whether yourinclude/excludeglobs match what you expect. Note: this is the current scope, not a diff against the marker — markgate stores a single hash, so "which files changed sinceset" can't be reconstructed. See Debugging a stale gate. - When should I use
composesvsrequires? Usecomposeswhen the parent is a summary gate ("all the pieces I care about are currently fresh") —setof the parent is allowed regardless of child state, butverifypropagates. Userequireswhen the parent gate represents a declaration that should be refused unless every dependency is fresh (e.g.,pr-readyrequiringcheckanddocs):setis the declaration itself and is refused with exit 2 if a required child is stale. See Gate dependencies.
MIT. See LICENSE.