From 94b36b38bfb17d6cc10cca5b2187aba47f5d92db Mon Sep 17 00:00:00 2001 From: Adan Date: Thu, 13 Aug 2026 19:29:32 +0000 Subject: [PATCH] Govern disk, gate merges, and find tokens where they live Three fixes, one per open issue: - Disk governance (#6): min_free_disk floor (per-check > top-level > 5G default, 0 disables), pre-start refusal with exit 16, mid-run statvfs sampling that kills through the existing escalation as non-cacheable disk_exhausted, and free-space observations in the verdict (schema v2). - Merge gating (#5): snapshot refuses only trees that don't honestly exist (unmerged entries, dirty submodules). Publish creates the commit git would: multi-parent merges from MERGE_HEAD (octopus included, stale heads filtered by ancestry), cherry-pick/revert finalization, squash and MERGE_MSG message precedence with git-faithful commentChar handling, rebase refused at publish only. Journal carries the full parent list (schema 2, back-compat reads). - Token chain (#4): GREENTREE_GITHUB_TOKEN -> GITHUB_TOKEN -> gh auth token, resolved once, source logged, never the value. Co-Authored-By: Claude Fable 5 Greentree-Change-Id: 5cfd4fdc9d3572e11dc3f2c42fec3730 --- README.md | 28 +- SECURITY.md | 27 +- docs/AGENTS.md | 3 +- docs/SPEC.md | 178 ++++++++++-- greentree.yaml | 6 + skills/greentree/SKILL.md | 3 +- src/cache.rs | 102 ++++++- src/commands.rs | 76 ++++-- src/config.rs | 207 ++++++++++++++ src/github.rs | 306 ++++++++++++++++++++- src/lib.rs | 24 +- src/publish.rs | 326 ++++++++++++++++++++-- src/runner.rs | 241 ++++++++++++++++- src/snapshot.rs | 35 +-- src/watch.rs | 54 +++- tests/it.rs | 550 +++++++++++++++++++++++++++++++++++++- 16 files changed, 2045 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index b5bd014..d4910d5 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,10 @@ flowchart LR again on resume. Every commit carries a `Greentree-Change-Id` trailer, the stable identity that will let stacks of verified changes survive rebases (see the [roadmap](docs/ROADMAP.md)). -4. **Statuses.** With a GitHub token in the environment, a pushed publish - posts a `greentree/` commit status on the new SHA. Statuses - satisfy branch protection required checks. +4. **Statuses.** With a GitHub token available (an env var, or `gh auth + token` if you're signed in with the `gh` CLI), a pushed publish posts a + `greentree/` commit status on the new SHA. Statuses satisfy + branch protection required checks. ## Install @@ -147,10 +148,11 @@ Every command takes `--json` (exactly one JSON object on stdout) and | 0 | success: checks green, published, or no-op | | 10 | a check ran and failed | | 11 | publish refused: tree not verified | -| 12 | unsnapshotable state: mid-merge, conflicted index, dirty submodule | +| 12 | repository state blocks it: conflicted index, dirty submodule, or publishing mid-rebase | | 13 | another greentree process holds the lock | | 14 | configuration error | | 15 | publish machinery failed: CAS refused, push rejected, no remote | +| 16 | free disk below `min_free_disk`: check refused, or aborted mid-run | Full JSON shapes and the verdict record schema are in [docs/SPEC.md](docs/SPEC.md). @@ -160,6 +162,8 @@ Full JSON shapes and the verdict record schema are in ```yaml version: 1 +min_free_disk: 10G # floor for every check; default 5G, "0" disables + checks: quick: run: pnpm lint && pnpm test @@ -169,6 +173,8 @@ checks: required_for_publish: true fresh: 30m # a pass older than this will not satisfy the gate timeout: 20m # default 15m; SIGTERM, 5s grace, SIGKILL + min_free_disk: 60G # this check's own floor: refuse to start below it, + # and kill it if free space falls under it mid-run snapshot: exclude: ["docs/generated/**"] # paths checks may write without @@ -200,6 +206,10 @@ freely with a running watcher. - Checks run under `/bin/sh -c` in their own process group with `GIT_*` redirection variables scrubbed: a `git` command inside your test suite sees your real repo, never greentree's shadow index. +- Checks are bounded in time and in disk: below `min_free_disk` free space + a check is refused before it starts (exit 16), and one that eats through + the floor mid-run is killed like a timeout, so a build cannot fill the + machine it runs on. - greentree never writes your index, your worktree, or your refs outside `refs/greentree/*`, except at publish, where the ref move is a compare-and-swap and the push records its lease explicitly. @@ -259,11 +269,11 @@ A commit that never went through a workspace has no status and cannot merge. To get green, route it through verification. That is the whole enforcement, with zero CI minutes. -greentree posts statuses with a GitHub token from the environment -(`GREENTREE_GITHUB_TOKEN` or `GITHUB_TOKEN`). Checks never see it: the -token is scrubbed from every check subprocess and reaches only the status -API call. Use a fine-grained token scoped to the repo with "Commit -statuses: write". +greentree posts statuses with a GitHub token resolved from +`GREENTREE_GITHUB_TOKEN`, then `GITHUB_TOKEN`, then (if neither is set and +`gh` is on PATH) `gh auth token`. Checks never see it: the token is +scrubbed from every check subprocess and reaches only the status API call. +Use a fine-grained token scoped to the repo with "Commit statuses: write". Verification runs wherever you invoke greentree. Pointing it at a persistent warm workspace instead of the local machine, so the cache and diff --git a/SECURITY.md b/SECURITY.md index 644cc38..80b4bde 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,15 +15,36 @@ on a machine that holds credentials or persistent state. ## Credentials -`attest` and `publish --push` post commit statuses with a GitHub token -read from `GREENTREE_GITHUB_TOKEN` or `GITHUB_TOKEN`. Two guarantees: +`attest` and `publish --push` post commit statuses with a GitHub token, +resolved in order: + +1. `GREENTREE_GITHUB_TOKEN` env +2. `GITHUB_TOKEN` env +3. `gh auth token --hostname github.com`, if a `gh` binary is on PATH + +An explicit env var always wins over `gh`, so it stays the way to pin or +override the token used. The `gh` fallback exists so a box where you have +already run `gh auth login` just works, without the token being pasted into +a shell profile, an exported environment variable, or your shell history. + +Where `gh` keeps that credential is `gh`'s business, and it varies: a +keyring where one is available, and a **plaintext `hosts.yml`** under +`~/.config/gh/` on a headless Linux box with no keyring — so treat the token +as on-disk unless you know otherwise. What using the fallback does buy is +that greentree never asks you to copy the token anywhere else, and never +puts it on a command line. Any failure resolving through `gh` (not +installed, not logged in, times out) falls through silently to "no token", +the same as before this chain existed. + +Two guarantees: - The token is scrubbed from every check subprocess, alongside the `GIT_*` redirection variables. A `run:` command cannot read it. The token reaches only the status API call. - Use a fine-grained token scoped to the single repository with "Commit statuses: write" and nothing else. That is the minimum blast radius if a - run is ever compromised. A classic PAT needs `repo:status`. + run is ever compromised. A classic PAT needs `repo:status`. This applies + whichever of the three sources supplies the token. ## What a green status means diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 28c754e..3c73503 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -20,8 +20,9 @@ it twice in a row is a no-op. Branch on exit code, not on output text: | 0 | published (or already published) | done | | 10 | a check failed | read `log_tail` in the JSON, fix, rerun | | 11 | tree changed after its last verification | rerun `gate` | -| 12 | repo mid-merge/rebase or dirty submodule | resolve, rerun | +| 12 | conflicted index or dirty submodule; or `gate` during a rebase | resolve conflicts (`git add`) / finish the rebase, rerun | | 13 | another greentree run in flight | wait, rerun | +| 16 | free disk below `min_free_disk` | free space, then rerun | While iterating, `greentree test --json` gives the same verdicts without publishing. `greentree status --json` answers "would publish succeed?" diff --git a/docs/SPEC.md b/docs/SPEC.md index a458985..17b9956 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -22,8 +22,13 @@ The dirty working tree is content-addressed as a git tree object: ones in `inputs:`), symlink targets outside the repo, dirty submodule contents (refused, exit 12). -Snapshotting is refused mid-merge/rebase/cherry-pick/revert and when the -real index holds unmerged entries. +**The honest-tree rule.** Snapshot answers one question — *is there an honest +tree here?* — so it is refused only when there is no single tree to name: the +real index holds unmerged (conflicted) entries, or a submodule is dirty (exit +12 either way). An unfinished merge, cherry-pick, revert or rebase step whose +conflicts have all been resolved is an ordinary tree: it snapshots, tests and +caches like any other. Whether a *commit* may be created from it is a separate +question, answered under [Publish](#publish). Trees a check actually runs against are **anchored** as commits at `refs/greentree/snapshots/` so the exact tested state survives @@ -39,9 +44,9 @@ A verdict binds an outcome to a **key**: `env_fingerprint` is a blake3 over the itemized digests of every file matching the config's `inputs:` globs (a missing input is recorded as -`absent`). Outcomes: `pass | fail | error | timeout | cancelled`. Only -`pass` and `fail` are cacheable. A run during which the tree hash changed is -`cancelled`: its result binds to no tree. +`absent`). Outcomes: `pass | fail | error | timeout | cancelled | +disk_exhausted`. Only `pass` and `fail` are cacheable. A run during which +the tree hash changed is `cancelled`: its result binds to no tree. Checks run as `/bin/sh -c ` from the repo root, in their own process group, with all `GIT_*` redirection variables scrubbed and @@ -52,12 +57,48 @@ check is not a way to leave daemons running. Output is streamed to `/greentree/logs/`, capped (head + tail retained), with a blake3 digest computed over the *full* stream. -Verdict record fields (JSON, `schema_version: 1`): `tree`, `check`, +### Disk + +Disk is governed like time. `min_free_disk` — per check, or top level for +all of them; default 5 GiB; `"0"` disables — is a floor of free space on the +filesystem holding the repo root, read from statvfs's *available* blocks +(`f_bavail`). Accepted values: a plain decimal byte count (`0`, `500`), or a +plain decimal with a `K`/`M`/`G`/`T` suffix in powers of 1024, optionally +followed by `B` and in any case (`60G`, `1.5G`, `2gb`). Nothing else — +exponent forms (`1e3`), `inf`/`nan`, and any value that does not fit in a +`u64` are configuration errors (exit 14) rather than a silently saturated +floor no filesystem could ever meet. + +- Below the floor at start, the check is **not started**: exit 16, no + verdict recorded. Like an unsnapshotable tree, this is a refusal, not an + outcome. A cache hit is never refused — no process runs. +- Falling below the floor *during* a run kills the process group through + the same SIGTERM → 5 s → SIGKILL escalation a timeout uses, and records + `disk_exhausted`: not cacheable, because the tree was never judged. Free + space is sampled at most once a second. +- With the floor disabled (`"0"`) free space is never sampled at all — not + even once — so a filesystem whose statvfs fails cannot refuse a run + through a floor that is switched off. The verdict's two disk fields are + then `0`, meaning "not observed". +- A refusal is fatal only to a one-shot verb. `watch` reports it like any + other cycle outcome and keeps watching: the disk that is full now may not + be in a minute, and dropping the watcher would leave the agent with no + verification at all. + +Verdict record fields (JSON, `schema_version: 2`): `tree`, `check`, `command`, `shell`, `check_hash`, `env_fingerprint`, `env_inputs` (itemized), `outcome`, `exit_code`, `signal`, `started`/`finished` (RFC 3339 UTC), -`duration_ms`, `finished_unix`, `os`, `arch`, `git_version`, -`greentree_version`, `snapshot_ref`, `log_path`, `log_bytes`, `log_digest`, -`log_truncated`. +`duration_ms`, `disk_free_start_bytes`, `disk_free_min_bytes`, +`finished_unix`, `os`, `arch`, `git_version`, `greentree_version`, +`snapshot_ref`, `log_path`, `log_bytes`, `log_digest`, `log_truncated`. + +The two disk fields are filesystem-level observations — free bytes when the +check started and the least seen while it ran — not a measurement of what +the check itself consumed: any other writer on the same filesystem confounds +attribution (and both are `0` when the floor is disabled: nothing was +observed). A record whose `schema_version` is not the current one is skipped +on load — checked explicitly, not left to whether it happens to still +deserialize — so a bump costs one cache miss per key, never an error. The store is an append-only JSONL log at `/greentree/verdicts.jsonl` (one record per line, last line wins per key, loaded into memory on open). @@ -67,8 +108,17 @@ store is machine-local and advisory, not a tamper-proof attestation. ### Publish -`publish` (and the publish half of `gate`): - +`publish` (and the publish half of `gate`) answers the other half of the +question snapshot leaves open: *can I create the commit git would create?* + +0. Refuse if a rebase is in progress (`rebase-merge`/`rebase-apply` — the + latter is also where `git am` keeps its state, so the refusal names both + continuations): the next commit belongs to that sequencer, which would + replay its todo list over ours. Exit 12, the same "fix the repository + state, then retry" class as an unsnapshotable tree. `test`, `status` and `watch` are + unaffected — their verdicts are tree-keyed, so they are still there for + `gate` or `attest` once `git rebase --continue` finishes. `gate` refuses + before running any check, not after. 1. Snapshot; require a `pass` verdict — fresh within the check's `fresh:` window, under the *current* env fingerprint — for every `required_for_publish` check (or every check if none is marked). This @@ -78,21 +128,69 @@ store is machine-local and advisory, not a tamper-proof attestation. unparseable journal is a loud error, never treated as "no pending publish". 2. If the tree already equals `HEAD^{tree}`: no-op (push-only if `--push`). -3. `git commit-tree -p ` with the message plus a - `Greentree-Change-Id: <32-hex>` trailer. The parent is recorded + Exempt while a merge is in progress — a merge commit records history, not + content, so an "ours" resolution still produces one. +3. `git commit-tree -p …` with the message plus a + `Greentree-Change-Id: <32-hex>` trailer. The parents are recorded explicitly in a journal before any ref moves. 4. Compare-and-swap `update-ref refs/heads/ ` — fails cleanly if anything else moved the branch. 5. Sync the real index to the new HEAD (`git read-tree HEAD`; retried under `index.lock` contention). Cost: staged-vs-unstaged distinction is dropped. + From step 4 on, the publish has happened: retiring the sequencer state and + syncing the index are housekeeping, and a failure in either is reported in + `warnings` (and on stderr) with the publish still reported as a success — + calling it a failure would invite a retry of a commit that already exists. 6. With `--push`: record the remote-tracking SHA first, then push with an explicit `--force-with-lease=:` (a bare lease is fooled by background fetches). Every step is journaled in `/greentree/publish-journal.json` -(schema-versioned); a rerun after a crash at any step resumes idempotently -and never mints a second change-id for the same publish. Detached HEAD is -refused. Publishing bypasses commit hooks by construction. +(schema-versioned; `schema_version: 2` records `parents` as a list, and a +schema-1 journal's single `parent` is still read). A rerun after a crash at +any step resumes idempotently and never mints a second change-id for the same +publish; the reuse check matches on tree **and** the whole parent list. +Detached HEAD is refused. Publishing bypasses commit hooks by construction. + +#### Finishing a merge, squash, cherry-pick or revert + +With one of those in progress and its conflicts resolved, publish writes the +commit `git commit` would have written: + +- **Parents**: HEAD, then every line of `MERGE_HEAD` — an octopus merge lists + one SHA per line, so a three-way octopus publishes a three-parent commit. + Exact repeats are dropped (`commit-tree` would drop them anyway). The + compare-and-swap expectation stays the first parent, the HEAD publish read. +- **Stale merge state is not a merge.** A `MERGE_HEAD` line naming a commit + HEAD already contains (`git merge-base --is-ancestor HEAD`) is + dropped: it is a file that outlived the commit which consumed it, and + honoring it would mint a second merge commit with a parent that adds + nothing. If every line is dropped, the publish is an ordinary + single-parent one — and the stale files are retired on success anyway. + `git merge --squash` is the same shape by design: no `MERGE_HEAD` at all, + so it publishes one single-parent commit. +- **Message precedence**: an explicit `-m` wins; otherwise the sequencer's + own message — `SQUASH_MSG` when it exists (what `git merge --squash` + leaves, and what `git commit` prefers), else `MERGE_MSG` (git writes it for + merges *and* for conflicted cherry-picks and reverts, where it carries the + picked commit's message) — with `core.commentChar` lines removed; otherwise + greentree's own `greentree: verified tree `. The change-id + trailer is appended either way. +- **`core.commentChar`** is honored as git honors it: unset means `#`, an + explicit single character means itself, and `auto` is resolved *per + message* — the first of `#;@!$%^&|:` that no line of that message starts + with — so a message whose lines begin with `#` keeps them, exactly as + `git commit` would. +- **State retired on success**, once the commit exists and the branch points + at it: `MERGE_HEAD`, `MERGE_MSG`, `SQUASH_MSG`, `MERGE_MODE`, + `CHERRY_PICK_HEAD`, `REVERT_HEAD`, `AUTO_MERGE`. `.git/sequencer/` is + deliberately left alone, so `git cherry-pick --continue` walks on through + the remaining commits. +- A resolution that reproduces HEAD's tree exactly is a no-op for a + cherry-pick or revert (nothing is committed, the state files stay put for + `git cherry-pick --skip`), and a real merge commit for a merge. On that + no-op path a stale `MERGE_HEAD`/`MERGE_MODE` is still removed; nothing + else is touched. ### Watch (v0.2) @@ -108,6 +206,9 @@ Semantics: - Relevance filter: paths under the git dir or matching `snapshot.exclude` never trigger or cancel; everything else does, and snapshot dedupe absorbs ignored-file noise (same tree = cache hit = no visible cycle). +- A refusal is not fatal: a cycle the disk floor stops (or any other + pre-start refusal) is reported and the watcher keeps watching, rerunning + on the next trigger. - The global flock is held only during a cycle; `test`/`gate` interleave between cycles. A second watcher is refused via `/greentree/watch.pid` (stale pidfiles are detected and @@ -131,9 +232,16 @@ check — context `greentree/`, state `success`, description - Compiled in via the default `github` cargo feature; `--no-default-features` builds the pure-git tool with no HTTP/TLS stack. -- Token: `GREENTREE_GITHUB_TOKEN` or `GITHUB_TOKEN` (classic PAT needs - `repo:status`; fine-grained needs "Commit statuses" read/write). No - token, or a non-github.com remote → posting is silently skipped. +- Token, resolved in order: `GREENTREE_GITHUB_TOKEN` env, then + `GITHUB_TOKEN` env, then (if a `gh` binary is on PATH) `gh auth token + --hostname github.com` — greentree only supports github.com remotes, so + the hostname is fixed. An env var always wins over `gh`. The `gh` step + is bounded by a short timeout and any failure (missing binary, non-zero + exit, empty output, timeout) falls through silently to the next source. + A classic PAT needs `repo:status`; fine-grained needs "Commit statuses" + read/write. No token from any of the three sources, or a non-github.com + remote → posting is silently skipped (`publish`) or exit 15 (`attest`, + which requires posting since that's its whole job). - Statuses, not Check Runs: the Checks API is GitHub-App-only; statuses work with a PAT and satisfy branch-protection required status checks (match on the context string). @@ -164,27 +272,44 @@ while working, commit and push with plain git, attest. Refusals use exit | 2 | CLI usage error | | 10 | a check ran and failed | | 11 | publish refused: tree not verified | -| 12 | unsnapshotable state (mid-merge, conflicted index, dirty submodule) | +| 12 | the repository state blocks the verb: conflicted index or dirty submodule (no honest tree), or publish during a rebase | | 13 | another greentree process holds the lock | | 14 | configuration error | | 15 | publish machinery failed (CAS refused, push rejected, no remote) | +| 16 | free disk below `min_free_disk`: check refused, or aborted mid-run | ## JSON output Every verb accepts `--json` and prints exactly one JSON object to stdout. Errors in JSON mode print `{"error": "...", "exit_code": N}`. Shapes: +Exit 16 has two shapes from `test`, and an agent branching on the code must +handle both: a **pre-start refusal** never ran anything, so it prints the +error object `{"error", "exit_code": 16}`; a check killed **mid-run** ran, so +it prints the full results object with that check's `outcome` set to +`disk_exhausted` (and `ok: false`). + - `test`: `{tree, ok, results: [{check, tree, outcome, cached, exit_code, duration_ms, log, log_tail?}]}`. `ok` is true only when every check passed **and** all verdicts bind to the same tree (an edit between checks makes `ok` false even if each check passed on its own tree). -- `status`: `{tree, branch, head, tree_at_head, publishable, - checks: [{check, state, finished}], pending_publish}` - where `state` ∈ `pass | fail | stale | missing | error | timeout | cancelled`. +- `status`: `{tree, branch, head, tree_at_head, merge_in_progress, + publishable, publish_blocked, checks: [{check, state, finished}], + pending_publish}`, where `publish_blocked` is null or the reason publish + would refuse regardless of the verdicts (a rebase in progress), in which + case `publishable` is false even with every check green, and `state` ∈ + `pass | fail | stale | missing` — status reads the verdict store, which + holds only the cacheable outcomes, so a check that timed out or was + cancelled reads as `missing` until it is rerun. `branch` is `null` when + HEAD is detached (mid-rebase, or a bare checkout); the human line prints + `(detached)`. `merge_in_progress` is true only while `MERGE_HEAD` names a + commit HEAD does not already contain — the state in which publish writes a + merge commit even though `tree_at_head` is true. `status` waits up to 3 s for the lock instead of failing with exit 13 while a check is running. - `publish`: `{tree, branch, noop, commit, change_id, pushed, resumed, - verified_by, statuses_posted, statuses_error}` + verified_by, statuses_posted, statuses_error}`, plus `warnings: [string]` + when post-commit housekeeping failed (omitted when empty) - `gate`: `{gate: "published", checks: [{check, tree, outcome, cached, duration_ms}], publish}` — `publish` is the same object the `publish` verb prints (statuses fields included) — or @@ -192,7 +317,10 @@ Errors in JSON mode print `{"error": "...", "exit_code": N}`. Shapes: - `attest`: `{commit, tree, checks, statuses_posted}` - `init`: `{config_written, config_path, checks, tree}` - `watch --json`: one line per visible cycle: - `{tree, results: [{check, outcome, cached, duration_ms}]}` + `{tree, results: [{check, outcome, cached, duration_ms}]}`. A cycle refused + before it started (free disk below the floor) prints the same shape with + `tree: null`, the refused check's `outcome` as `disk_exhausted`, and an + extra `error` naming the reason; the watcher keeps running. - `gc`: `{snapshots_pruned, snapshots_kept, logs_deleted, log_bytes_freed}` ## Namespaces owned by greentree diff --git a/greentree.yaml b/greentree.yaml index 04b919b..703020f 100644 --- a/greentree.yaml +++ b/greentree.yaml @@ -1,5 +1,11 @@ version: 1 +# A cargo build writes hundreds of megabytes; refuse to start rather than fill +# the disk of the box a check runs on. Kept low enough to admit a run on a +# nearly-full dev box or a stock CI runner (~14G free) — the floor is a +# backstop against filling a disk, not a capacity plan. +min_free_disk: 2G + checks: quick: run: cargo clippy --all-targets -- -D warnings diff --git a/skills/greentree/SKILL.md b/skills/greentree/SKILL.md index d6315db..e7c1cdb 100644 --- a/skills/greentree/SKILL.md +++ b/skills/greentree/SKILL.md @@ -32,10 +32,11 @@ Add `--push` to also push. Without it, `gate` commits locally only. | 0 | verified and committed | done | | 10 | a check failed | read `.log_tail` in the JSON, fix the code, run `gate` again | | 11 | tree not verified yet | run `greentree test --json`, then `gate` | -| 12 | repo mid-merge/rebase, or a dirty submodule | resolve it, then `gate` | +| 12 | conflicted index or dirty submodule; or `gate` during a rebase | `git add` the resolved files (a resolved merge/cherry-pick gates fine, and `gate` writes the merge commit); for a rebase, `git rebase --continue` first | | 13 | another greentree run is in progress | wait a moment, then retry | | 14 | config error | read `.error`; fix `greentree.yaml` | | 15 | publish/push failed | read `.error` (e.g. remote rejected, no token) | +| 16 | free disk below `min_free_disk` | free space (`cargo clean`, prune logs), then retry; lower the floor only deliberately | ## While iterating diff --git a/src/cache.rs b/src/cache.rs index faafaef..54e4835 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -13,10 +13,13 @@ use serde::{Deserialize, Serialize}; use crate::Result; -pub const VERDICT_SCHEMA_VERSION: u32 = 1; +/// 2 added the disk-observation fields. A record whose `schema_version` is +/// not this one is skipped on load (see `JsonStore::open`) — a bump costs one +/// cache miss per key, never an error. +pub const VERDICT_SCHEMA_VERSION: u32 = 2; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "snake_case")] pub enum Outcome { /// Check exited 0. Cacheable. Pass, @@ -28,6 +31,9 @@ pub enum Outcome { Timeout, /// The tree changed while the check ran; the result binds to no tree. Not cacheable. Cancelled, + /// Free disk fell below `min_free_disk` mid-run and the check was killed. + /// Like a timeout, the tree was never judged. Not cacheable. + DiskExhausted, } impl Outcome { @@ -41,6 +47,7 @@ impl Outcome { Outcome::Error => "error", Outcome::Timeout => "timeout", Outcome::Cancelled => "cancelled", + Outcome::DiskExhausted => "disk_exhausted", } } } @@ -82,6 +89,11 @@ pub struct Verdict { pub started: String, pub finished: String, pub duration_ms: u64, + /// Free bytes on the filesystem holding the repo when the check started, + /// and the least seen while it ran (equal when never sampled lower). + /// Filesystem-level observations: other writers confound attribution. + pub disk_free_start_bytes: u64, + pub disk_free_min_bytes: u64, /// Unix seconds of `finished`, used for freshness checks. pub finished_unix: u64, pub os: String, @@ -149,8 +161,15 @@ impl JsonStore { } // A line that fails to parse (partial append after a crash, // schema drift) is skipped: the store is a cache, never fatal. + // The version is checked EXPLICITLY rather than left to serde: + // a future schema that only adds optional fields would still + // deserialize into today's `Verdict` and hand back a record + // whose meaning we do not know. if let Ok(raw) = serde_json::from_str::>(line) { if let Ok(v) = serde_json::from_str::(raw.get()) { + if v.schema_version != VERDICT_SCHEMA_VERSION { + continue; + } verdicts.insert(v.key().as_string(), raw); } } @@ -203,3 +222,82 @@ impl From for crate::Error { crate::Error::Io(std::io::Error::other(e)) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn verdict() -> Verdict { + Verdict { + schema_version: VERDICT_SCHEMA_VERSION, + tree: "t".into(), + check: "c".into(), + command: "true".into(), + shell: "/bin/sh".into(), + check_hash: "h".into(), + env_fingerprint: "e".into(), + env_inputs: BTreeMap::new(), + outcome: Outcome::Pass, + exit_code: Some(0), + signal: None, + started: "2026-01-01T00:00:00Z".into(), + finished: "2026-01-01T00:00:01Z".into(), + duration_ms: 1000, + disk_free_start_bytes: 0, + disk_free_min_bytes: 0, + finished_unix: 0, + os: "linux".into(), + arch: "x86_64".into(), + git_version: "2.43.0".into(), + greentree_version: crate::VERSION.into(), + snapshot_ref: "refs/greentree/snapshots/t".into(), + log_path: "/dev/null".into(), + log_bytes: 0, + log_digest: "d".into(), + log_truncated: false, + } + } + + #[test] + fn records_from_another_schema_version_are_skipped() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let mut value = serde_json::to_value(verdict()).expect("serialize"); + // A future record: every field this version knows, plus a version we + // do not. It parses cleanly — only the explicit check rejects it. + value["schema_version"] = serde_json::json!(999); + // A schema-1 record: the disk fields did not exist yet. + let mut v1 = serde_json::to_value(verdict()).expect("serialize"); + v1["schema_version"] = serde_json::json!(1); + v1["tree"] = serde_json::json!("t1"); + let obj = v1.as_object_mut().unwrap(); + obj.remove("disk_free_start_bytes"); + obj.remove("disk_free_min_bytes"); + + std::fs::write( + dir.path().join("verdicts.jsonl"), + format!("{v1}\n{value}\n\nnot json at all\n"), + ) + .expect("write store"); + + let store = JsonStore::open(dir.path()).expect("open"); + assert!( + store.verdicts.is_empty(), + "loaded records from a foreign schema: {:?}", + store.verdicts.keys().collect::>() + ); + assert!(store.get(&verdict().key()).is_none()); + } + + #[test] + fn a_current_record_round_trips() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let mut store = JsonStore::open(dir.path()).expect("open"); + let v = verdict(); + store.put(v.clone()).expect("put"); + let reopened = JsonStore::open(dir.path()).expect("reopen"); + assert_eq!( + reopened.get(&v.key()).map(|r| r.outcome), + Some(Outcome::Pass) + ); + } +} diff --git a/src/commands.rs b/src/commands.rs index 7c8b95a..b0871fd 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -228,6 +228,9 @@ fn test(cli: &Cli, git: &Git, only: Option<&str>, no_cache: bool) -> crate::Resu Ok(match worst { Outcome::Pass => exit::OK, Outcome::Fail => exit::CHECK_FAILED, + // Aborted for the same reason a pre-start refusal fires, so it + // reports the same code: an agent branches on 16 either way. + Outcome::DiskExhausted => exit::DISK_FLOOR, _ => exit::ERROR, }) } @@ -246,14 +249,23 @@ fn status(cli: &Cli, git: &Git) -> crate::Result { Some(h) => git.rev_parse_opt(&format!("{h}^{{tree}}"))?, None => None, }; + // Detached HEAD (mid-rebase, or a bare checkout) has no branch name: + // JSON says null rather than an empty string that reads like a branch + // whose name we failed to print. let branch = git .run_unchecked(["symbolic-ref", "-q", "--short", "HEAD"])? .stdout; let branch = String::from_utf8_lossy(&branch).trim().to_string(); + let branch = (!branch.is_empty()).then_some(branch); let now = SystemTime::now(); + // A rebase leaves an honest, testable tree that still cannot be + // published; `publishable` must not promise a publish that would refuse. + let blocked = crate::publish::refuse_unpublishable(git) + .err() + .map(|e| e.to_string()); let mut checks = Vec::new(); - let mut publishable = true; + let mut publishable = blocked.is_none(); for (name, check) in cfg.required_checks() { let key = VerdictKey { tree: tree.clone(), @@ -281,6 +293,10 @@ fn status(cli: &Cli, git: &Git) -> crate::Result { let journal = load_journal(git)?; let at_head = head_tree.as_deref() == Some(&*tree); + // Mid-merge, "already committed at HEAD" is not the whole truth: publish + // still writes the merge commit, because a merge records history rather + // than content. + let merging = crate::publish::merge_in_progress(git)?; if cli.json { println!( @@ -290,7 +306,9 @@ fn status(cli: &Cli, git: &Git) -> crate::Result { "branch": branch, "head": head, "tree_at_head": at_head, + "merge_in_progress": merging, "publishable": publishable, + "publish_blocked": blocked, "checks": checks.iter().map(|(name, state, v)| json!({ "check": name, "state": state, @@ -302,12 +320,23 @@ fn status(cli: &Cli, git: &Git) -> crate::Result { }) ); } else { - println!("tree {} branch {}", short(&tree), branch); + println!( + "tree {} branch {}", + short(&tree), + branch.as_deref().unwrap_or("(detached)") + ); for (name, state, _) in &checks { println!(" {name}: {state}"); } if at_head { - println!(" tree already committed at HEAD"); + if merging { + println!( + " tree already committed at HEAD, but a merge is in progress — \ + publish still writes the merge commit" + ); + } else { + println!(" tree already committed at HEAD"); + } } if let Some(j) = &journal { println!( @@ -315,14 +344,17 @@ fn status(cli: &Cli, git: &Git) -> crate::Result { short(&j.tree) ); } - println!( - " publish: {}", - if publishable { - "would succeed" - } else { - "would refuse" - } - ); + match &blocked { + Some(reason) => println!(" publish: would refuse ({reason})"), + None => println!( + " publish: {}", + if publishable { + "would succeed" + } else { + "would refuse" + } + ), + } } Ok(exit::OK) } @@ -331,6 +363,9 @@ fn gate(cli: &Cli, git: &Git, push: bool, message: Option) -> crate::Res let _lock = lock::acquire(&git.state_dir())?; let cfg = Config::effective(&git.root)?; let mut store = JsonStore::open(&git.state_dir())?; + // Refuse BEFORE running the checks: gate ends in a publish, so a state + // that cannot publish should not cost the agent a full check run first. + crate::publish::refuse_unpublishable(git)?; let mut results: Vec<(String, RunResult)> = Vec::new(); let mut pre_tree: Option = None; @@ -361,6 +396,7 @@ fn gate(cli: &Cli, git: &Git, push: bool, message: Option) -> crate::Res } return Ok(match outcome { Outcome::Fail => exit::CHECK_FAILED, + Outcome::DiskExhausted => exit::DISK_FLOOR, _ => exit::ERROR, }); } @@ -478,10 +514,13 @@ fn maybe_post_statuses( let Some(commit) = &report.commit else { return Statuses::default(); }; - if crate::github::token_from_env().is_none() { - tracing::debug!("no GitHub token in env; skipping status posting"); + // Resolved ONCE and threaded through: the `gh` step of the chain is a + // subprocess spawn, and a presence check followed by a second resolve + // inside the poster would pay for it twice (up to 10 s). + let Some(token) = crate::github::resolve_token() else { + tracing::debug!("no GitHub token available; skipping status posting"); return Statuses::default(); - } + }; match crate::github::remote_url(git) { Some(url) if crate::github::parse_github_remote(&url).is_some() => {} _ => return Statuses::default(), @@ -497,7 +536,7 @@ fn maybe_post_statuses( } else { report.verified_by.clone() }; - match crate::github::post_statuses(git, commit, &report.tree, &checks) { + match crate::github::post_statuses_with_token(git, commit, &report.tree, &checks, &token) { Ok(posted) => Statuses { posted, error: None, @@ -549,13 +588,16 @@ fn names(cfg: &Config) -> String { cfg.checks.keys().cloned().collect::>().join(", ") } -fn mark(o: Outcome) -> &'static str { +/// One check outcome, as every verb prints it (watch included, so an agent +/// reads the same words everywhere). +pub(crate) fn mark(o: Outcome) -> &'static str { match o { Outcome::Pass => "✓", Outcome::Fail => "✗", Outcome::Error => "error", Outcome::Timeout => "timeout", Outcome::Cancelled => "cancelled (tree changed mid-run)", + Outcome::DiskExhausted => "aborted (free disk fell below min_free_disk)", } } @@ -564,7 +606,7 @@ fn worse(a: Outcome, b: Outcome) -> Outcome { let rank = |o: Outcome| match o { Pass => 0, Fail => 2, - Error | Timeout | Cancelled => 1, + Error | Timeout | Cancelled | DiskExhausted => 1, }; if rank(b) > rank(a) { b diff --git a/src/config.rs b/src/config.rs index ae58498..a7b375a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,10 @@ use crate::{Error, Result}; pub const CONFIG_FILE: &str = "greentree.yaml"; pub const DEFAULT_TIMEOUT: &str = "15m"; +/// Free disk a check must have before it is allowed to start, when neither +/// the check nor the config names a floor. A build that fills the disk of +/// the box it runs on is a worse failure than a refusal to start. +pub const DEFAULT_MIN_FREE_DISK: u64 = 5 * 1024 * 1024 * 1024; /// The pinned shell identity for `run:` commands — part of the contract. pub const SHELL: &str = "/bin/sh"; @@ -27,6 +31,10 @@ pub struct Config { /// itemized into the environment fingerprint. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub inputs: Vec, + /// Free-disk floor for every check; a per-check `min_free_disk` wins. + /// Unset falls back to [`DEFAULT_MIN_FREE_DISK`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_free_disk: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -44,6 +52,43 @@ pub struct Check { /// Run this check from `greentree watch` when the tree settles. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub watch: bool, + /// Refuse to start (and abort mid-run) below this much free disk on the + /// filesystem holding the repo, e.g. "60G". "0" disables the floor for + /// this check. Overrides the top-level `min_free_disk`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_free_disk: Option, +} + +/// A free-disk floor, in bytes. Written as a human-unit string ("60G", +/// "500M") or a plain byte count; K/M/G/T are powers of 1024. Zero disables +/// the floor for its scope. Parsed at config load, so a typo is a +/// configuration error, not a surprise at run time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct DiskSize(pub u64); + +impl<'de> Deserialize<'de> for DiskSize { + fn deserialize>(d: D) -> std::result::Result { + struct V; + impl serde::de::Visitor<'_> for V { + type Value = DiskSize; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a byte count or a size like \"60G\"") + } + fn visit_u64(self, v: u64) -> std::result::Result { + Ok(DiskSize(v)) + } + fn visit_i64(self, v: i64) -> std::result::Result { + u64::try_from(v) + .map(DiskSize) + .map_err(|_| E::custom(format!("negative size {v}"))) + } + fn visit_str(self, s: &str) -> std::result::Result { + parse_disk_size(s).map(DiskSize).map_err(E::custom) + } + } + d.deserialize_any(V) + } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -84,6 +129,68 @@ fn parse_duration(s: &str) -> Result { humantime::parse_duration(s).map_err(|e| Error::Config(format!("invalid duration {s:?}: {e}"))) } +/// Parse a `min_free_disk` value: a plain decimal byte count ("500", "0") or a +/// decimal with a K/M/G/T suffix (powers of 1024, optional trailing "B", +/// case-insensitive) — "60G", "1.5G", "2gb". Nothing else: exponent, `inf` and +/// `nan` spellings are rejected rather than quietly accepted by `f64::parse`, +/// and a value that would not fit in a `u64` is an error rather than a silent +/// saturation to "no disk on earth satisfies this floor". +/// The error is a bare string so it composes with serde's own context. +pub fn parse_disk_size(s: &str) -> std::result::Result { + let bad = || format!("invalid size {s:?}: expected bytes or a size like \"60G\""); + let t = s.trim(); + // "60GB" means "60G"; a trailing B on its own is just bytes. + let t = t.strip_suffix(['b', 'B']).unwrap_or(t); + let (num, scale) = match t.chars().last() { + None => return Err(bad()), + Some(c) if c.is_ascii_digit() => (t, 1u64), + Some(c) => { + let scale = match c.to_ascii_uppercase() { + 'K' => 1u64 << 10, + 'M' => 1u64 << 20, + 'G' => 1u64 << 30, + 'T' => 1u64 << 40, + _ => return Err(bad()), + }; + (&t[..t.len() - c.len_utf8()], scale) + } + }; + let num = num.trim(); + // Plain decimals only: `f64::parse` would otherwise take "1e3", "inf" and + // "NaN", none of which is a size a human wrote on purpose. + if num.is_empty() + || !num.chars().all(|c| c.is_ascii_digit() || c == '.') + || !num.chars().any(|c| c.is_ascii_digit()) + || num.matches('.').count() > 1 + { + return Err(bad()); + } + let value: f64 = num.parse().map_err(|_| bad())?; + let bytes = value * scale as f64; + if !bytes.is_finite() || bytes >= u64::MAX as f64 { + return Err(format!( + "size {s:?} is larger than {} bytes", + u64::MAX as u128 + )); + } + Ok(bytes as u64) +} + +/// Render a byte count in the units `min_free_disk` accepts, for messages. +pub fn format_bytes(bytes: u64) -> String { + for (suffix, scale) in [ + ("T", 1u64 << 40), + ("G", 1u64 << 30), + ("M", 1u64 << 20), + ("K", 1u64 << 10), + ] { + if bytes >= scale { + return format!("{:.1}{suffix}", bytes as f64 / scale as f64); + } + } + format!("{bytes}") +} + impl Config { /// Load greentree.yaml, or fall back to auto-detection. pub fn effective(root: &Path) -> Result { @@ -143,6 +250,7 @@ impl Config { fresh: None, timeout: None, watch: true, + min_free_disk: None, }, ); Some(Config { @@ -150,9 +258,20 @@ impl Config { checks, snapshot: SnapshotCfg::default(), inputs: Vec::new(), + min_free_disk: None, }) } + /// Free disk a check must have to run: per-check `min_free_disk`, else + /// the top-level one, else [`DEFAULT_MIN_FREE_DISK`]. Zero disables it. + pub fn disk_floor(&self, check: &Check) -> u64 { + check + .min_free_disk + .or(self.min_free_disk) + .map(|d| d.0) + .unwrap_or(DEFAULT_MIN_FREE_DISK) + } + /// Checks `greentree watch` runs on each settle. pub fn watch_checks(&self) -> Vec<(&String, &Check)> { self.checks.iter().filter(|(_, c)| c.watch).collect() @@ -228,3 +347,91 @@ fn hash_input(root: &Path, entry: &Path, items: &mut BTreeMap) - } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn disk_sizes_parse_in_powers_of_1024() { + assert_eq!(parse_disk_size("0"), Ok(0)); + assert_eq!(parse_disk_size("500"), Ok(500)); + assert_eq!(parse_disk_size("1K"), Ok(1024)); + assert_eq!(parse_disk_size("500M"), Ok(500 * 1024 * 1024)); + assert_eq!(parse_disk_size(" 60G "), Ok(60 * 1024 * 1024 * 1024)); + assert_eq!(parse_disk_size("100T"), Ok(100 * (1u64 << 40))); + assert_eq!(parse_disk_size("2gb"), Ok(2 * 1024 * 1024 * 1024)); + assert_eq!(parse_disk_size("1.5G"), Ok(1024 * 1024 * 1024 * 3 / 2)); + for garbage in ["", " ", "G", "60X", "sixty", "-1", "6 0G", "60G7"] { + assert!( + parse_disk_size(garbage).is_err(), + "{garbage:?} must not parse" + ); + } + } + + #[test] + fn oversized_and_non_decimal_sizes_are_errors_not_surprises() { + // Saturating to u64::MAX would turn a typo into a floor no machine can + // ever meet — every check refused, with the config looking fine. + for huge in ["99999999T", "18446744073709551616", "1000000000000G"] { + let err = parse_disk_size(huge).expect_err("{huge:?} must not parse"); + assert!(err.contains("larger than"), "unhelpful error: {err}"); + } + // f64::parse accepts these; a size string must not. + for weird in ["1e3", "1E3", "inf", "NaN", "1.2.3", "."] { + assert!(parse_disk_size(weird).is_err(), "{weird:?} must not parse"); + } + // The largest value that still fits stays accepted. + assert_eq!(parse_disk_size("15T"), Ok(15 * (1u64 << 40))); + } + + #[test] + fn disk_size_deserializes_from_string_or_number() { + let cfg: Config = serde_yaml::from_str( + "version: 1\nmin_free_disk: 0\nchecks:\n a:\n run: 'true'\n min_free_disk: \"60G\"\n", + ) + .unwrap(); + assert_eq!(cfg.min_free_disk, Some(DiskSize(0))); + assert_eq!( + cfg.checks["a"].min_free_disk, + Some(DiskSize(60 * 1024 * 1024 * 1024)) + ); + assert!(serde_yaml::from_str::( + "version: 1\nchecks:\n a:\n run: 'true'\n min_free_disk: nope\n" + ) + .is_err()); + } + + #[test] + fn floor_resolution_prefers_the_narrowest_scope() { + let mut cfg = Config::detect(Path::new(".")).expect("this repo has Cargo.toml"); + let mut check = cfg.checks["test"].clone(); + assert_eq!( + cfg.disk_floor(&check), + DEFAULT_MIN_FREE_DISK, + "built-in default" + ); + + cfg.min_free_disk = Some(DiskSize(20 * 1024 * 1024 * 1024)); + assert_eq!(cfg.disk_floor(&check), 20 * 1024 * 1024 * 1024, "top-level"); + + check.min_free_disk = Some(DiskSize(60 * 1024 * 1024 * 1024)); + assert_eq!( + cfg.disk_floor(&check), + 60 * 1024 * 1024 * 1024, + "per-check wins" + ); + + check.min_free_disk = Some(DiskSize(0)); + assert_eq!(cfg.disk_floor(&check), 0, "per-check zero disables"); + } + + #[test] + fn byte_counts_render_in_config_units() { + assert_eq!(format_bytes(0), "0"); + assert_eq!(format_bytes(512), "512"); + assert_eq!(format_bytes(5 * 1024 * 1024 * 1024), "5.0G"); + assert_eq!(format_bytes(100 * (1u64 << 40)), "100.0T"); + } +} diff --git a/src/github.rs b/src/github.rs index f4ab816..08dc699 100644 --- a/src/github.rs +++ b/src/github.rs @@ -7,6 +7,14 @@ //! post never fails the publish; rerunning `publish --push` re-posts //! (same context = overwrite = idempotent). +use std::ffi::OsStr; +use std::io::Read; +use std::os::unix::process::CommandExt; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use nix::sys::signal::{killpg, Signal}; +use nix::unistd::Pid; use serde::Serialize; use crate::git::Git; @@ -15,6 +23,13 @@ use crate::{Error, Result}; pub use crate::TOKEN_ENVS; +/// How long we give `gh auth token` before treating it as unavailable. `gh` +/// only reads a local credential store for this, so anything near this +/// bound means something is wrong (hung prompt, broken install) rather than +/// slow — better to fall through than hang `attest`/`publish`. +const GH_AUTH_TIMEOUT: Duration = Duration::from_secs(5); +const GH_POLL: Duration = Duration::from_millis(20); + #[derive(Debug, PartialEq, Eq)] pub struct RepoSlug { pub owner: String, @@ -42,10 +57,103 @@ pub fn parse_github_remote(url: &str) -> Option { }) } -pub fn token_from_env() -> Option { - TOKEN_ENVS - .iter() - .find_map(|k| std::env::var(k).ok().filter(|v| !v.is_empty())) +/// Ask the local `gh` for a github.com token — greentree only ever talks to +/// github.com, so the hostname is fixed rather than derived from the +/// remote. `path_override`, when set, replaces the child's `PATH` (tests +/// use it to point at a fake `gh` without touching the process's real +/// environment). Any failure — no `gh` on PATH, non-zero exit, empty +/// stdout, or a timeout — is reported as `None`: this is a convenience +/// fallback, not a hard dependency, so it must never itself error. +/// +/// Same subprocess discipline as a check run (runner.rs): its own process +/// group, so a timeout kills the credential helpers `gh` forks rather than +/// just `gh` itself, and stdout is drained by a thread so a chatty `gh` +/// cannot fill the pipe and deadlock the wait loop. +fn gh_auth_token(path_override: Option<&OsStr>) -> Option { + let mut cmd = Command::new("gh"); + cmd.args(["auth", "token", "--hostname", "github.com"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .process_group(0); + if let Some(path) = path_override { + cmd.env("PATH", path); + } + let mut child = cmd.spawn().ok()?; + let pgid = Pid::from_raw(child.id() as i32); + let mut stdout = child.stdout.take()?; + let reader = std::thread::spawn(move || { + let mut out = String::new(); + let _ = stdout.read_to_string(&mut out); + out + }); + + let deadline = Instant::now() + GH_AUTH_TIMEOUT; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => std::thread::sleep(GH_POLL), + // Hung (or unwaitable): kill the whole group rather than block + // attest/publish on it, and reap so nothing is left behind. + _ => { + let _ = killpg(pgid, Signal::SIGKILL); + let _ = child.wait(); + let _ = reader.join(); + return None; + } + } + }; + // `gh` is reaped; anything it forked that still holds the pipe would keep + // the reader thread blocked, so the group dies here either way. + let _ = killpg(pgid, Signal::SIGKILL); + let out = reader.join().ok()?; + if !status.success() { + return None; + } + let token = out.trim(); + (!token.is_empty()).then(|| token.to_string()) +} + +/// Which of the three chain steps supplied the token — for logging, never +/// for the value itself. +fn resolve_token_with( + env: impl Fn(&str) -> Option, + gh: impl FnOnce() -> Option, +) -> Option { + if let Some(v) = env("GREENTREE_GITHUB_TOKEN") { + tracing::debug!("token source: GREENTREE_GITHUB_TOKEN env"); + return Some(v); + } + if let Some(v) = env("GITHUB_TOKEN") { + tracing::debug!("token source: GITHUB_TOKEN env"); + return Some(v); + } + if let Some(v) = gh() { + tracing::debug!("token source: gh auth token"); + return Some(v); + } + None +} + +fn env_var_nonempty(key: &str) -> Option { + std::env::var(key).ok().filter(|v| !v.is_empty()) +} + +/// Resolve a GitHub token: `GREENTREE_GITHUB_TOKEN` env, then `GITHUB_TOKEN` +/// env, then (if `gh` is on PATH) `gh auth token --hostname github.com`. +/// This is the chain both `attest` and a pushed `publish` use; env wins so +/// an explicit override always beats whatever `gh` has cached. +pub fn resolve_token() -> Option { + resolve_token_with(env_var_nonempty, || gh_auth_token(None)) +} + +/// The exit-15 error when none of the three chain steps produced a token. +fn missing_token_error() -> Error { + Error::Publish( + "no GitHub token: GREENTREE_GITHUB_TOKEN unset, GITHUB_TOKEN unset, and \ + `gh auth token` unavailable — set one or run `gh auth login`" + .into(), + ) } /// URL of the repo's first remote, if any. @@ -71,13 +179,17 @@ pub struct StatusEntry { pub description: String, } -/// Post one `greentree/` status per entry on the commit. `publish` +/// Post one `greentree/` status per entry on the commit, with a token +/// the caller already resolved — resolving is a subprocess spawn in the `gh` +/// case, so it happens once per command, never once per call site. `publish` /// and `attest` only ever post successes: unverified trees are refused /// before this point. Returns the contexts posted. -pub fn post_entries(git: &Git, commit: &str, entries: &[StatusEntry]) -> Result> { - let token = token_from_env().ok_or_else(|| { - Error::Publish("no GREENTREE_GITHUB_TOKEN or GITHUB_TOKEN in the environment".into()) - })?; +pub fn post_entries( + git: &Git, + commit: &str, + entries: &[StatusEntry], + token: &str, +) -> Result> { let url = remote_url(git) .ok_or_else(|| Error::Publish("no git remote with a URL configured".into()))?; let slug = parse_github_remote(&url) @@ -113,12 +225,26 @@ pub fn post_entries(git: &Git, commit: &str, entries: &[StatusEntry]) -> Result< Ok(posted) } -/// Success statuses for verified checks (the publish/attest path). +/// Success statuses for verified checks (the attest path), resolving the +/// token itself. Callers that already hold one use +/// [`post_statuses_with_token`]. pub fn post_statuses( git: &Git, commit: &str, tree: &str, checks: &[String], +) -> Result> { + let token = resolve_token().ok_or_else(missing_token_error)?; + post_statuses_with_token(git, commit, tree, checks, &token) +} + +/// Success statuses for verified checks, with an already-resolved token. +pub fn post_statuses_with_token( + git: &Git, + commit: &str, + tree: &str, + checks: &[String], + token: &str, ) -> Result> { let entries: Vec = checks .iter() @@ -128,12 +254,29 @@ pub fn post_statuses( description: format!("verified tree {}", short(tree)), }) .collect(); - post_entries(git, commit, &entries) + post_entries(git, commit, &entries, token) } #[cfg(test)] mod tests { use super::*; + use std::os::unix::fs::PermissionsExt; + + /// Write an executable shell script named `gh` into a fresh temp dir and + /// return (the dir, its path as an OsString) so a test can point + /// `gh_auth_token`'s `path_override` at it. This never touches the real + /// process environment (PATH, GITHUB_TOKEN, ...), so these tests are + /// safe to run in parallel with everything else in the suite: no + /// shared, global, mutable state to race on. + fn fake_gh(body: &str) -> (tempfile::TempDir, std::ffi::OsString) { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("gh"); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write fake gh"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) + .expect("chmod fake gh"); + let path = dir.path().as_os_str().to_owned(); + (dir, path) + } #[test] fn parses_github_remote_forms() { @@ -161,4 +304,145 @@ mod tests { assert!(parse_github_remote(url).is_none(), "accepted {url}"); } } + + #[test] + fn gh_auth_token_returns_the_trimmed_token_on_success() { + let (_dir, path) = fake_gh("echo ' gh-fake-token '"); + assert_eq!(gh_auth_token(Some(&path)).as_deref(), Some("gh-fake-token")); + } + + #[test] + fn gh_auth_token_falls_through_on_nonzero_exit() { + let (_dir, path) = fake_gh("echo would-be-a-token; exit 1"); + assert_eq!(gh_auth_token(Some(&path)), None); + } + + #[test] + fn gh_auth_token_falls_through_on_empty_output() { + let (_dir, path) = fake_gh("exit 0"); + assert_eq!(gh_auth_token(Some(&path)), None); + } + + #[test] + fn gh_auth_token_falls_through_when_gh_is_not_on_path() { + // An empty PATH means the child spawn itself fails to find `gh`. + let dir = tempfile::TempDir::new().expect("tempdir"); + assert_eq!( + gh_auth_token(Some(dir.path().as_os_str())), + None, + "no gh on PATH must fall through, not error" + ); + } + + #[test] + fn gh_auth_token_is_bounded_by_a_timeout() { + // A `gh` that hangs must not hang attest/publish forever; the + // timeout kills it and the chain falls through. + // Absolute path: the fake-gh dir is the *only* thing on the child's + // PATH, so a bare `sleep` would fail to resolve and this script + // would race past it instead of actually hanging. + let (_dir, path) = fake_gh("/bin/sleep 30; echo late-token"); + let start = Instant::now(); + assert_eq!(gh_auth_token(Some(&path)), None); + assert!( + start.elapsed() < Duration::from_secs(10), + "gh_auth_token did not respect its timeout: took {:?}", + start.elapsed() + ); + } + + #[test] + fn env_vars_win_over_gh_even_when_gh_is_on_path() { + // A real, invocable fake `gh` that would hand back a token if asked + // — proves the env branches short-circuit before `gh` ever runs, by + // checking the marker file it would have written stays absent. + let dir = tempfile::TempDir::new().expect("tempdir"); + let marker = dir.path().join("called"); + // `:>` is a shell builtin redirect, not `touch`: the child's PATH is + // ONLY the fake-gh dir, so an external command would never resolve + // and the marker could never appear — making the assertion vacuous. + let (_gh_dir, gh_path) = + fake_gh(&format!(":> {:?}; echo gh-token", marker.to_str().unwrap())); + + let greentree_env = |k: &str| (k == "GREENTREE_GITHUB_TOKEN").then(|| "gtt-token".into()); + let token = resolve_token_with(greentree_env, || gh_auth_token(Some(&gh_path))); + assert_eq!(token.as_deref(), Some("gtt-token")); + assert!(!marker.exists(), "gh must not run when env has a token"); + + let github_env = |k: &str| (k == "GITHUB_TOKEN").then(|| "ght-token".into()); + let token = resolve_token_with(github_env, || gh_auth_token(Some(&gh_path))); + assert_eq!(token.as_deref(), Some("ght-token")); + assert!(!marker.exists(), "gh must not run when env has a token"); + + // Negative control: the same fake `gh`, actually invoked, DOES write + // the marker — so its absence above means something. + assert_eq!(gh_auth_token(Some(&gh_path)).as_deref(), Some("gh-token")); + assert!(marker.exists(), "the fake gh never wrote its marker"); + } + + #[test] + fn gh_auth_token_does_not_deadlock_on_chatty_output() { + // More output than a pipe buffer holds (64 KiB on Linux): a wait loop + // that only reads after the child exits would block here until the + // timeout. Pure shell builtins — the child's PATH holds only `gh`. + let (_dir, path) = fake_gh( + "i=0; while [ $i -lt 4000 ]; do echo \ + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; \ + i=$((i+1)); done", + ); + let start = Instant::now(); + let out = gh_auth_token(Some(&path)).expect("chatty gh still returns its output"); + assert!(out.len() > 64 * 1024, "output was truncated: {}", out.len()); + assert!( + start.elapsed() < GH_AUTH_TIMEOUT, + "gh_auth_token deadlocked on a full pipe: took {:?}", + start.elapsed() + ); + } + + #[test] + fn greentree_env_beats_github_env_beats_gh() { + let both = |k: &str| match k { + "GREENTREE_GITHUB_TOKEN" => Some("gtt".to_string()), + "GITHUB_TOKEN" => Some("ght".to_string()), + _ => None, + }; + assert_eq!( + resolve_token_with(both, || panic!("gh must not run")).as_deref(), + Some("gtt") + ); + } + + #[test] + fn chain_falls_through_to_gh_when_no_env_var_is_set() { + let (_dir, path) = fake_gh("echo from-gh"); + let no_env = |_: &str| None; + assert_eq!( + resolve_token_with(no_env, || gh_auth_token(Some(&path))).as_deref(), + Some("from-gh") + ); + } + + #[test] + fn chain_returns_none_when_env_and_gh_both_fail() { + let (_dir, path) = fake_gh("exit 1"); + let no_env = |_: &str| None; + assert_eq!( + resolve_token_with(no_env, || gh_auth_token(Some(&path))), + None + ); + } + + #[test] + fn missing_token_error_enumerates_all_three_sources() { + let msg = missing_token_error().to_string(); + for needle in [ + "GREENTREE_GITHUB_TOKEN unset", + "GITHUB_TOKEN unset", + "gh auth token", + "gh auth login", + ] { + assert!(msg.contains(needle), "message missing {needle:?}: {msg}"); + } + } } diff --git a/src/lib.rs b/src/lib.rs index bb64fb7..44d4b93 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,6 +36,7 @@ pub mod exit { pub const LOCK_HELD: u8 = 13; pub const CONFIG: u8 = 14; pub const PUBLISH: u8 = 15; + pub const DISK_FLOOR: u8 = 16; } #[derive(Debug, thiserror::Error)] @@ -46,12 +47,32 @@ pub enum Error { Config(String), #[error("cannot snapshot: {0}")] Unsnapshotable(String), + /// The tree is honest and testable, but no commit can be created from it + /// right now (a rebase owns the next commit). Shares exit code 12 with + /// `Unsnapshotable`: both mean "fix the repository state, then retry". + #[error("cannot publish: {0}")] + Unpublishable(String), #[error("another greentree process holds the lock")] LockHeld, #[error("tree {tree} is not verified: {reason}")] NotVerified { tree: String, reason: String }, #[error("publish failed: {0}")] Publish(String), + /// Free disk is below the check's floor, so the check is not started at + /// all. A refusal, not an outcome: no verdict is recorded. + #[error( + "check {check:?} not started: {} free on the filesystem holding {root}, \ + below its {} min_free_disk floor; lower `min_free_disk` in greentree.yaml \ + (per check or top level), or set it to \"0\" to disable the floor", + crate::config::format_bytes(*free), + crate::config::format_bytes(*floor) + )] + DiskFloor { + check: String, + floor: u64, + free: u64, + root: String, + }, #[error(transparent)] Io(#[from] std::io::Error), } @@ -61,10 +82,11 @@ impl Error { match self { Error::Git(_) | Error::Io(_) => exit::ERROR, Error::Config(_) => exit::CONFIG, - Error::Unsnapshotable(_) => exit::UNSNAPSHOTABLE, + Error::Unsnapshotable(_) | Error::Unpublishable(_) => exit::UNSNAPSHOTABLE, Error::LockHeld => exit::LOCK_HELD, Error::NotVerified { .. } => exit::NOT_VERIFIED, Error::Publish(_) => exit::PUBLISH, + Error::DiskFloor { .. } => exit::DISK_FLOOR, } } } diff --git a/src/publish.rs b/src/publish.rs index 0e4e2e5..be2b326 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -5,6 +5,11 @@ //! is journaled so a crashed publish resumes idempotently. Every commit we //! create carries a `Greentree-Change-Id` trailer — the stable identity a //! change keeps across rewrites (the stack seed; v0.1 is a stack of depth 1). +//! +//! Publish also finishes what git started: with a merge, cherry-pick or +//! revert in progress and its conflicts resolved, it writes the commit git +//! would have written — the full parent list, the sequencer's message, and +//! the same state files retired afterwards. use std::ffi::OsStr; use std::io::Read; @@ -21,14 +26,33 @@ use crate::{Error, Result}; const JOURNAL_FILE: &str = "publish-journal.json"; pub const CHANGE_ID_TRAILER: &str = "Greentree-Change-Id"; +const JOURNAL_SCHEMA: u32 = 2; + +/// Per-worktree files that record an in-progress sequencer operation. All of +/// them live directly in the (worktree-specific) git dir, so a path join +/// replaces a `rev-parse --git-path` spawn apiece. +const MERGE_HEAD: &str = "MERGE_HEAD"; +const CHERRY_PICK_HEAD: &str = "CHERRY_PICK_HEAD"; +const REVERT_HEAD: &str = "REVERT_HEAD"; +const MERGE_MSG: &str = "MERGE_MSG"; +/// `git merge --squash` leaves this (and no MERGE_HEAD): the squash's message, +/// which `git commit` prefers over MERGE_MSG. +const SQUASH_MSG: &str = "SQUASH_MSG"; +const MERGE_MODE: &str = "MERGE_MODE"; +const AUTO_MERGE: &str = "AUTO_MERGE"; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Journal { pub schema_version: u32, pub tree: String, pub branch: String, - /// Explicit parent of the commit being created (None = unborn branch). - /// Never implicit HEAD: stack publishes extend this same journal shape. - pub parent: Option, + /// Explicit parents of the commit being created, in order — HEAD first, + /// then every MERGE_HEAD line for a merge; empty for an unborn branch. + /// Never implicit HEAD: stack publishes extend this same journal shape, + /// and a retried merge publish must rebuild the SAME commit, which means + /// the full parent list has to survive the crash. + #[serde(default, alias = "parent", deserialize_with = "de_parents")] + pub parents: Vec, pub change_id: String, pub new_commit: Option, /// Remote-tracking SHA recorded before the first push attempt; the @@ -37,6 +61,27 @@ pub struct Journal { pub lease: Option>, } +/// Reads both journal shapes: schema 1's `"parent": |null` and schema 2's +/// `"parents": [, …]`. A journal is in-flight state, so a v1 file left by +/// an interrupted publish under an older greentree still resumes instead of +/// tripping the unparseable-journal error. +fn de_parents<'de, D>(d: D) -> std::result::Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum Shape { + One(Option), + Many(Vec), + } + Ok(match Shape::deserialize(d)? { + Shape::One(Some(parent)) => vec![parent], + Shape::One(None) => Vec::new(), + Shape::Many(parents) => parents, + }) +} + #[derive(Debug, Default)] pub struct PublishOptions { pub push: bool, @@ -55,6 +100,13 @@ pub struct PublishReport { /// Whether this publish resumed a previously interrupted one. pub resumed: bool, pub verified_by: Vec, + /// Non-fatal problems AFTER the commit existed and the branch moved — + /// leftover sequencer files that could not be removed, an index that + /// could not be synced. The commit is real either way, so these are + /// reported, not raised: failing here would tell an agent to retry a + /// publish that already happened. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, } pub fn publish( @@ -63,9 +115,26 @@ pub fn publish( store: &dyn VerdictStore, opts: &PublishOptions, ) -> Result { + refuse_unpublishable(git)?; let tree = crate::snapshot::snapshot(git, cfg)?; let branch = current_branch(git)?; let head = git.rev_parse_opt("HEAD")?; + // A merge in progress makes this publish the merge commit: HEAD first, + // then every MERGE_HEAD line (an octopus lists one SHA per line) that is + // not already reachable from HEAD. + let (merge_heads, stale_merge) = merge_heads(git, head.as_deref())?; + let parents: Vec = + head.iter() + .cloned() + .chain(merge_heads.clone()) + .fold(Vec::new(), |mut acc, p| { + // `commit-tree` warns and drops exact repeats; drop them here so + // the journal records the parent list the commit actually gets. + if !acc.contains(&p) { + acc.push(p); + } + acc + }); // The gate runs FIRST, unconditionally — including on the resume path. // A leftover journal must never bypass verification: the environment @@ -86,7 +155,13 @@ pub fn publish( ))); } let commit = journal.new_commit.clone().unwrap(); - sync_index(git)?; + // The commit is already HEAD, so the sequencer's state files are + // spent even if the crash landed before they were cleared. Both + // steps are best-effort: the commit exists, so a failure here is + // a warning, never a reason to report the publish as failed. + let mut warnings = Vec::new(); + warn_on(&mut warnings, clear_sequencer_state(git)); + warn_on(&mut warnings, sync_index(git)); let mut pushed = false; if opts.push { push_with_lease( @@ -108,14 +183,26 @@ pub fn publish( pushed, resumed: true, verified_by, + warnings, }); } } - // Empty diff: this exact tree is already committed at HEAD. - if let Some(head_sha) = &head { + // Empty diff: this exact tree is already committed at HEAD. A merge in + // progress is exempt — a merge commit records history, not content, so + // `git commit` still writes one when the resolution happens to reproduce + // HEAD's tree (the "ours" resolution), and so do we. + if let (Some(head_sha), true) = (&head, merge_heads.is_empty()) { let head_tree = git.run(["rev-parse", &format!("{head_sha}^{{tree}}")])?; if head_tree == tree { + let mut warnings = Vec::new(); + // Nothing was committed, so a live cherry-pick's state is left + // for `git cherry-pick --skip` — but a MERGE_HEAD naming only + // commits HEAD already contains describes no merge at all, and + // leaving it would make the next publish a phantom merge. + if stale_merge { + warn_on(&mut warnings, clear_stale_merge_state(git)); + } let mut pushed = false; if opts.push { let lease = remote_tracking(git, &branch)?; @@ -131,19 +218,20 @@ pub fn publish( pushed, resumed: false, verified_by, + warnings, }); } } - // Fresh journal (or reuse one for the same tree+parent so a retried + // Fresh journal (or reuse one for the same tree+parents so a retried // publish never mints a second change-id or duplicate commit). let mut journal = match load_journal(git)? { - Some(j) if j.tree == tree && j.parent == head => j, + Some(j) if j.tree == tree && j.parents == parents => j, _ => Journal { - schema_version: 1, + schema_version: JOURNAL_SCHEMA, tree: tree.clone(), branch: branch.clone(), - parent: head.clone(), + parents: parents.clone(), change_id: new_change_id()?, new_commit: None, lease: None, @@ -156,9 +244,14 @@ pub fn publish( c.clone() } _ => { - let message = build_message(opts.message.as_deref(), &tree, &journal.change_id); + let message = build_message( + opts.message.as_deref(), + pending_message(git, !merge_heads.is_empty())?.as_deref(), + &tree, + &journal.change_id, + ); let mut args: Vec = vec!["commit-tree".into(), tree.clone()]; - if let Some(parent) = &journal.parent { + for parent in &journal.parents { args.push("-p".into()); args.push(parent.clone()); } @@ -172,8 +265,9 @@ pub fn publish( }; // Compare-and-swap the branch ref: fails cleanly if the agent moved it. + // The expectation is the FIRST parent — the HEAD this publish read. let refname = format!("refs/heads/{branch}"); - let expected = journal.parent.clone().unwrap_or_default(); + let expected = journal.parents.first().cloned().unwrap_or_default(); git.run(["update-ref", &refname, &commit, &expected]) .map_err(|e| { Error::Publish(format!( @@ -181,7 +275,14 @@ pub fn publish( )) })?; - sync_index(git)?; + // The commit exists and the branch points at it: the sequencer's state + // files describe a finished operation now, exactly as after `git commit`. + // Past this line the publish has HAPPENED — housekeeping that fails is + // reported as a warning, because telling the caller "publish failed" + // would invite a retry of a commit that already exists. + let mut warnings = Vec::new(); + warn_on(&mut warnings, clear_sequencer_state(git)); + warn_on(&mut warnings, sync_index(git)); let mut pushed = false; if opts.push { @@ -215,9 +316,18 @@ pub fn publish( pushed, resumed: false, verified_by, + warnings, }) } +/// Record a post-commit housekeeping failure instead of raising it. +fn warn_on(warnings: &mut Vec, result: Result<()>) { + if let Err(e) = result { + tracing::warn!(error = %e, "publish succeeded with a housekeeping failure"); + warnings.push(e.to_string()); + } +} + /// The verification gate, shared by publish and attest: every /// required check must hold a passing verdict for exactly `tree`, fresh /// within its window, under the CURRENT environment fingerprint. Returns @@ -293,10 +403,196 @@ pub fn attest_target(git: &Git, cfg: &Config, store: &dyn VerdictStore) -> Resul }) } -fn build_message(user: Option<&str>, tree: &str, change_id: &str) -> String { +/// States in which no commit can be created, however honest the tree is. +/// +/// Snapshot asks "is there a tree here?"; publish asks "can I create the +/// commit git would create?". For a rebase the answer is no: the next commit +/// belongs to the rebase's own sequencer, which would replay the todo list +/// over whatever we wrote and leave a duplicate behind. Testing is +/// unaffected — the verdicts cached during the rebase are keyed by tree, so +/// they are still there for `gate` or `attest` once it finishes. +pub fn refuse_unpublishable(git: &Git) -> Result<()> { + for dir in ["rebase-merge", "rebase-apply"] { + if git.git_dir.join(dir).exists() { + // `.git/rebase-apply` is also where `git am` keeps its state, so + // the advice names both continuations rather than sending an + // interrupted `git am` after a rebase that isn't running. + return Err(Error::Unpublishable( + "a rebase is in progress; finish it with `git rebase --continue` (or \ + `git am --continue`), then `greentree attest` will find the verdicts \ + cached for this tree (`greentree test` and `status` keep working \ + meanwhile)" + .into(), + )); + } + } + Ok(()) +} + +/// The commits MERGE_HEAD names that HEAD does not already contain, one per +/// line — an octopus merge lists them all. Empty when no merge is in progress. +/// +/// A head already reachable from HEAD is **stale**: MERGE_HEAD outlived the +/// commit that consumed it (a publish or `git commit` that crashed between +/// moving the ref and clearing the state files). Merging it again would mint +/// a second merge commit with a parent that adds nothing, so it is dropped +/// here and the file is retired on success instead. The second return value +/// says whether anything was dropped. +fn merge_heads(git: &Git, head: Option<&str>) -> Result<(Vec, bool)> { + let path = git.git_dir.join(MERGE_HEAD); + let Ok(content) = std::fs::read_to_string(&path) else { + return Ok((Vec::new(), false)); + }; + let listed: Vec = content + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect(); + let Some(head) = head else { + return Ok((listed, false)); + }; + let mut live = Vec::with_capacity(listed.len()); + for mh in listed { + // `--is-ancestor` is also true for HEAD itself, which is exactly the + // shape a re-run of a finished merge leaves behind. + let out = git.run_unchecked(["merge-base", "--is-ancestor", &mh, head])?; + if out.status.success() { + tracing::warn!( + merge_head = %short(&mh), + "MERGE_HEAD names a commit already in HEAD; treating it as stale" + ); + continue; + } + live.push(mh); + } + let stale = live.len() != content.lines().filter(|l| !l.trim().is_empty()).count(); + Ok((live, stale)) +} + +/// True when a merge whose MERGE_HEAD still names commits HEAD does not +/// contain is in progress — the state in which publish writes a merge commit +/// even though the tree already matches HEAD. +pub fn merge_in_progress(git: &Git) -> Result { + let head = git.rev_parse_opt("HEAD")?; + Ok(!merge_heads(git, head.as_deref())?.0.is_empty()) +} + +/// The message git would have offered the editor for the sequencer operation +/// in progress, comment lines removed: +/// +/// - `SQUASH_MSG` when it exists — `git merge --squash` leaves it (with no +/// MERGE_HEAD) and `git commit` prefers it, so we do too; +/// - otherwise `MERGE_MSG`, written for merges *and* for conflicted +/// cherry-picks and reverts (where it carries the picked commit's message). +/// +/// `None` when nothing is in progress, or when stripping leaves nothing. +fn pending_message(git: &Git, merging: bool) -> Result> { + let squash = git.git_dir.join(SQUASH_MSG); + let path = if squash.exists() { + squash + } else if merging + || [CHERRY_PICK_HEAD, REVERT_HEAD] + .iter() + .any(|f| git.git_dir.join(f).exists()) + { + git.git_dir.join(MERGE_MSG) + } else { + return Ok(None); + }; + let Ok(raw) = std::fs::read_to_string(&path) else { + return Ok(None); + }; + let comment = comment_char(git, &raw); + let body = raw + .lines() + .filter(|l| !l.starts_with(comment)) + .collect::>() + .join("\n"); + let body = body.trim_end().to_string(); + Ok((!body.is_empty()).then_some(body)) +} + +/// The characters git picks from, in order, for `core.commentChar = auto`. +const AUTO_COMMENT_CHARS: &str = "#;@!$%^&|:"; + +/// The comment character to strip from `message`, following `core.commentChar`: +/// unset is `#`, an explicit single character is itself, and `auto` is resolved +/// the way git resolves it — the first candidate no line of THIS message starts +/// with, so a message whose lines begin with `#` keeps them. (Git errors out +/// when every candidate is taken; we fall back to `#`, the default, rather than +/// refuse a publish over a message it could not have written either.) +fn comment_char(git: &Git, message: &str) -> char { + let Ok(out) = git.run_unchecked(["config", "--get", "core.commentChar"]) else { + return '#'; + }; + let value = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if value == "auto" { + return AUTO_COMMENT_CHARS + .chars() + .find(|c| !message.lines().any(|l| l.starts_with(*c))) + .unwrap_or('#'); + } + let mut chars = value.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) => c, + _ => '#', + } +} + +/// Retire the sequencer state a commit consumes, exactly as `git commit` +/// does: MERGE_HEAD/MERGE_MSG/MERGE_MODE for a merge, SQUASH_MSG for a +/// squash, CHERRY_PICK_HEAD or REVERT_HEAD for a pick, and the AUTO_MERGE ref +/// the ort strategy leaves behind. `.git/sequencer/` is deliberately +/// untouched: it holds the REST of a multi-commit `cherry-pick`/`revert`, and +/// `git cherry-pick --continue` picks up from there. +fn clear_sequencer_state(git: &Git) -> Result<()> { + remove_state( + git, + &[ + MERGE_HEAD, + MERGE_MSG, + SQUASH_MSG, + MERGE_MODE, + CHERRY_PICK_HEAD, + REVERT_HEAD, + AUTO_MERGE, + ], + ) +} + +/// Retire ONLY the markers of a merge that HEAD already contains, when no +/// commit was created. A live cherry-pick's own files (and the message it +/// still needs) are left alone — `git cherry-pick --skip` wants them. +fn clear_stale_merge_state(git: &Git) -> Result<()> { + remove_state(git, &[MERGE_HEAD, MERGE_MODE]) +} + +fn remove_state(git: &Git, files: &[&str]) -> Result<()> { + for file in files { + let path = git.git_dir.join(file); + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return Err(Error::Publish(format!( + "commit created, but {} could not be removed: {e}", + path.display() + ))) + } + } + } + Ok(()) +} + +/// Message precedence: an explicit `-m` wins; otherwise the in-progress +/// sequencer message (MERGE_MSG) — which is what `git commit` would have +/// defaulted to; otherwise greentree's own line naming the verified tree. +fn build_message(user: Option<&str>, pending: Option<&str>, tree: &str, change_id: &str) -> String { let body = user .map(str::trim_end) .filter(|s| !s.is_empty()) + .or_else(|| pending.map(str::trim_end).filter(|s| !s.is_empty())) .map(str::to_string) .unwrap_or_else(|| format!("greentree: verified tree {}", short(tree))); format!("{body}\n\n{CHANGE_ID_TRAILER}: {change_id}\n") diff --git a/src/runner.rs b/src/runner.rs index a3574b6..1d4d9c1 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -10,6 +10,7 @@ use std::collections::VecDeque; use std::io::{Read, Seek, SeekFrom, Write}; use std::os::unix::process::{CommandExt, ExitStatusExt}; +use std::path::Path; use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime}; @@ -30,6 +31,9 @@ const HEAD_CAP: u64 = 4 * 1024 * 1024; const TAIL_CAP: usize = 256 * 1024; const GRACE: Duration = Duration::from_secs(5); const POLL: Duration = Duration::from_millis(50); +/// Free space is sampled at most this often while a check runs — statvfs is +/// cheap, but the poll loop ticks 20x a second and disk does not. +const DISK_SAMPLE: Duration = Duration::from_secs(1); pub struct RunResult { pub verdict: Verdict, @@ -43,6 +47,106 @@ pub struct RunResult { pub tree_after: String, } +/// Where free-space observations come from. Real runs read statvfs; tests +/// inject a fake, so the floor logic is exercised without filling a disk. +pub trait FreeSpace { + fn free_bytes(&self) -> Result; +} + +/// The filesystem holding a path, via statvfs. +pub struct Statvfs<'a>(pub &'a Path); + +impl FreeSpace for Statvfs<'_> { + fn free_bytes(&self) -> Result { + free_bytes(self.0) + } +} + +/// Free bytes on the filesystem holding `path`, counted from the blocks +/// *available* to an unprivileged writer (f_bavail) — the root-reserved +/// remainder is not space a check can use. +pub fn free_bytes(path: &Path) -> Result { + let s = nix::sys::statvfs::statvfs(path) + .map_err(|e| std::io::Error::other(format!("statvfs {}: {e}", path.display())))?; + // The block-count and fragment-size widths differ across platforms, so + // these casts are redundant only on some of them. + #[allow(clippy::unnecessary_cast)] + let free = (s.blocks_available() as u64).saturating_mul(s.fragment_size() as u64); + Ok(free) +} + +/// A floor of 0 disables the check entirely — that is the documented way to +/// opt out, and it must never be read as "refuse everything". +fn below_floor(free: u64, floor: u64) -> bool { + floor > 0 && free < floor +} + +/// Refuse to START a check that would begin with less than its floor of free +/// disk. A refusal is not an outcome: like an unsnapshotable tree, nothing is +/// recorded. Returns the free bytes observed, which the verdict carries. +/// +/// A floor of 0 is the documented opt-out, so it must not be able to fail a +/// run: the observation is skipped entirely (a statvfs that errors on an +/// exotic filesystem cannot refuse a check whose floor is disabled), and the +/// verdict records 0 free bytes — "not observed". +fn admit(name: &str, root: &Path, floor: u64, space: &dyn FreeSpace) -> Result { + if floor == 0 { + return Ok(0); + } + let free = space.free_bytes()?; + if below_floor(free, floor) { + return Err(crate::Error::DiskFloor { + check: name.to_string(), + floor, + free, + root: root.display().to_string(), + }); + } + Ok(free) +} + +/// Mid-run disk supervision: samples free space at a bounded interval and +/// says when the check must die. Split out of the poll loop so the decision +/// is unit-testable. +struct DiskGuard<'a> { + floor: u64, + space: &'a dyn FreeSpace, + interval: Duration, + last: Instant, + min_free: u64, +} + +impl<'a> DiskGuard<'a> { + fn new(floor: u64, space: &'a dyn FreeSpace, start_free: u64, now: Instant) -> DiskGuard<'a> { + DiskGuard { + floor, + space, + interval: DISK_SAMPLE, + last: now, + min_free: start_free, + } + } + + /// True when free space has fallen below the floor and the check must be + /// killed. A statvfs that fails is not a reason to kill a running check. + fn exhausted(&mut self, now: Instant) -> bool { + if self.floor == 0 || now.duration_since(self.last) < self.interval { + return false; + } + self.last = now; + match self.space.free_bytes() { + Ok(free) => { + self.min_free = self.min_free.min(free); + below_floor(free, self.floor) + } + Err(e) => { + tracing::debug!(error = %e, "free-space sample failed; not killing the check"); + false + } + } + } +} + pub fn run_check( git: &Git, cfg: &Config, @@ -94,6 +198,13 @@ pub fn run_check_with( } } + // Governed like time: a check that cannot start with room is refused + // before anything is anchored or spawned. A cache hit never gets here — + // no process runs, so no disk is at risk. + let floor = cfg.disk_floor(check); + let space = Statvfs(&git.root); + let disk_free_start = admit(name, &git.root, floor, &space)?; + // Only trees a check actually runs against get anchored. anchor(git, &tree)?; let snapshot_ref = format!("refs/greentree/snapshots/{tree}"); @@ -174,6 +285,8 @@ pub fn run_check_with( 0, blake3::Hasher::new(), false, + disk_free_start, + disk_free_start, ); let tree_after = tree.clone(); return Ok(RunResult { @@ -191,9 +304,10 @@ pub fn run_check_with( let t_out = drain(stdout, Arc::clone(&sink)); let t_err = drain(stderr, Arc::clone(&sink)); - // Wait with timeout/cancel; escalate SIGTERM -> SIGKILL on the process group. + // Wait with timeout/cancel/disk; escalate SIGTERM -> SIGKILL on the process group. let pgid = Pid::from_raw(child.id() as i32); let mut killed: Option = None; + let mut disk = DiskGuard::new(floor, &space, disk_free_start, Instant::now()); let status = loop { if let Some(status) = child.try_wait()? { break status; @@ -205,6 +319,10 @@ pub fn run_check_with( .unwrap_or(false) { Some(Outcome::Cancelled) + } else if disk.exhausted(Instant::now()) { + // The check started with room and ate it: kill it the same way a + // timeout does, before it takes the filesystem with it. + Some(Outcome::DiskExhausted) } else { None }; @@ -299,6 +417,8 @@ pub fn run_check_with( total, hasher, total > HEAD_CAP, + disk_free_start, + disk.min_free, ); if outcome.cacheable() { @@ -332,6 +452,8 @@ fn build_verdict( log_bytes: u64, hasher: blake3::Hasher, log_truncated: bool, + disk_free_start_bytes: u64, + disk_free_min_bytes: u64, ) -> Verdict { let finished_at = SystemTime::now(); Verdict { @@ -349,6 +471,8 @@ fn build_verdict( started: humantime::format_rfc3339_seconds(started_at).to_string(), finished: humantime::format_rfc3339_seconds(finished_at).to_string(), duration_ms: started_mono.elapsed().as_millis() as u64, + disk_free_start_bytes, + disk_free_min_bytes, finished_unix: unix_secs(finished_at), os: std::env::consts::OS.to_string(), arch: std::env::consts::ARCH.to_string(), @@ -475,3 +599,118 @@ fn unix_secs(t: SystemTime) -> u64 { .map(|d| d.as_secs()) .unwrap_or(0) } + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + /// A scripted disk: each observation pops the next value, and the last + /// one repeats. + struct Fake(Cell, Vec); + + impl Fake { + fn new(readings: &[u64]) -> Fake { + Fake(Cell::new(0), readings.to_vec()) + } + } + + impl FreeSpace for Fake { + fn free_bytes(&self) -> Result { + let i = self.0.get(); + self.0.set((i + 1).min(self.1.len() - 1)); + Ok(self.1[i]) + } + } + + struct Broken; + + impl FreeSpace for Broken { + fn free_bytes(&self) -> Result { + Err(crate::Error::Io(std::io::Error::other("no statvfs"))) + } + } + + const G: u64 = 1024 * 1024 * 1024; + + #[test] + fn a_check_below_the_floor_is_refused_before_it_starts() { + let root = Path::new("/repo"); + match admit("full", root, 60 * G, &Fake::new(&[4 * G])) { + Err(crate::Error::DiskFloor { + check, floor, free, .. + }) => { + assert_eq!(check, "full"); + assert_eq!((floor, free), (60 * G, 4 * G)); + } + other => panic!("expected a refusal, got {other:?}"), + } + } + + #[test] + fn room_above_the_floor_admits_and_reports_free_space() { + let root = Path::new("/repo"); + assert_eq!( + admit("full", root, 10 * G, &Fake::new(&[27 * G])).unwrap(), + 27 * G + ); + // Exactly at the floor is room enough; a zero floor admits anything. + assert_eq!( + admit("full", root, 10 * G, &Fake::new(&[10 * G])).unwrap(), + 10 * G + ); + // A zero floor admits without observing at all (see below). + assert_eq!(admit("full", root, 0, &Fake::new(&[1024])).unwrap(), 0); + } + + #[test] + fn a_disabled_floor_admits_without_consulting_the_disk() { + // "0" is the documented opt-out, so a filesystem whose statvfs fails + // must not be able to refuse the run through it. + assert_eq!(admit("full", Path::new("/repo"), 0, &Broken).unwrap(), 0); + assert!( + admit("full", Path::new("/repo"), 1, &Broken).is_err(), + "with a floor set, an unobservable disk is still an error" + ); + } + + #[test] + fn falling_below_the_floor_mid_run_aborts_the_check() { + let space = Fake::new(&[30 * G, 20 * G, 3 * G]); + let t0 = Instant::now(); + let mut guard = DiskGuard::new(10 * G, &space, 40 * G, t0); + // Sampling is rate-limited: a tick inside the interval reads nothing. + assert!(!guard.exhausted(t0 + Duration::from_millis(100))); + assert_eq!(guard.min_free, 40 * G); + + assert!(!guard.exhausted(t0 + DISK_SAMPLE)); + assert!(!guard.exhausted(t0 + 2 * DISK_SAMPLE)); + assert_eq!(guard.min_free, 20 * G, "the low-water mark tracks the run"); + + assert!( + guard.exhausted(t0 + 3 * DISK_SAMPLE), + "a drop below the floor must kill the check" + ); + assert_eq!(guard.min_free, 3 * G); + } + + #[test] + fn a_zero_floor_and_a_broken_statvfs_never_kill_a_check() { + let t0 = Instant::now(); + let space = Fake::new(&[1]); + let mut off = DiskGuard::new(0, &space, 1, t0); + assert!(!off.exhausted(t0 + 10 * DISK_SAMPLE)); + + let mut blind = DiskGuard::new(10 * G, &Broken, 40 * G, t0); + assert!(!blind.exhausted(t0 + DISK_SAMPLE)); + assert_eq!(blind.min_free, 40 * G); + } + + #[test] + fn free_space_of_the_repo_filesystem_is_observable() { + // Not an assertion about this box, just that the syscall path works + // and a nonexistent path is an error rather than a silent zero. + free_bytes(Path::new(".")).expect("statvfs on the cwd"); + assert!(free_bytes(Path::new("/nonexistent-greentree-test")).is_err()); + } +} diff --git a/src/snapshot.rs b/src/snapshot.rs index 6b72c0c..4a737b9 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -97,33 +97,24 @@ pub fn anchor(git: &Git, tree: &str) -> Result { } /// States in which a tree hash would lie or write-tree would fail. -/// These are all per-worktree files living directly in the (worktree- -/// specific) git dir, so a path join replaces six `rev-parse --git-path` -/// subprocess spawns per snapshot. +/// +/// Only those. Snapshot asks one question — *is there an honest tree here?* — +/// and a half-finished merge, cherry-pick, revert or rebase step whose +/// conflicts have all been resolved answers yes: the working tree is an +/// ordinary tree that deserves to be tested and cached like any other. What +/// such a state may forbid is *creating a commit*, which is publish's +/// question and lives in `publish::refuse_unpublishable`. fn refuse_unsnapshotable(git: &Git) -> Result<()> { - for (file, what) in [ - ("MERGE_HEAD", "a merge is in progress"), - ("CHERRY_PICK_HEAD", "a cherry-pick is in progress"), - ("REVERT_HEAD", "a revert is in progress"), - ] { - if git.git_dir.join(file).exists() { - return Err(Error::Unsnapshotable(format!( - "{what}; finish or abort it first" - ))); - } - } - for dir in ["rebase-merge", "rebase-apply"] { - if git.git_dir.join(dir).exists() { - return Err(Error::Unsnapshotable( - "a rebase is in progress; finish or abort it first".into(), - )); - } - } // Unmerged entries live in the REAL index; they never appear in the shadow. + // With conflicts outstanding there is no single tree to name: `write-tree` + // would silently hash one side of every conflicted path. let unmerged = git.run(["ls-files", "-u"])?; if !unmerged.is_empty() { return Err(Error::Unsnapshotable( - "the index has unmerged (conflicted) entries".into(), + "the index has unmerged (conflicted) entries; resolve them and \ + `git add` the results — a resolved merge or cherry-pick snapshots \ + and tests like any other tree" + .into(), )); } // Dirty submodules are invisible to the superproject tree hash: the diff --git a/src/watch.rs b/src/watch.rs index 97e06f8..99f825d 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -182,7 +182,19 @@ pub fn watch(git: &Git, opts: &WatchOptions) -> Result<()> { } }; let _ = worker.join(); - let results = results?; + let results = match results { + Ok(results) => results, + // A pre-start disk refusal is a refusal, not an outcome — and for + // a long-lived watcher it is transient: the disk that is full now + // may not be in a minute. Report it and keep watching, exactly + // like a timeout or a cancelled cycle. (One-shot `test`/`gate` + // still exit 16; there is nothing to wait for there.) + Err(e @ Error::DiskFloor { .. }) => { + report_refusal(opts, &e); + continue; + } + Err(e) => return Err(e), + }; let cancelled = results .iter() @@ -220,11 +232,7 @@ pub fn watch(git: &Git, opts: &WatchOptions) -> Result<()> { println!( "tree {} {name} {}{}", short(&r.verdict.tree), - match r.verdict.outcome { - Outcome::Pass => "✓", - Outcome::Fail => "✗", - o => o.as_str(), - }, + crate::commands::mark(r.verdict.outcome), if r.cached { " (cached)".to_string() } else { @@ -241,6 +249,40 @@ pub fn watch(git: &Git, opts: &WatchOptions) -> Result<()> { } } +/// A cycle that never started, in watch's own vocabulary: the same JSON shape +/// a cycle prints (with a null tree — nothing was judged) plus the reason, and +/// the same outcome word `test` uses for a check the disk floor stopped. +fn report_refusal(opts: &WatchOptions, error: &Error) { + let check = match error { + Error::DiskFloor { check, .. } => check.clone(), + _ => String::new(), + }; + if opts.json { + println!( + "{}", + serde_json::json!({ + "tree": serde_json::Value::Null, + "results": [{ + "check": check, + "outcome": Outcome::DiskExhausted.as_str(), + "cached": false, + "duration_ms": 0, + }], + "error": error.to_string(), + }) + ); + } else { + println!( + "{check} {} — still watching", + crate::commands::mark(Outcome::DiskExhausted) + ); + eprintln!("{error}"); + } + // Debug, not warn: both output modes already carry the reason, and the + // default filter is `warn` — logging it again would print it twice. + tracing::debug!(error = %error, "cycle refused; watch continues"); +} + fn compile_excludes(cfg: &Config) -> Vec { cfg.snapshot .exclude diff --git a/tests/it.rs b/tests/it.rs index d9399d0..d778524 100644 --- a/tests/it.rs +++ b/tests/it.rs @@ -5,7 +5,7 @@ use std::path::Path; use std::process::Command; use greentree::cache::{JsonStore, Outcome, VerdictStore}; -use greentree::config::{Check, Config}; +use greentree::config::{Check, Config, DiskSize}; use greentree::git::Git; use greentree::publish::{publish, PublishOptions, CHANGE_ID_TRAILER}; use greentree::runner::run_check; @@ -41,6 +41,10 @@ fn repo() -> (TempDir, Git) { (tmp, git) } +/// The shared config for tests that are not ABOUT the disk floor. The floor +/// is explicitly disabled: leaving it unset would make every one of these +/// tests silently require the built-in 5 GiB default of free space, and fail +/// with a refusal that has nothing to do with what they assert. fn config(run: &str) -> Config { let mut checks = IndexMap::new(); checks.insert( @@ -51,6 +55,7 @@ fn config(run: &str) -> Config { fresh: None, timeout: None, watch: false, + min_free_disk: None, }, ); Config { @@ -58,6 +63,7 @@ fn config(run: &str) -> Config { checks, snapshot: Default::default(), inputs: Vec::new(), + min_free_disk: Some(DiskSize(0)), } } @@ -114,20 +120,409 @@ fn snapshot_excludes_work_on_gitignored_paths() { assert_eq!(t1, t2, "excluded+ignored churn must not move the tree"); } -#[test] -fn snapshot_refuses_mid_merge() { - let (tmp, git) = repo(); +/// Leave the repo mid-merge with `c.txt` conflicted. Returns (ours, theirs). +fn conflicted_merge(dir: &Path) -> (String, String) { sh( - tmp.path(), + dir, "git checkout -qb other && echo a > c.txt && git add -A && git commit -qm a \ && git checkout -q main && echo b > c.txt && git add -A && git commit -qm b \ && git merge other > /dev/null 2>&1 || true", ); + ( + sh(dir, "git rev-parse HEAD"), + sh(dir, "git rev-parse other"), + ) +} + +#[test] +fn snapshot_refuses_conflicts_but_not_a_resolved_merge() { + // The honest-tree rule: only the conflicted index is unsnapshotable. Once + // the conflict is resolved the tree is ordinary and must hash like any + // other — being mid-merge is publish's problem, not snapshot's. + let (tmp, git) = repo(); + conflicted_merge(tmp.path()); let cfg = config("true"); match snapshot(&git, &cfg) { Err(Error::Unsnapshotable(_)) => {} other => panic!("expected Unsnapshotable, got {other:?}"), } + + sh(tmp.path(), "echo resolved > c.txt && git add c.txt"); + let tree = snapshot(&git, &cfg).expect("a resolved merge snapshots"); + assert!( + tmp.path().join(".git/MERGE_HEAD").exists(), + "still mid-merge — that is the point" + ); + assert_eq!( + tree, + snapshot(&git, &cfg).unwrap(), + "and caches like any tree" + ); +} + +#[test] +fn resolved_merge_publishes_a_two_parent_commit() { + let (tmp, git) = repo(); + let (ours, theirs) = conflicted_merge(tmp.path()); + let cfg = config("grep -q resolved c.txt"); + let mut store = JsonStore::open(&git.state_dir()).unwrap(); + let (name, check) = check_of(&cfg); + + sh(tmp.path(), "echo resolved > c.txt && git add c.txt"); + let r = run_check(&git, &cfg, name, check, &mut store, true).unwrap(); + assert_eq!(r.verdict.outcome, Outcome::Pass); + let verified_tree = r.verdict.tree.clone(); + assert!( + greentree::publish::merge_in_progress(&git).unwrap(), + "a real merge must read as in progress" + ); + + let report = publish(&git, &cfg, &store, &PublishOptions::default()).unwrap(); + assert!(!report.noop); + let commit = report.commit.unwrap(); + + assert_eq!( + sh(tmp.path(), "git log -1 --format=%P"), + format!("{ours} {theirs}"), + "a merge commit, with HEAD and MERGE_HEAD as its parents" + ); + assert_eq!( + sh(tmp.path(), "git rev-parse 'HEAD^{tree}'"), + verified_tree, + "published commit IS the verified tree" + ); + assert_eq!(sh(tmp.path(), "git rev-parse HEAD"), commit); + // No -m: the message defaults to MERGE_MSG, minus its comment lines. + assert_eq!( + sh(tmp.path(), "git log -1 --format=%s"), + "Merge branch 'other'" + ); + assert!(sh(tmp.path(), "git log -1 --format=%B").contains(CHANGE_ID_TRAILER)); + + for state in ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE", "AUTO_MERGE"] { + assert!( + !tmp.path().join(".git").join(state).exists(), + "{state} survived the merge commit" + ); + } + assert_eq!( + sh(tmp.path(), "git status --porcelain"), + "", + "the merge is finished, not merely committed" + ); +} + +#[test] +fn octopus_merge_publishes_every_parent() { + let (tmp, git) = repo(); + sh( + tmp.path(), + "git checkout -qb a && echo a > a.txt && git add -A && git commit -qm a \ + && git checkout -q main && git checkout -qb b && echo b > b.txt \ + && git add -A && git commit -qm b && git checkout -q main \ + && echo m > m.txt && git add -A && git commit -qm m \ + && git merge --no-commit a b > /dev/null 2>&1 || true", + ); + let heads: Vec = ["HEAD", "a", "b"] + .iter() + .map(|r| sh(tmp.path(), &format!("git rev-parse {r}"))) + .collect(); + assert_eq!( + std::fs::read_to_string(tmp.path().join(".git/MERGE_HEAD")) + .unwrap() + .lines() + .count(), + 2, + "octopus MERGE_HEAD lists one SHA per line" + ); + + let cfg = config("test -f a.txt && test -f b.txt"); + let mut store = JsonStore::open(&git.state_dir()).unwrap(); + let (name, check) = check_of(&cfg); + assert_eq!( + run_check(&git, &cfg, name, check, &mut store, true) + .unwrap() + .verdict + .outcome, + Outcome::Pass + ); + + publish(&git, &cfg, &store, &PublishOptions::default()).unwrap(); + assert_eq!( + sh(tmp.path(), "git log -1 --format=%P"), + heads.join(" "), + "three parents: HEAD then both MERGE_HEAD lines" + ); + assert!(!tmp.path().join(".git/MERGE_HEAD").exists()); +} + +#[test] +fn resolved_cherry_pick_publishes_a_single_parent_commit() { + let (tmp, git) = repo(); + sh( + tmp.path(), + "git checkout -qb other && echo a > c.txt && git add -A \ + && git commit -qm 'the picked commit' && git checkout -q main \ + && echo b > c.txt && git add -A && git commit -qm b \ + && git cherry-pick other > /dev/null 2>&1 || true", + ); + assert!(tmp.path().join(".git/CHERRY_PICK_HEAD").exists()); + let head = sh(tmp.path(), "git rev-parse HEAD"); + + let cfg = config("grep -q resolved c.txt"); + let mut store = JsonStore::open(&git.state_dir()).unwrap(); + let (name, check) = check_of(&cfg); + sh(tmp.path(), "echo resolved > c.txt && git add c.txt"); + let r = run_check(&git, &cfg, name, check, &mut store, true).unwrap(); + assert_eq!(r.verdict.outcome, Outcome::Pass); + + publish(&git, &cfg, &store, &PublishOptions::default()).unwrap(); + assert_eq!( + sh(tmp.path(), "git log -1 --format=%P"), + head, + "a cherry-pick is an ordinary single-parent commit" + ); + assert_eq!( + sh(tmp.path(), "git log -1 --format=%s"), + "the picked commit", + "the picked commit's message carries over from MERGE_MSG" + ); + assert!( + !tmp.path().join(".git/CHERRY_PICK_HEAD").exists(), + "CHERRY_PICK_HEAD survived the commit" + ); + assert_eq!(sh(tmp.path(), "git status --porcelain"), ""); +} + +#[test] +fn squash_merge_publishes_one_commit_with_the_squash_message() { + // `git merge --squash` leaves SQUASH_MSG + AUTO_MERGE and NO MERGE_HEAD: + // one ordinary commit, whose message git takes from SQUASH_MSG. + let (tmp, git) = repo(); + sh( + tmp.path(), + "git checkout -qb other && echo a > a.txt && git add -A \ + && git commit -qm 'the squashed commit' && git checkout -q main \ + && echo m > m.txt && git add -A && git commit -qm m \ + && git merge --squash other > /dev/null", + ); + let head = sh(tmp.path(), "git rev-parse HEAD"); + assert!(tmp.path().join(".git/SQUASH_MSG").exists()); + assert!( + !tmp.path().join(".git/MERGE_HEAD").exists(), + "a squash merge records no second parent" + ); + + let cfg = config("test -f a.txt"); + let mut store = JsonStore::open(&git.state_dir()).unwrap(); + let (name, check) = check_of(&cfg); + let r = run_check(&git, &cfg, name, check, &mut store, true).unwrap(); + assert_eq!(r.verdict.outcome, Outcome::Pass); + + publish(&git, &cfg, &store, &PublishOptions::default()).unwrap(); + assert_eq!( + sh(tmp.path(), "git log -1 --format=%P"), + head, + "a squash is a single-parent commit" + ); + assert_eq!( + sh(tmp.path(), "git log -1 --format=%s"), + "Squashed commit of the following:", + "the squash message must not be thrown away" + ); + assert!(sh(tmp.path(), "git log -1 --format=%B").contains("the squashed commit")); + for state in ["SQUASH_MSG", "MERGE_MSG", "AUTO_MERGE"] { + assert!( + !tmp.path().join(".git").join(state).exists(), + "{state} survived the squash commit" + ); + } + assert_eq!(sh(tmp.path(), "git status --porcelain"), ""); +} + +#[test] +fn a_stale_merge_head_never_mints_a_second_merge_commit() { + // A MERGE_HEAD left behind by a crashed cleanup names a commit HEAD + // already contains. Publishing must read that as "no merge in progress", + // not as a merge to redo — and must retire the stale file. + let (tmp, git) = repo(); + let cfg = config("test -f feature.txt"); + let mut store = JsonStore::open(&git.state_dir()).unwrap(); + let head = sh(tmp.path(), "git rev-parse HEAD"); + std::fs::write(tmp.path().join(".git/MERGE_HEAD"), format!("{head}\n")).unwrap(); + std::fs::write( + tmp.path().join(".git/MERGE_MSG"), + "Merge branch 'already-merged'\n", + ) + .unwrap(); + + assert!( + !greentree::publish::merge_in_progress(&git).unwrap(), + "a MERGE_HEAD HEAD already contains is not a merge in progress" + ); + + sh(tmp.path(), "echo done > feature.txt"); + let (name, check) = check_of(&cfg); + let r = run_check(&git, &cfg, name, check, &mut store, true).unwrap(); + assert_eq!(r.verdict.outcome, Outcome::Pass); + + let report = publish(&git, &cfg, &store, &PublishOptions::default()).unwrap(); + assert!(!report.noop); + assert_eq!( + sh(tmp.path(), "git log -1 --format=%P"), + head, + "the stale MERGE_HEAD became a second parent" + ); + assert!( + sh(tmp.path(), "git log -1 --format=%s").starts_with("greentree: verified tree"), + "a stale merge's message must not be inherited either" + ); + assert!( + !tmp.path().join(".git/MERGE_HEAD").exists(), + "stale MERGE_HEAD survived the publish" + ); + assert_eq!(sh(tmp.path(), "git status --porcelain"), ""); +} + +#[test] +fn comment_char_auto_keeps_a_message_whose_lines_start_with_hash() { + // `core.commentChar = auto` picks the first character no line of the + // message starts with; stripping `#` regardless would eat this subject. + let (tmp, git) = repo(); + sh( + tmp.path(), + "git config core.commentChar auto \ + && git checkout -qb other && echo a > c.txt && git add -A \ + && git commit -qm '#42: the picked commit' && git checkout -q main \ + && echo b > c.txt && git add -A && git commit -qm b \ + && git cherry-pick other > /dev/null 2>&1 || true", + ); + assert!(tmp.path().join(".git/CHERRY_PICK_HEAD").exists()); + + let cfg = config("grep -q resolved c.txt"); + let mut store = JsonStore::open(&git.state_dir()).unwrap(); + let (name, check) = check_of(&cfg); + sh(tmp.path(), "echo resolved > c.txt && git add c.txt"); + run_check(&git, &cfg, name, check, &mut store, true).unwrap(); + + publish(&git, &cfg, &store, &PublishOptions::default()).unwrap(); + assert_eq!( + sh(tmp.path(), "git log -1 --format=%s"), + "#42: the picked commit", + "the subject was stripped as a comment" + ); +} + +#[test] +fn rebase_tests_but_refuses_to_publish() { + let (tmp, git) = repo(); + sh( + tmp.path(), + "git checkout -qb feature && echo f > c.txt && git add -A && git commit -qm f \ + && git checkout -q main && echo m > c.txt && git add -A && git commit -qm m \ + && git checkout -q feature && git rebase main > /dev/null 2>&1 || true", + ); + sh(tmp.path(), "echo resolved > c.txt && git add c.txt"); + assert!( + tmp.path().join(".git/rebase-merge").exists() + || tmp.path().join(".git/rebase-apply").exists(), + "expected a rebase in progress" + ); + + // Testing is unaffected: the tree is honest, and the verdict is cached + // for the tree the rebase will end up producing. + let cfg = config("grep -q resolved c.txt"); + let mut store = JsonStore::open(&git.state_dir()).unwrap(); + let (name, check) = check_of(&cfg); + let r = run_check(&git, &cfg, name, check, &mut store, true).unwrap(); + assert_eq!(r.verdict.outcome, Outcome::Pass); + + // Publishing is not: the next commit belongs to the rebase's sequencer. + match publish(&git, &cfg, &store, &PublishOptions::default()) { + Err(Error::Unpublishable(msg)) => { + assert!(msg.contains("rebase --continue"), "unhelpful: {msg}") + } + other => panic!("expected Unpublishable, got {other:?}"), + } + + // …and `gate` refuses with the same exit code, before running anything. + sh( + tmp.path(), + "printf 'version: 1\\nmin_free_disk: \"0\"\\nchecks:\\n test:\\n run: \"true\"\\n' \ + > greentree.yaml", + ); + let (code, _, stderr) = bin(tmp.path(), &["gate"]); + assert_eq!(code, 12, "gate during a rebase: {stderr}"); + assert!(stderr.contains("rebase"), "unhelpful: {stderr}"); + + // …and `status` says so instead of promising a publish that would refuse. + let (code, stdout, _) = bin(tmp.path(), &["status", "--json"]); + assert_eq!(code, 0, "status keeps working: {stdout}"); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(v["publishable"], serde_json::Value::Bool(false)); + assert!( + v["publish_blocked"] + .as_str() + .unwrap_or("") + .contains("rebase"), + "status hid the rebase: {stdout}" + ); + // A rebase detaches HEAD: an empty string reads like a branch we failed + // to print, so the contract is null. + assert_eq!( + v["branch"], + serde_json::Value::Null, + "detached HEAD must report a null branch: {stdout}" + ); +} + +#[test] +fn interrupted_merge_publish_replays_the_full_parent_list() { + // Crash between commit-tree and update-ref: the journal is the only + // record of the merge's parents, and the retry must rebuild the SAME + // commit rather than a single-parent one. + let (tmp, git) = repo(); + let (ours, theirs) = conflicted_merge(tmp.path()); + let cfg = config("grep -q resolved c.txt"); + let mut store = JsonStore::open(&git.state_dir()).unwrap(); + let (name, check) = check_of(&cfg); + sh(tmp.path(), "echo resolved > c.txt && git add c.txt"); + let tree = run_check(&git, &cfg, name, check, &mut store, true) + .unwrap() + .verdict + .tree; + + let commit = sh( + tmp.path(), + &format!("git commit-tree {tree} -p {ours} -p {theirs} -m 'interrupted merge'"), + ); + let journal = serde_json::json!({ + "schema_version": 2, "tree": tree, "branch": "main", + "parents": [ours, theirs], + "change_id": "deadbeefdeadbeefdeadbeefdeadbeef", "new_commit": commit, "lease": null, + }); + std::fs::write( + git.state_dir().join("publish-journal.json"), + serde_json::to_vec(&journal).unwrap(), + ) + .unwrap(); + + let report = publish(&git, &cfg, &store, &PublishOptions::default()).unwrap(); + assert_eq!( + report.commit.as_deref(), + Some(&*commit), + "the journaled commit was reused, not re-minted" + ); + assert_eq!( + report.change_id.as_deref(), + Some("deadbeefdeadbeefdeadbeefdeadbeef") + ); + assert_eq!( + sh(tmp.path(), "git log -1 --format=%P"), + format!("{ours} {theirs}") + ); + assert!(!tmp.path().join(".git/MERGE_HEAD").exists()); + assert!(!git.state_dir().join("publish-journal.json").exists()); } #[test] @@ -285,6 +680,9 @@ fn interrupted_publish_resumes_from_journal() { tmp.path(), &format!("git update-ref refs/heads/main {commit} {parent}"), ); + // Deliberately the schema-1 shape: a journal left in flight by an older + // greentree must still be readable, its single `parent` becoming the + // one-element parent list. let journal = serde_json::json!({ "schema_version": 1, "tree": tree, @@ -300,6 +698,14 @@ fn interrupted_publish_resumes_from_journal() { serde_json::to_vec(&journal).unwrap(), ) .unwrap(); + assert_eq!( + greentree::publish::load_journal(&git) + .unwrap() + .unwrap() + .parents, + vec![parent.clone()], + "schema-1 journals migrate to the parent list" + ); let report = publish(&git, &cfg, &store, &PublishOptions::default()).unwrap(); assert!(report.resumed, "must take the resume path"); @@ -319,6 +725,37 @@ fn interrupted_publish_resumes_from_journal() { ); } +#[test] +fn housekeeping_that_fails_after_the_commit_warns_instead_of_failing() { + // A stale `index.lock` (a crashed git, or one still running) makes the + // post-commit index sync impossible. The commit exists and the branch + // already moved, so reporting failure would invite a retry of a publish + // that already happened: it is a warning on a successful report. + let (tmp, git) = repo(); + let cfg = config("true"); + let mut store = JsonStore::open(&git.state_dir()).unwrap(); + sh(tmp.path(), "echo v > f.txt"); + let (name, check) = check_of(&cfg); + run_check(&git, &cfg, name, check, &mut store, true).unwrap(); + + let lock = tmp.path().join(".git/index.lock"); + std::fs::write(&lock, b"").unwrap(); + let report = publish(&git, &cfg, &store, &PublishOptions::default()).unwrap(); + std::fs::remove_file(&lock).unwrap(); + + let commit = report.commit.clone().expect("the commit was still created"); + assert_eq!(sh(tmp.path(), "git rev-parse HEAD"), commit); + assert!( + report.warnings.iter().any(|w| w.contains("index sync")), + "the failed sync went unreported: {:?}", + report.warnings + ); + assert!( + !git.state_dir().join("publish-journal.json").exists(), + "the publish completed, so its journal is spent" + ); +} + #[test] fn changed_declared_input_invalidates_cache() { let (tmp, git) = repo(); @@ -421,7 +858,7 @@ fn watch_once_verifies_a_settling_tree() { let (tmp, git) = repo(); sh( tmp.path(), - "printf 'version: 1\\nchecks:\\n test:\\n run: \"test -f base.txt\"\\n watch: true\\n' > greentree.yaml", + "printf 'version: 1\\nmin_free_disk: \"0\"\\nchecks:\\n test:\\n run: \"test -f base.txt\"\\n watch: true\\n' > greentree.yaml", ); let mut child = Command::new(env!("CARGO_BIN_EXE_greentree")) @@ -466,6 +903,67 @@ fn watch_once_verifies_a_settling_tree() { ); } +#[test] +fn watch_survives_a_disk_floor_refusal() { + // A pre-start disk refusal must not be fatal to a long-lived watcher: + // it is reported like any other cycle outcome and the next edit is still + // picked up. (One-shot `test`/`gate` still exit 16 — see + // `a_floor_no_disk_can_meet_refuses_the_run`.) + let (tmp, git) = repo(); + sh( + tmp.path(), + "printf 'version: 1\\nmin_free_disk: \"100T\"\\nchecks:\\n test:\\n run: \"true\"\\n watch: true\\n' > greentree.yaml", + ); + + let mut child = Command::new(env!("CARGO_BIN_EXE_greentree")) + .args(["watch", "--json"]) + .current_dir(tmp.path()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn watch"); + let stdout = child.stdout.take().expect("piped stdout"); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + use std::io::BufRead as _; + for line in std::io::BufReader::new(stdout) + .lines() + .map_while(Result::ok) + { + let _ = tx.send(line); + } + }); + + let wait = std::time::Duration::from_secs(20); + std::thread::sleep(std::time::Duration::from_millis(700)); + sh(tmp.path(), "echo edit > watched.txt"); + let first = rx.recv_timeout(wait).expect("no refusal line from watch"); + let v: serde_json::Value = serde_json::from_str(&first).expect("json cycle line"); + assert_eq!( + v["results"][0]["outcome"], "disk_exhausted", + "refusal line: {first}" + ); + assert!( + v["error"].as_str().unwrap_or("").contains("min_free_disk"), + "refusal line names no reason: {first}" + ); + + // Still watching: a later edit is still verified (here, refused again). + sh(tmp.path(), "echo again > watched.txt"); + let second = rx + .recv_timeout(wait) + .expect("watch stopped watching after a refusal"); + assert!(second.contains("disk_exhausted"), "second line: {second}"); + assert!( + child.try_wait().expect("try_wait").is_none(), + "watch exited on a disk refusal instead of continuing" + ); + + let _ = child.kill(); + let _ = child.wait(); + let _ = git; +} + #[test] fn resume_path_still_requires_verification() { // A leftover journal must never bypass the gate: same setup as the @@ -628,6 +1126,7 @@ fn colliding_check_names_get_distinct_logs() { fresh: None, timeout: None, watch: false, + min_free_disk: None, }, ); } @@ -636,6 +1135,7 @@ fn colliding_check_names_get_distinct_logs() { checks, snapshot: Default::default(), inputs: Vec::new(), + min_free_disk: Some(DiskSize(0)), }; let mut store = JsonStore::open(&git.state_dir()).unwrap(); let dot = run_check(&git, &cfg, "a.b", &cfg.checks["a.b"], &mut store, true).unwrap(); @@ -774,12 +1274,48 @@ fn bin(dir: &Path, args: &[&str]) -> (i32, String, String) { ) } +#[test] +fn a_floor_no_disk_can_meet_refuses_the_run() { + // No box has 100T free, so the floor fires deterministically; setting it + // to "0" is the documented escape hatch and must let the same check run. + let (tmp, _git) = repo(); + let cfg = |floor: &str| { + format!( + "printf 'version: 1\\nmin_free_disk: \"{floor}\"\\nchecks:\\n \ + test:\\n run: \"true\"\\n' > greentree.yaml" + ) + }; + + sh(tmp.path(), &cfg("100T")); + let (code, _, stderr) = bin(tmp.path(), &["test"]); + assert_eq!(code, 16, "disk floor exit code; stderr: {stderr}"); + for needle in ["test", "100", "min_free_disk"] { + assert!( + stderr.contains(needle), + "refusal must name {needle:?}: {stderr}" + ); + } + + let (code, stdout, _) = bin(tmp.path(), &["test", "--json"]); + assert_eq!(code, 16); + let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("json error object"); + assert_eq!(v["exit_code"], 16); + assert!( + v["error"].as_str().unwrap().contains("min_free_disk"), + "json error names the config key: {stdout}" + ); + + sh(tmp.path(), &cfg("0")); + let (code, stdout, stderr) = bin(tmp.path(), &["test"]); + assert_eq!(code, 0, "a zero floor disables it: {stdout}{stderr}"); +} + #[test] fn exit_codes_are_the_documented_contract() { let (tmp, _git) = repo(); sh( tmp.path(), - "printf 'version: 1\\nchecks:\\n test:\\n run: \"test -f ok.txt\"\\n required_for_publish: true\\n' > greentree.yaml", + "printf 'version: 1\\nmin_free_disk: \"0\"\\nchecks:\\n test:\\n run: \"test -f ok.txt\"\\n required_for_publish: true\\n' > greentree.yaml", ); let (code, _, _) = bin(tmp.path(), &["test"]);