Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .supertool.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,10 @@
}
}
},
"git-push": {
"budget": 1500,
"_doc": "The DEFAULT push budget for this repository, in seconds — what `:budget=SECONDS` sets per call, stated once. Reach for it when a pre-push hook runs a test suite: this repo's runs the full suite on a push to master (309.86s measured), against a 300s default, so every such push timed out having sent nothing (#1631). Precedence is :budget=SECONDS > ops.git-push.budget > 300. It must be a whole positive number of seconds, at most 1800, and STRICTLY under ops.git-push.timeout on the same merged entry — a value that is not is REFUSED naming both numbers, before anything is pushed, never clamped: past the op timeout supertool kills the process and a killed push can verify nothing (#399)."
},
"git-commit": {
"coauthor": "Max <noreply>",
"_doc": "Overrides the git preset's git-commit op. 'coauthor' passes through as SUPERTOOL_COAUTHOR — the Co-Authored-By trailer auto-appended when the commit message lacks one (default 'Max <noreply>'). Set to '' / 'none' / 'off' / 'false' to disable."
Expand Down
3 changes: 3 additions & 0 deletions .supertool.json
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,9 @@
}
},
"ops": {
"git-push": {
"budget": 1500
},
"dashboard": {
"lane_prefix": "lane-"
},
Expand Down
7 changes: 7 additions & 0 deletions changelog.d/1631.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- **`ops.git-push.budget` — a repository can now state its own push budget, instead of every push to `master` timing out to learn the same fact** ([#1631](https://github.com/Digital-Process-Tools/claude-supertool/issues/1631)). `_PUSH_TIMEOUT = 300` was a module constant reachable from nothing but the per-call `:budget=SECONDS` flag, and its own comment made the case against itself: what decides the right number "is not visible from here". It is visible from the project's config, because *this repo's pre-push hook runs a suite* is a property of the repo. Measured while filing: two pushes of one markdown commit failed at 302.70s and 302.90s with nothing sent, and the third at `:budget=1500` printed `12868 passed, 51 skipped in 309.86s` and landed. Ten seconds over the default — so the failure is total rather than occasional, and a merely bigger constant would have produced the worse regime where the same command sometimes works. A repo with no pre-push hook wants a *shorter* budget for the opposite reason. Per-repo in both directions is what makes it configuration.

Precedence is **`:budget=SECONDS` > `ops.git-push.budget` > 300**, and 300 is still the answer when neither is set. The key merges over the preset entry key-by-key like every other per-op key, so `{"git-push": {"budget": 1500}}` keeps the op's `cmd` and `timeout`; the receipt names the source of the number actually in force, and a budget the flag overrode is not consulted at all.

**Refused, never clamped.** The configured budget is validated against `ops.git-push.timeout` **from the same merged entry** and refused when it is not strictly under it, naming both numbers — matching what `_parse_budget` already does for the flag. Past the op-level cap supertool kills the process, and a killed push can verify nothing, so the caller acts on a bare `FAIL (timeout)` for a push that landed ([#399](https://github.com/Digital-Process-Tools/claude-supertool/issues/399)); on the recovery path `_report_recovery_timeout` is the only thing that would have said the worktree is paused mid-rebase ([#1615](https://github.com/Digital-Process-Tools/claude-supertool/issues/1615)). A clamp would convert *the caller asked for a number and got a different one* into a discovery made at the moment a push cannot be verified.

This value is not an op argument — it arrives from a config file and ends up as `timeout=` on a `subprocess` call, so nothing in `_safe_path` or the op's `paths` declaration stands in front of it. Every shape is checked before anything is pushed: a JSON number, whole (`bool` is an `int` in Python and is refused), positive, at most 1800, and strictly under the op timeout. A `timeout` that itself does not read as a positive whole number is a third state — the budget is refused rather than assumed safe, because a check that could not run must not return the shape of a clean result. Both keys come from one read of the merged entry rather than one from the environment and one from disk: core exports `budget` to the subprocess as `SUPERTOOL_BUDGET` for free, but `timeout` is reserved and deliberately does not, and validating two answers to the same question against each other is not a check.
28 changes: 26 additions & 2 deletions docs/presets/git.md
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ The line above the relay has three states, and it is a claim about configuration

`ran` with nothing after it gets its own sentence — *it printed nothing, so this receipt cannot say which arm it took* — because a silent hook and an absent one otherwise render identically.

One arm carries no relay and says so. A push that outlasts `_PUSH_TIMEOUT` is killed and its captured output dies with it, so the timeout receipt states that the hook's words were never captured rather than leaving a blank that reads as a hook with nothing to say.
One arm carries no relay and says so. A push that outlasts its push budget — `_PUSH_TIMEOUT`, `ops.git-push.budget` or `:budget=SECONDS`, whichever was in force — is killed and its captured output dies with it, so the timeout receipt states that the hook's words were never captured rather than leaving a blank that reads as a hook with nothing to say.

**The rebase-recovery route carries all of it too, and carried none of it until [#1490](https://github.com/Digital-Process-Tools/claude-supertool/issues/1490).** A non-fast-forward hands the push to `_recover_by_rebase`, which runs its **own** `git push` and prints its own receipts — and neither the disclosure above nor the head/tail bound followed it there. So `Status: pushed ✓ (rebased onto remote)` was the one landed-push receipt in this op that said nothing about the hook at all, which is #1448's premise turned back on it: a push that lands after a rebase is precisely a push whose hook has just run. Both of that route's `--- git output ---` dumps are bounded now as well, on the same 5/30 as the straight route, and the rejected-after-rebase arm is where the transcript is largest for exactly the same reason. The `rebase could not start` arm prints no hook line, deliberately: no push of that route's own has run yet, so there is nothing it could say about a hook that would be about the failure it is reporting.

Expand Down Expand Up @@ -695,13 +695,37 @@ Three calls name their own budget instead, because they are the ones that legiti

| call | budget | why |
|---|---|---|
| `git push` (`git-push`) | 300s, or `:budget=SECONDS` up to 1800 | The op owns its own timeout so it can verify the remote before reporting; supertool's outer cap must not fire first |
| `git push` (`git-push`) | 300s, or `ops.git-push.budget`, or `:budget=SECONDS` up to 1800 | The op owns its own timeout so it can verify the remote before reporting; supertool's outer cap must not fire first |
| `git fetch` / `git rebase` on `git-push`'s recovery path | 120s, or what is left of the push budget | Can land on a worktree git has already paused ([#640](https://github.com/Digital-Process-Tools/claude-supertool/issues/640)) |
| `git commit` (`git-commit`) | 30s | Runs whatever the pre-commit hook chain is |
| `git merge` (`git-merge`) | 30s | Runs merge drivers, potentially over the whole tree |

**An explicit budget wins; the environment sets the default** ([#704](https://github.com/Digital-Process-Tools/claude-supertool/issues/704)). Setting `SUPERTOOL_GIT_TIMEOUT=5` to tighten `git-status` does not cap `git-push`'s 300s and report a push still in flight as failed.

#### `ops.git-push.budget` — the default your repository chooses

`:budget=SECONDS` is per *invocation* — see the deadline section below for what it means within one — and there are repositories where it is the right answer on **every** invocation: a pre-push hook that runs the suite on a push to `master` cannot finish inside 300s, so the flag has to be retyped every session or the push times out having sent nothing. Set the default once instead ([#1631](https://github.com/Digital-Process-Tools/claude-supertool/issues/1631)):

```json
{
"ops": {
"git-push": { "budget": 1500 }
}
}
```

Precedence is **`:budget=SECONDS` > `ops.git-push.budget` > 300**, and 300 is still the answer when neither is set. The key merges over the shipped preset entry key-by-key, so writing `budget` alone keeps the op's `cmd`, `timeout` and everything else; `registry:git-push` renders the merged result with the source of each key.

**It is refused, never clamped, and never silently ignored.** The budget has to stay *strictly* under `ops.git-push.timeout` from the same merged entry — past that cap supertool kills the process, and a killed push cannot ask the remote what landed, which is the verdict this op exists to produce ([#399](https://github.com/Digital-Process-Tools/claude-supertool/issues/399)). A configured value that is not a whole positive number of seconds, is above 1800, is at or above the op timeout, or that could not be checked against the op timeout at all, refuses the push before anything is sent and names both numbers. A push that never happened is recoverable by fixing one line of config; a push under a clock nobody chose is discovered when it cannot be verified.

A budget the flag overrode is not consulted, so a broken key cannot refuse a push whose clock it does not set. When the config value is the one in force, the receipt says so by name:

```
Push budget: 1500s (ops.git-push.budget — default is 300s)
```

**Why this is not just a bigger default.** The suite behind this repository's own pre-push hook takes 309.86s against the 300s default — ten seconds, which is well inside normal variance. A raised constant would move a repo like this from *always fails* to *sometimes fails*, from the same command; and a repo with no pre-push hook wants a **shorter** budget, because there the only thing a long one buys is a longer wait before an honest failure. The number is per-repo in both directions.

**`git-push`'s budget is a deadline on its pushing, not a per-call timeout** ([#1615](https://github.com/Digital-Process-Tools/claude-supertool/issues/1615)). `:budget=N` means *this op stops pushing within N seconds of starting*, and the clock covers the initial push, the recovery fetch, the rebase and the re-push between them. It used to mean *each `git push` gets N*, which on the non-fast-forward path spent `2N + 240` — so `:budget=1800` asked for 3840s inside an op capped at 1920, and past that cap supertool kills the process, on the one path where the receipt is the only thing that would say the worktree is paused mid-rebase.

The clock opens at the first `git push`, so a run with one push is unchanged. What it costs is on the recovery: a first push that spends most of `N` and is *then* rejected non-fast-forward leaves little or nothing for the rest, and the rest is **declined rather than run short** — `NOT PUSHED - BUDGET SPENT`, naming whether the rebase had already replayed your branch. A `git push` launched on an expired clock is killed before it can verify anything, and on this op the verdict is the whole product. Raise `:budget` and retry; the branch is already rebased, so the retry is a fast-forward. The preamble that picks a remote and the receipt that reads the result stay outside the clock — the receipt deliberately, because an expiring clock past the point of no return must never cost you the answer ([#675](https://github.com/Digital-Process-Tools/claude-supertool/issues/675)).
Expand Down
2 changes: 1 addition & 1 deletion presets/git.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@
"safety": "acts",
"cmd": "{python} {path}git/push.py {args}",
"timeout": 1920,
"description": "Push current branch (sets upstream if missing). ALWAYS ends on a one-line `[result]` verdict — PUSHED / NOT PUSHED (already up to date | REJECTED | REBASE PAUSED | UNVERIFIED | no push attempted) with branch → remote/ref @ sha — so the answer survives `| tail -3`. The post-push sha is read back off the real remote via ls-remote and labelled verified/unverified; a sha that was not read is never printed as if it were. Receipt: Repo (which LOCAL repository these commits came from), remote before/after, ahead/behind, MR/PR + pipeline, mergeability, behind-target, uncommitted-leftover COUNT (list: git-status:full), watch cmd. Non-ff auto-rebases (conflict → paused + git-conflicts). Hook amend+push reported as PUSHED. A push that outlasts its budget is verified against the remote ref before any verdict — landed = PUSHED. A fetch/rebase on the non-fast-forward recovery path that outlasts its own budget reports the WORKTREE state — rebase in progress (with continue/abort), not started, or explicitly unknown — instead of a traceback. The stale-base check follows the branch's real upstream remote (not a hardcoded origin) and says `skipped` when the target ref does not resolve, so silence means only `checked, base is fresh`. On a branch with NO upstream the push remote is RESOLVED, not assumed: branch.<name>.pushRemote, remote.pushDefault, branch.<name>.remote (git's own order), then `origin` if it exists, then the only remote if there is exactly one — so `git clone -o gitlab` and fork/upstream layouts work. Two or more remotes with none named origin and nothing configured is REFUSED (exit 1, nothing pushed) naming the candidates, because creating a branch on a guessed remote is not recoverable by an error message. A resolved remote or ref that begins with `-` is REFUSED by name before any argv is built: those keys are read verbatim (git accepts a URL in them) and `git push -u --receive-pack=<cmd> HEAD` runs <cmd> — git eats the option and spawns receive-pack for the local path `HEAD` before failing to find a repository there (observed, git 2.46.2; #818, #1617). Flags: :force-with-lease, :no-verify, :budget=SECONDS (how long this op may spend PUSHING, in place of the 300s default; a DEADLINE for the whole pushing phase, not a per-call timeout — the non-fast-forward recovery's fetch, rebase and re-push all draw from what is left of it, and a phase with nothing left is declined as `NOT PUSHED - BUDGET SPENT` rather than launched on an expired clock (#1615) — the flag to reach for when a pre-push hook runs a test suite, which is where :no-verify is least appropriate; capped at 1800s, and an unreadable, non-positive, contradicted or over-cap value is REFUSED before anything is pushed rather than clamped), :watch (spawns a background pipeline poller; falls back to the running interpreter + supertool.py where the ./supertool wrapper is absent, e.g. a git worktree, and names the reason if it cannot start). An UNKNOWN flag is REFUSED before anything is pushed (exit 2) — never silently dropped. An upstream that resolves to a DIFFERENT branch — the default outcome of `git worktree add -b <new> <base>`, so every st-wt/NNN branch starts here — is still refused rather than guessed through, but both ways out are now flags on this op instead of raw `git push` lines the caller's own hook may forbid: `:set-upstream` pushes the branch under its own name and retargets tracking to <remote>/<branch> (the usual first push), `:to-upstream` pushes onto the tracked ref on purpose with an explicit refspec. Asking for both is REFUSED (exit 2) naming the two targets — they are different refs and precedence would be the guess the refusal exists to prevent.",
"description": "Push current branch (sets upstream if missing). ALWAYS ends on a one-line `[result]` verdict — PUSHED / NOT PUSHED (already up to date | REJECTED | REBASE PAUSED | UNVERIFIED | no push attempted) with branch → remote/ref @ sha — so the answer survives `| tail -3`. The post-push sha is read back off the real remote via ls-remote and labelled verified/unverified; a sha that was not read is never printed as if it were. Receipt: Repo (which LOCAL repository these commits came from), remote before/after, ahead/behind, MR/PR + pipeline, mergeability, behind-target, uncommitted-leftover COUNT (list: git-status:full), watch cmd. Non-ff auto-rebases (conflict → paused + git-conflicts). Hook amend+push reported as PUSHED. A push that outlasts its budget is verified against the remote ref before any verdict — landed = PUSHED. A fetch/rebase on the non-fast-forward recovery path that outlasts its own budget reports the WORKTREE state — rebase in progress (with continue/abort), not started, or explicitly unknown — instead of a traceback. The stale-base check follows the branch's real upstream remote (not a hardcoded origin) and says `skipped` when the target ref does not resolve, so silence means only `checked, base is fresh`. On a branch with NO upstream the push remote is RESOLVED, not assumed: branch.<name>.pushRemote, remote.pushDefault, branch.<name>.remote (git's own order), then `origin` if it exists, then the only remote if there is exactly one — so `git clone -o gitlab` and fork/upstream layouts work. Two or more remotes with none named origin and nothing configured is REFUSED (exit 1, nothing pushed) naming the candidates, because creating a branch on a guessed remote is not recoverable by an error message. A resolved remote or ref that begins with `-` is REFUSED by name before any argv is built: those keys are read verbatim (git accepts a URL in them) and `git push -u --receive-pack=<cmd> HEAD` runs <cmd> — git eats the option and spawns receive-pack for the local path `HEAD` before failing to find a repository there (observed, git 2.46.2; #818, #1617). Flags: :force-with-lease, :no-verify, :budget=SECONDS (how long this op may spend PUSHING, in place of the 300s default; a DEADLINE for the whole pushing phase, not a per-call timeout — the non-fast-forward recovery's fetch, rebase and re-push all draw from what is left of it, and a phase with nothing left is declined as `NOT PUSHED - BUDGET SPENT` rather than launched on an expired clock (#1615) — the flag to reach for when a pre-push hook runs a test suite, which is where :no-verify is least appropriate; capped at 1800s, and an unreadable, non-positive, contradicted or over-cap value is REFUSED before anything is pushed rather than clamped; the DEFAULT itself is settable per repository as ops.git-push.budget in .supertool.json — precedence :budget > ops.git-push.budget > 300 — validated against ops.git-push.timeout FROM THE SAME MERGED ENTRY and refused naming both numbers, before anything is pushed, when it is not strictly under it (#1631)), :watch (spawns a background pipeline poller; falls back to the running interpreter + supertool.py where the ./supertool wrapper is absent, e.g. a git worktree, and names the reason if it cannot start). An UNKNOWN flag is REFUSED before anything is pushed (exit 2) — never silently dropped. An upstream that resolves to a DIFFERENT branch — the default outcome of `git worktree add -b <new> <base>`, so every st-wt/NNN branch starts here — is still refused rather than guessed through, but both ways out are now flags on this op instead of raw `git push` lines the caller's own hook may forbid: `:set-upstream` pushes the branch under its own name and retargets tracking to <remote>/<branch> (the usual first push), `:to-upstream` pushes onto the tracked ref on purpose with an explicit refspec. Asking for both is REFUSED (exit 2) naming the two targets — they are different refs and precedence would be the guess the refusal exists to prevent.",
"syntax": "git-push[:force-with-lease][:no-verify][:watch][:budget=SECONDS][:set-upstream|:to-upstream]",
"replaces": [
{ "argv": "git push", "unless_flag": ["--tags", "--follow-tags", "--delete", "-d", "--mirror", "--all", "--prune", "--force", "-f", "--dry-run", "-n"], "use": "git-push" },
Expand Down
Loading