acuvo-code 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/git.mjs
ADDED
|
@@ -0,0 +1,1341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐ GIT — THE AGENT CAN FINALLY SEE WHAT IT CHANGED ───────────────────────
|
|
3
|
+
*
|
|
4
|
+
* Until now the CLI wrote files and had no idea what it had done to the repo.
|
|
5
|
+
* That gap is bigger than it sounds, because it removes the one check a human
|
|
6
|
+
* developer performs constantly and for free: `git diff` before committing.
|
|
7
|
+
* Without it the model's only model of the working tree is its own memory of
|
|
8
|
+
* the files it wrote this session — which is wrong the moment anything existed
|
|
9
|
+
* before the session started.
|
|
10
|
+
*
|
|
11
|
+
* ── ⚠️ WHY GIT IS NOT A `run_command` BINARY ────────────────────────────────
|
|
12
|
+
* The obvious implementation is to add `git` to `ALLOWED_BINARIES`. It is the
|
|
13
|
+
* wrong one, twice over, and both reasons are structural rather than fussy:
|
|
14
|
+
*
|
|
15
|
+
* 1. **The character whitelist would make commit impossible.** `command.mjs`
|
|
16
|
+
* refuses quotes, so a commit message could never contain an apostrophe, a
|
|
17
|
+
* comma, a newline or a colon — i.e. could never be a commit message. The
|
|
18
|
+
* only way to allow one through a command STRING is to weaken the
|
|
19
|
+
* whitelist that protects every other command, for the sake of one verb.
|
|
20
|
+
* 2. **Git's surface is enormous and mostly destructive.** `reset --hard`,
|
|
21
|
+
* `checkout .`, `clean -fdx`, `push --force`, `filter-branch` — an agent
|
|
22
|
+
* with a git command string is one hallucination away from deleting work
|
|
23
|
+
* that was never its own. A flag denylist for a program with a thousand
|
|
24
|
+
* flags is a promise nobody can keep.
|
|
25
|
+
*
|
|
26
|
+
* ⭐ So git is exposed as STRUCTURED VERBS. The model supplies parameters, this
|
|
27
|
+
* file builds the exact `argv`, and there is no path from a model-authored
|
|
28
|
+
* string to a git subcommand it was not given. `push`, `reset`, `checkout`,
|
|
29
|
+
* `clean`, `rebase` and `remote` are not refused by a check — they are simply
|
|
30
|
+
* not expressible. That is the whitelist doctrine the rest of the package uses,
|
|
31
|
+
* applied where it matters most.
|
|
32
|
+
*
|
|
33
|
+
* ── ⚠️ THE SUBDIRECTORY TRAP, WHICH IS THE REAL BUG IN HERE ─────────────────
|
|
34
|
+
* `git` walks UP from its cwd to find a repository. Point this CLI at
|
|
35
|
+
* `~/work/monorepo/packages/thing` and every command silently operates on the
|
|
36
|
+
* whole monorepo: `git status` reports other people's work in progress, and a
|
|
37
|
+
* commit lands in a repository the user never pointed us at. Nothing errors.
|
|
38
|
+
*
|
|
39
|
+
* So every verb first resolves `--show-toplevel` and REFUSES unless it equals
|
|
40
|
+
* the workspace root. A workspace inside someone else's repo is a legitimate
|
|
41
|
+
* place to write code and never a legitimate place to commit from.
|
|
42
|
+
*
|
|
43
|
+
* ── ⚠️⚠️ AND THE SECOND ESCAPE, WHICH THAT ONE DOES NOT CATCH (fixed 08-11) ──
|
|
44
|
+
* Proving the REPO is the workspace says nothing about whether a PATH inside it
|
|
45
|
+
* really is. `gitCommit` validated its paths with `normalizeRelativePath` — the
|
|
46
|
+
* pure, LEXICAL half of the workspace guard, which by design never touches the
|
|
47
|
+
* disk and therefore cannot see a link. Measured in a scratch repo: a directory
|
|
48
|
+
* junction (`mklink /J`, no elevation) pointing out of the workspace let
|
|
49
|
+
* `paths: ['link/secret.txt']` commit an outside file, `ok: true`, and
|
|
50
|
+
* `git show HEAD:link/secret.txt` printed its contents.
|
|
51
|
+
*
|
|
52
|
+
* ⭐ So the containment every file verb enforces — `resolveInWorkspace`, i.e.
|
|
53
|
+
* realpath the deepest EXISTING ancestor and compare against the realpath'd root
|
|
54
|
+
* — is now applied in `gitCommit` too, and by the same means rather than a
|
|
55
|
+
* second invention. The full argument, including why it is not too tight, sits
|
|
56
|
+
* at the check itself; the bidirectional proof is
|
|
57
|
+
* `test/git-commit-containment.test.mjs`.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
61
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
62
|
+
|
|
63
|
+
import { clampOutput, scrubEnvironment, spawnBounded } from './command.mjs';
|
|
64
|
+
import { normalizeRelativePath, resolveInWorkspace } from './workspace.mjs';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* ⚠️ THE SHAPES ARE DECLARED, NOT INFERRED — the same rule `workspace.mjs` and
|
|
68
|
+
* `command.mjs` follow, and for the same reason. Left to inference, `ok` widens
|
|
69
|
+
* to `boolean` instead of the literals, so a caller that has ALREADY checked
|
|
70
|
+
* `if (!r.ok)` still cannot reach `r.error` without a cast. A discriminated
|
|
71
|
+
* union is what makes the refusal path type-safe at every call site.
|
|
72
|
+
*
|
|
73
|
+
* @typedef {{ ok: false, error: string }} GitRefused
|
|
74
|
+
* @typedef {{ ok: true, exitCode: number | null, stdout: string, stderr: string, timedOut: boolean }} GitRan
|
|
75
|
+
* @typedef {(file: string, args: string[], opts: object) => any} SpawnImpl
|
|
76
|
+
* @typedef {{ spawnImpl?: SpawnImpl }} GitOpts
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/** Git is fast or wedged; there is no slow-but-fine case worth waiting on. */
|
|
80
|
+
export const GIT_TIMEOUT_MS = 20_000;
|
|
81
|
+
/** A diff is the output most likely to be enormous, and the model pays per token. */
|
|
82
|
+
export const MAX_DIFF_CHARS = 12_000;
|
|
83
|
+
export const MAX_COMMIT_MESSAGE_CHARS = 4_000;
|
|
84
|
+
export const MAX_COMMIT_PATHS = 50;
|
|
85
|
+
/** More than this and the answer is "read the log yourself". */
|
|
86
|
+
export const MAX_LOG_COUNT = 50;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* ⚠️ NEVER STAGED, WHATEVER THE MODEL ASKS OR `.gitignore` SAYS.
|
|
90
|
+
*
|
|
91
|
+
* The failure this prevents: the agent writes a `.env` so the app it just built
|
|
92
|
+
* can run, then helpfully commits "all the project files". The secret is now in
|
|
93
|
+
* history — a place from which deleting it does not remove it — and the repo may
|
|
94
|
+
* be pushed by a human later who has no idea it is in there.
|
|
95
|
+
*
|
|
96
|
+
* Git already refuses ignored files without `-f` (which is never passed), so
|
|
97
|
+
* this only fires when the file is NOT ignored, which is exactly the dangerous
|
|
98
|
+
* case: the repository has no protection and nobody noticed.
|
|
99
|
+
*/
|
|
100
|
+
/**
|
|
101
|
+
* ⚠️⚠️ THIS IS THE ONE LIST. `read-window.mjs` used to keep a second one
|
|
102
|
+
* (`CREDENTIAL_BASENAME`) for `read_lines` and `read_around`, written
|
|
103
|
+
* separately, and the two DISAGREED. Measured 2026-08-13 through the real
|
|
104
|
+
* dispatcher:
|
|
105
|
+
*
|
|
106
|
+
* read_lines LEAKED : vault.pfx · keys.jks · secrets.json · credentials.yml
|
|
107
|
+
* · service-account.json
|
|
108
|
+
* read_file LEAKED : .git-credentials
|
|
109
|
+
*
|
|
110
|
+
* Each list covered holes the other left, so which of a user's secrets were
|
|
111
|
+
* protected depended on which verb the model happened to pick. Both consumers
|
|
112
|
+
* now call `refusedCommitPath`, and `.git-credentials` — which only the other
|
|
113
|
+
* list had — is folded in below. Add the next pattern HERE and every consumer
|
|
114
|
+
* gains it at once.
|
|
115
|
+
*/
|
|
116
|
+
/**
|
|
117
|
+
* ⚠️ THE LIST MOVED TO `secret-paths.mjs`, A LEAF MODULE, and this file now
|
|
118
|
+
* IMPORTS it like everyone else. `workspace.mjs` needs it for `move_file` — a
|
|
119
|
+
* rename is the one verb that can relabel `.env` into something committable —
|
|
120
|
+
* and importing it from here closed a cycle the bundler cannot order:
|
|
121
|
+
* command.mjs → workspace.mjs → git.mjs → command.mjs
|
|
122
|
+
*
|
|
123
|
+
* ⚠️ AND IT IS NOT RE-EXPORTED FROM HERE. `export { x } from` is a form
|
|
124
|
+
* `scripts/bundle.mjs` refuses outright ("a bundler that half-supports a form
|
|
125
|
+
* is how a bundle ends up one byte wrong"), so every consumer imports the leaf
|
|
126
|
+
* directly. Caught by the bundle test, not by the suite — Node runs a cycle
|
|
127
|
+
* and a re-export happily, so nothing looked wrong until ship time.
|
|
128
|
+
*/
|
|
129
|
+
import { refusedCommitPath } from './secret-paths.mjs';
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* ⚠️ THE GIT ENVIRONMENT, AND WHY IT IS MORE LOCKED DOWN THAN THE COMMAND ONE.
|
|
133
|
+
*
|
|
134
|
+
* `scrubEnvironment` already removes conventionally-named secrets. On top of
|
|
135
|
+
* that:
|
|
136
|
+
*
|
|
137
|
+
* · `GIT_TERMINAL_PROMPT=0` — git asking for a username on a machine with no
|
|
138
|
+
* terminal attached is a process that hangs until the timeout kills it. Every
|
|
139
|
+
* credential prompt becomes an immediate, legible error instead.
|
|
140
|
+
* · `GIT_PAGER=cat` + `--no-pager` — a pager waits for a keypress that will
|
|
141
|
+
* never come. Same hang, different cause, so both are closed.
|
|
142
|
+
* · `GIT_OPTIONAL_LOCKS=0` — a plain `status` should not take the index lock
|
|
143
|
+
* and fight the editor the user has open in the same repo.
|
|
144
|
+
*
|
|
145
|
+
* ⚠️⚠️ THIS PARAGRAPH USED TO SAY GIT HERE HAD "NOWHERE TO SEND ANYTHING".
|
|
146
|
+
* That stopped being true on 2026-08-14 when `git_push` landed. It is still
|
|
147
|
+
* true of THIS function: `gitEnvironment` is the environment for every LOCAL
|
|
148
|
+
* verb, and a local verb has no business holding a credential. The one verb
|
|
149
|
+
* that leaves the machine builds its own environment in `pushEnvironment`
|
|
150
|
+
* below, restores exactly one variable, and says why.
|
|
151
|
+
*/
|
|
152
|
+
export function gitEnvironment(env = process.env) {
|
|
153
|
+
const out = scrubEnvironment(env);
|
|
154
|
+
out.GIT_TERMINAL_PROMPT = '0';
|
|
155
|
+
out.GIT_PAGER = 'cat';
|
|
156
|
+
out.GIT_OPTIONAL_LOCKS = '0';
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* ── ⚠️⚠️ THE SCRUB DELETES `SSH_AUTH_SOCK`, AND PUSH IS THE VERB THAT NEEDS IT ──
|
|
162
|
+
*
|
|
163
|
+
* `SECRET_NAME` in command.mjs is `/(KEY|TOKEN|SECRET|…|AUTH|…)/i` — read it at
|
|
164
|
+
* `command.mjs:814`. `SSH_AUTH_SOCK` contains "AUTH", so `scrubEnvironment`
|
|
165
|
+
* drops it, and every other verb in this file is happier for that. A push over
|
|
166
|
+
* `git@github.com:` with no agent socket does not hang (GIT_TERMINAL_PROMPT=0)
|
|
167
|
+
* — it fails with "Permission denied (publickey)", which reads like a broken
|
|
168
|
+
* key rather than an environment we emptied ourselves.
|
|
169
|
+
*
|
|
170
|
+
* ⭐ SO PUSH GETS THE SOCKET BACK AND NOTHING ELSE. The value is a path to a
|
|
171
|
+
* unix socket / named pipe, not a secret; the agent behind it will only ever
|
|
172
|
+
* sign a challenge for a process that can reach it, and the argv reaching it
|
|
173
|
+
* was built by this file. HTTPS remotes need nothing here — git's credential
|
|
174
|
+
* helper is a separate program driven by config, not by our environment — and
|
|
175
|
+
* `GITHUB_TOKEN` stays deleted, because git does not read it and `github.mjs`
|
|
176
|
+
* fetches its own copy for the API call.
|
|
177
|
+
*
|
|
178
|
+
* ⚠️ HONEST LIMIT: this is REASONED, not measured. The push proof in
|
|
179
|
+
* `test/git-deliver.test.mjs` pushes to a LOCAL BARE REPOSITORY, which needs no
|
|
180
|
+
* credential at all, so it does not exercise SSH. What IS measured is the shape
|
|
181
|
+
* of the environment — `test/git-deliver.test.mjs` asserts the socket survives
|
|
182
|
+
* and `OPENROUTER_API_KEY` does not.
|
|
183
|
+
*/
|
|
184
|
+
export const PUSH_ENV_KEEP = Object.freeze(['SSH_AUTH_SOCK', 'SSH_AGENT_PID']);
|
|
185
|
+
|
|
186
|
+
export function pushEnvironment(env = process.env) {
|
|
187
|
+
const out = gitEnvironment(env);
|
|
188
|
+
for (const name of PUSH_ENV_KEEP) {
|
|
189
|
+
const value = env[name];
|
|
190
|
+
if (typeof value === 'string' && value !== '') out[name] = value;
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Run git with an argv this file constructed. `args` never contains anything
|
|
197
|
+
* derived from a model string except as a SEPARATE ARRAY ELEMENT, which is
|
|
198
|
+
* where the safety comes from: an element is one argument no matter what is in
|
|
199
|
+
* it, because no shell exists to re-split it.
|
|
200
|
+
*/
|
|
201
|
+
/** @returns {Promise<GitRefused | GitRan>} */
|
|
202
|
+
async function git(root, args, { spawnImpl, timeoutMs = GIT_TIMEOUT_MS, env = null } = {}) {
|
|
203
|
+
const run = await spawnBounded({
|
|
204
|
+
file: 'git',
|
|
205
|
+
args: ['--no-pager', ...args],
|
|
206
|
+
cwd: root,
|
|
207
|
+
timeoutMs,
|
|
208
|
+
spawnImpl,
|
|
209
|
+
// ⚠️ `env` IS AN OVERRIDE, NOT A REPLACEMENT OF THE RULE. Every local verb
|
|
210
|
+
// gets `gitEnvironment()`; only `gitPush` passes anything, and what it
|
|
211
|
+
// passes is `pushEnvironment()` — which is `gitEnvironment()` plus the ssh
|
|
212
|
+
// agent socket. There is no path here to an unscrubbed environment.
|
|
213
|
+
env: env ?? gitEnvironment(),
|
|
214
|
+
});
|
|
215
|
+
if (!run.ok) {
|
|
216
|
+
/**
|
|
217
|
+
* ⚠️ ENOENT HERE MEANS GIT IS NOT INSTALLED, and the raw spawn error says
|
|
218
|
+
* "spawn git ENOENT", which reads like a bug in this CLI rather than a
|
|
219
|
+
* missing program. Translate it once, here.
|
|
220
|
+
*/
|
|
221
|
+
const missing = /ENOENT/.test(run.error ?? '');
|
|
222
|
+
return { ok: false, error: missing ? 'git is not installed, or not on PATH for this process' : run.error };
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
ok: true,
|
|
226
|
+
exitCode: run.exitCode,
|
|
227
|
+
stdout: run.stdout,
|
|
228
|
+
stderr: run.stderr,
|
|
229
|
+
timedOut: run.timedOut,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* ── ⚠️⚠️ `realpath` DOES NOT EXPAND AN 8.3 SHORT NAME, AND GIT RETURNS THE LONG ONE ──
|
|
235
|
+
*
|
|
236
|
+
* Measured on Windows 2026-08-13, and it is not a corner case:
|
|
237
|
+
*
|
|
238
|
+
* C:\...\Temp\ACUVOR~1 (the 8.3 alias, what the caller had)
|
|
239
|
+
* realpathSync(...) -> C:\...\Temp\ACUVOR~1 ← unchanged
|
|
240
|
+
* git rev-parse -> C:/.../Temp/acuvorunneradminprobe
|
|
241
|
+
*
|
|
242
|
+
* Two spellings of ONE directory, and the string compare above — case-folded,
|
|
243
|
+
* realpath'd, and written precisely to avoid being "too literal" — still calls
|
|
244
|
+
* them different. Every git verb is then refused with "this workspace is INSIDE
|
|
245
|
+
* a git repository rooted at …", naming a path that IS the workspace.
|
|
246
|
+
*
|
|
247
|
+
* ⭐ WHO HITS IT: anyone reached through an 8.3 alias, which Windows generates
|
|
248
|
+
* for any name over eight characters. `C:\Users\<name>` longer than eight,
|
|
249
|
+
* `C:\PROGRA~1`, and — the reason this surfaced at all — GitHub's own Windows
|
|
250
|
+
* runner, where `runneradmin` is `RUNNER~1`. It broke SEVEN tests the moment
|
|
251
|
+
* the Windows matrix first executed.
|
|
252
|
+
*
|
|
253
|
+
* ⭐ THE FIX IS TO STOP COMPARING SPELLINGS. The question was never "are these
|
|
254
|
+
* strings equal", it is "are these the same directory", and the filesystem
|
|
255
|
+
* answers that directly: device + inode. Node exposes both on Windows (the
|
|
256
|
+
* inode is the NTFS file index), so this needs no dependency and no platform
|
|
257
|
+
* branch. `bigint: true` because a 64-bit file index does not survive a double.
|
|
258
|
+
*
|
|
259
|
+
* ⚠️ It can only ever turn a refusal INTO an allow, and only when the two paths
|
|
260
|
+
* are provably one directory — a genuine outer repository is a different
|
|
261
|
+
* directory with a different index, so the guard this backs up is untouched.
|
|
262
|
+
* Anything unreadable, or a filesystem that reports no inode at all (0 on some
|
|
263
|
+
* network and FAT volumes), falls back to the string compare rather than
|
|
264
|
+
* guessing: unknown identity must not read as "same".
|
|
265
|
+
*/
|
|
266
|
+
function sameDirectoryOnDisk(a, b) {
|
|
267
|
+
try {
|
|
268
|
+
const sa = statSync(a, { bigint: true });
|
|
269
|
+
const sb = statSync(b, { bigint: true });
|
|
270
|
+
if (sa.ino === 0n || sb.ino === 0n) return false;
|
|
271
|
+
return sa.dev === sb.dev && sa.ino === sb.ino;
|
|
272
|
+
} catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Resolve the repository and prove it is THIS workspace — see the subdirectory
|
|
279
|
+
* trap in the header. Every verb goes through here first.
|
|
280
|
+
*
|
|
281
|
+
* ── ⚠️⚠️ THE GUARD WAS APPLIED TO THE READS TOO, AND THAT COST A REAL USER
|
|
282
|
+
* THEIR WHOLE GIT SURFACE (fixed 2026-08-14) ────────────────────────
|
|
283
|
+
*
|
|
284
|
+
* MEASURED, in this very checkout, before the fix — `acuvo-code/` is a
|
|
285
|
+
* subdirectory of the outer repository:
|
|
286
|
+
*
|
|
287
|
+
* gitStatus(root) -> ok:false "this workspace is INSIDE a git repository…"
|
|
288
|
+
* gitDiff(root) -> ok:false "this workspace is INSIDE a git repository…"
|
|
289
|
+
* gitLog(root) -> ok:false
|
|
290
|
+
* gitCommit(root) -> ok:false
|
|
291
|
+
*
|
|
292
|
+
* Four dark verbs, for a workspace layout that is not exotic — it is what every
|
|
293
|
+
* monorepo package looks like. And the two that hurt most are the READS: an
|
|
294
|
+
* agent that just edited three files could not look at its own diff.
|
|
295
|
+
*
|
|
296
|
+
* ⭐ THE GUARD'S ARGUMENT ONLY EVER APPLIED TO WRITING. Re-read the header: the
|
|
297
|
+
* danger is "a commit lands in a repository the user never pointed us at" and
|
|
298
|
+
* "status reports other people's work in progress". The first is about writing
|
|
299
|
+
* history. The second is not an argument for refusing — it is an argument for
|
|
300
|
+
* SCOPING, which git does natively and for free: run from the workspace with a
|
|
301
|
+
* `.` pathspec and the answer is about the workspace and nothing else.
|
|
302
|
+
*
|
|
303
|
+
* So: `allowSubdirectory: true` is passed by `gitStatus`, `gitDiff` and
|
|
304
|
+
* `gitLog`, which then scope every question to the workspace directory and
|
|
305
|
+
* report paths relative to it. The write verbs — `gitCommit`, `gitBranch`,
|
|
306
|
+
* `gitPush` — leave it false and still refuse outright, because a commit, a
|
|
307
|
+
* ref and a push are repository-wide acts that cannot be scoped to a
|
|
308
|
+
* subdirectory even in principle.
|
|
309
|
+
*
|
|
310
|
+
* ⚠️ WHY THE READS ARE NOT A LEAK EITHER. Scoped to `.`, they can only report
|
|
311
|
+
* files inside the workspace — the same files `read_file` would happily open.
|
|
312
|
+
* The refusal was not protecting anything the file tools were not already
|
|
313
|
+
* handing over.
|
|
314
|
+
*/
|
|
315
|
+
/**
|
|
316
|
+
* @param {string} root
|
|
317
|
+
* @param {GitOpts & { allowSubdirectory?: boolean }} [opts]
|
|
318
|
+
* @returns {Promise<GitRefused | { ok: true, root: string, subdirectory: string }>}
|
|
319
|
+
*/
|
|
320
|
+
export async function resolveRepo(root, { spawnImpl, allowSubdirectory = false } = {}) {
|
|
321
|
+
const top = await git(root, ['rev-parse', '--show-toplevel'], { spawnImpl });
|
|
322
|
+
if (!top.ok) return top;
|
|
323
|
+
if (top.exitCode !== 0) {
|
|
324
|
+
return { ok: false, error: 'this workspace is not a git repository, so there is nothing to inspect or commit' };
|
|
325
|
+
}
|
|
326
|
+
const reported = top.stdout.trim();
|
|
327
|
+
if (!reported) return { ok: false, error: 'git did not report a repository root' };
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* ⚠️ COMPARED THROUGH `realpath`, NOT AS STRINGS. Git prints forward slashes
|
|
331
|
+
* on Windows and resolves symlinks; the workspace root may be `C:\...` and a
|
|
332
|
+
* symlinked path besides. A string compare would report a MISMATCH for the
|
|
333
|
+
* ordinary case and refuse to work at all — the failure mode of a safety
|
|
334
|
+
* check that is too literal is that it gets deleted.
|
|
335
|
+
*/
|
|
336
|
+
let a; let b;
|
|
337
|
+
try {
|
|
338
|
+
a = realpathSync(resolve(reported));
|
|
339
|
+
b = realpathSync(resolve(root));
|
|
340
|
+
} catch (err) {
|
|
341
|
+
return { ok: false, error: `could not resolve the repository path: ${err instanceof Error ? err.message : String(err)}` };
|
|
342
|
+
}
|
|
343
|
+
const same = (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b)
|
|
344
|
+
|| sameDirectoryOnDisk(a, b);
|
|
345
|
+
if (!same && allowSubdirectory) {
|
|
346
|
+
/**
|
|
347
|
+
* ⭐ THE READ PATH. The workspace is somewhere inside the repository, which
|
|
348
|
+
* is the ordinary monorepo-package shape. Work out where, so the caller can
|
|
349
|
+
* scope its question to `.` and strip the prefix off what git reports.
|
|
350
|
+
*
|
|
351
|
+
* ⚠️ `relative()` IS ASKED IN THE REALPATH'D WORLD, both sides, so a repo
|
|
352
|
+
* under a junctioned home directory does not read as an escape — the same
|
|
353
|
+
* reason the compare above is realpath'd.
|
|
354
|
+
*
|
|
355
|
+
* ⚠️ AND IT CAN STILL SAY NO. If the workspace does not resolve INSIDE the
|
|
356
|
+
* reported repository root, this is not a subdirectory at all: git found a
|
|
357
|
+
* repository by walking up from a path that realpath moved elsewhere. That
|
|
358
|
+
* is the one shape where scoping would be a guess, so it keeps the refusal.
|
|
359
|
+
*/
|
|
360
|
+
const rel = relative(a, b);
|
|
361
|
+
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
|
|
362
|
+
return { ok: true, root: a, subdirectory: rel.split(sep).join('/') };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (!same) {
|
|
366
|
+
/**
|
|
367
|
+
* ⚠️ IT NAMES BOTH SIDES, AND THAT IS NOT DECORATION.
|
|
368
|
+
*
|
|
369
|
+
* This refusal used to print only the repository root. When it fired on a
|
|
370
|
+
* Windows CI runner against a repo that WAS its own root, the message said
|
|
371
|
+
* "you are inside a repository rooted at X" where X was, to the eye, the
|
|
372
|
+
* workspace itself — an accusation with no way to check it, about the one
|
|
373
|
+
* mechanism that gates every git verb. The comparison is between two
|
|
374
|
+
* realpath'd strings, so the only useful thing to print is BOTH of them.
|
|
375
|
+
*
|
|
376
|
+
* ⭐ A safety check that is too literal gets deleted, and the step before
|
|
377
|
+
* deletion is a user who cannot tell whether it is right.
|
|
378
|
+
*/
|
|
379
|
+
return {
|
|
380
|
+
ok: false,
|
|
381
|
+
error:
|
|
382
|
+
`this workspace is INSIDE a git repository rooted at ${reported}, not at the workspace root. `
|
|
383
|
+
+ 'Writing verbs are refused here: a commit, a branch or a push is repository-wide, and would '
|
|
384
|
+
+ 'act on the whole outer repository, which nobody pointed this agent at. '
|
|
385
|
+
+ 'git_status, git_diff and git_log still work — they are scoped to this directory. '
|
|
386
|
+
+ `(repository root resolves to "${a}"; the workspace root resolves to "${b}")`,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
return { ok: true, root: b, subdirectory: '' };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Parse `status --porcelain=v1` into something a model can reason about. */
|
|
393
|
+
export function parseStatus(porcelain) {
|
|
394
|
+
const files = [];
|
|
395
|
+
for (const raw of String(porcelain).split('\n')) {
|
|
396
|
+
if (raw.length < 4) continue;
|
|
397
|
+
const x = raw[0];
|
|
398
|
+
const y = raw[1];
|
|
399
|
+
let path = raw.slice(3);
|
|
400
|
+
// A rename is printed as `old -> new`; the new name is the useful one.
|
|
401
|
+
const arrow = path.indexOf(' -> ');
|
|
402
|
+
if (arrow !== -1) path = path.slice(arrow + 4);
|
|
403
|
+
files.push({
|
|
404
|
+
path: path.replace(/^"|"$/g, ''),
|
|
405
|
+
staged: x !== ' ' && x !== '?',
|
|
406
|
+
// `??` is untracked — new work, which is the common case for this agent
|
|
407
|
+
// and the one a model most often forgets it has to `add`.
|
|
408
|
+
untracked: x === '?' && y === '?',
|
|
409
|
+
code: `${x}${y}`,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
return files;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* @param {string} root
|
|
417
|
+
* @param {GitOpts} [opts]
|
|
418
|
+
* @returns {Promise<GitRefused | { ok: true, branch: string, files: {path:string,staged:boolean,untracked:boolean,code:string}[], clean: boolean }>}
|
|
419
|
+
*/
|
|
420
|
+
export async function gitStatus(root, { spawnImpl } = {}) {
|
|
421
|
+
const repo = await resolveRepo(root, { spawnImpl, allowSubdirectory: true });
|
|
422
|
+
if (!repo.ok) return repo;
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* ⭐ `-- .` IS THE WHOLE CONTAINMENT STORY FOR A READ. Pathspecs are resolved
|
|
426
|
+
* relative to the process cwd, and cwd is the workspace, so this asks about
|
|
427
|
+
* the workspace directory and nothing else. At the repository root the
|
|
428
|
+
* pathspec is omitted entirely, so the ordinary case is byte-identical to
|
|
429
|
+
* what this function ran before.
|
|
430
|
+
*/
|
|
431
|
+
const statusArgs = ['status', '--porcelain=v1'];
|
|
432
|
+
if (repo.subdirectory) statusArgs.push('--', '.');
|
|
433
|
+
|
|
434
|
+
const [status, branch] = await Promise.all([
|
|
435
|
+
git(root, statusArgs, { spawnImpl }),
|
|
436
|
+
git(root, ['rev-parse', '--abbrev-ref', 'HEAD'], { spawnImpl }),
|
|
437
|
+
]);
|
|
438
|
+
if (!status.ok) return status;
|
|
439
|
+
if (status.exitCode !== 0) return { ok: false, error: status.stderr.trim() || 'git status failed' };
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* ⚠️ PORCELAIN PATHS ARE REPOSITORY-RELATIVE EVEN WITH A PATHSPEC, and there
|
|
443
|
+
* is no `--relative` for `status` (the config knob only touches the long
|
|
444
|
+
* format). So a workspace at `packages/thing` would be handed
|
|
445
|
+
* `packages/thing/src/a.js` — a path `read_file` refuses, because from the
|
|
446
|
+
* workspace the file is `src/a.js`. Stripping the prefix is what makes the
|
|
447
|
+
* result usable by the very next tool call, which is the only reason a status
|
|
448
|
+
* is worth paying for.
|
|
449
|
+
*/
|
|
450
|
+
const prefix = repo.subdirectory ? `${repo.subdirectory}/` : '';
|
|
451
|
+
const files = parseStatus(status.stdout).map((f) => (
|
|
452
|
+
prefix && f.path.startsWith(prefix) ? { ...f, path: f.path.slice(prefix.length) } : f
|
|
453
|
+
));
|
|
454
|
+
return {
|
|
455
|
+
ok: true,
|
|
456
|
+
// ⭐ REPORTED, not hidden. The model is being told about a slice of a bigger
|
|
457
|
+
// repository, and a slice presented as the whole is how a wrong conclusion
|
|
458
|
+
// gets drawn confidently.
|
|
459
|
+
subdirectory: repo.subdirectory || null,
|
|
460
|
+
// ⚠️ A repository with no commits reports a branch name but `rev-parse`
|
|
461
|
+
// fails; an empty string here is honest and the caller renders "(no
|
|
462
|
+
// commits yet)" rather than printing an error for a normal new repo.
|
|
463
|
+
branch: branch.ok && branch.exitCode === 0 ? branch.stdout.trim() : '',
|
|
464
|
+
files,
|
|
465
|
+
clean: files.length === 0,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* @param {string} root
|
|
471
|
+
* @param {{ path?: string | null, staged?: boolean } & GitOpts} [opts]
|
|
472
|
+
* @returns {Promise<GitRefused | { ok: true, staged: boolean, path: string | null, diff: string, truncated: boolean, empty: boolean }>}
|
|
473
|
+
*/
|
|
474
|
+
export async function gitDiff(root, { path = null, staged = false, spawnImpl } = {}) {
|
|
475
|
+
const repo = await resolveRepo(root, { spawnImpl, allowSubdirectory: true });
|
|
476
|
+
if (!repo.ok) return repo;
|
|
477
|
+
|
|
478
|
+
const args = ['diff', '--no-color'];
|
|
479
|
+
if (staged) args.push('--cached');
|
|
480
|
+
/**
|
|
481
|
+
* ── ⭐ `--relative` DOES BOTH JOBS, AND I ONLY BELIEVED THAT AFTER MEASURING ─
|
|
482
|
+
*
|
|
483
|
+
* A diff's paths live inside the PATCH TEXT (`--- a/packages/thing/a.js`), so
|
|
484
|
+
* unlike the status case they cannot be fixed up afterwards — rewriting patch
|
|
485
|
+
* text with a string replace is how a diff stops applying.
|
|
486
|
+
*
|
|
487
|
+
* ⚠️ I FIRST WROTE THIS AS `--relative` PLUS A `-- .` PATHSPEC, on the theory
|
|
488
|
+
* that `--relative` only renames headers and something else had to do the
|
|
489
|
+
* containment. Measured, cwd = `packages/thing`, git 2.50.1, with a change in
|
|
490
|
+
* BOTH `outer.md` and `packages/thing/a.js`:
|
|
491
|
+
*
|
|
492
|
+
* git diff -> both files, repo-relative headers
|
|
493
|
+
* git diff --relative -> a.js only, header `a/a.js` ← both jobs
|
|
494
|
+
* git diff -- . -> a.js only, header `a/packages/thing/a.js`
|
|
495
|
+
* git diff --relative -- . -> identical to `--relative` alone
|
|
496
|
+
*
|
|
497
|
+
* ⭐ So the pathspec was a second mechanism with NO observable effect — and a
|
|
498
|
+
* mutation test proved it: deleting it left all 21 tests green, which is the
|
|
499
|
+
* definition of a line nothing can hold to account. It is gone. `--relative`
|
|
500
|
+
* excludes changes outside the current directory by itself, which is exactly
|
|
501
|
+
* the containment we wanted, and the one line is falsifiable.
|
|
502
|
+
*
|
|
503
|
+
* ⚠️ Only when we are actually in a subdirectory. At the root it is a no-op,
|
|
504
|
+
* and a no-op flag added "for consistency" is a behaviour change waiting to
|
|
505
|
+
* be discovered by somebody else.
|
|
506
|
+
*/
|
|
507
|
+
if (repo.subdirectory) args.push('--relative');
|
|
508
|
+
// ⚠️ "." IS HOW A MODEL SAYS "EVERYTHING", and normalizeRelativePath refuses
|
|
509
|
+
// it ("resolves to the workspace root itself") — correctly, for a file tool.
|
|
510
|
+
// Here it means the default, so it is translated rather than rejected with a
|
|
511
|
+
// message the model would try to work around.
|
|
512
|
+
if (path !== null && path !== undefined && path !== '' && path !== '.' && path !== './') {
|
|
513
|
+
const norm = normalizeRelativePath(path);
|
|
514
|
+
if (!norm.ok) return { ok: false, error: `"${path}" is not a usable path: ${norm.reason}` };
|
|
515
|
+
// ⚠️ `--` FIRST. Without it a path that begins with a dash, or that happens
|
|
516
|
+
// to equal a branch name, is parsed as a revision — and `git diff main`
|
|
517
|
+
// means something entirely different from `git diff -- main`.
|
|
518
|
+
args.push('--', norm.path);
|
|
519
|
+
}
|
|
520
|
+
const run = await git(root, args, { spawnImpl });
|
|
521
|
+
if (!run.ok) return run;
|
|
522
|
+
if (run.exitCode !== 0) return { ok: false, error: run.stderr.trim() || 'git diff failed' };
|
|
523
|
+
|
|
524
|
+
const clamped = clampOutput(run.stdout, MAX_DIFF_CHARS);
|
|
525
|
+
return {
|
|
526
|
+
ok: true,
|
|
527
|
+
staged,
|
|
528
|
+
path: path ?? null,
|
|
529
|
+
subdirectory: repo.subdirectory || null,
|
|
530
|
+
diff: clamped.text,
|
|
531
|
+
truncated: clamped.truncated,
|
|
532
|
+
// ⚠️ An empty diff is a RESULT, not a failure — and specifically it is the
|
|
533
|
+
// answer to "did my edit apply?", so it must be reported as a fact rather
|
|
534
|
+
// than as an absence the model has to infer from a blank string.
|
|
535
|
+
empty: run.stdout.trim() === '',
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* @param {string} root
|
|
541
|
+
* @param {{ count?: number, path?: string | null } & GitOpts} [opts]
|
|
542
|
+
* @returns {Promise<GitRefused | { ok: true, commits: {hash:string,author:string,when:string,subject:string}[], empty: boolean }>}
|
|
543
|
+
*/
|
|
544
|
+
export async function gitLog(root, { count = 10, path = null, spawnImpl } = {}) {
|
|
545
|
+
const repo = await resolveRepo(root, { spawnImpl, allowSubdirectory: true });
|
|
546
|
+
if (!repo.ok) return repo;
|
|
547
|
+
|
|
548
|
+
const n = Math.min(Math.max(1, Number.isFinite(count) ? Math.floor(count) : 10), MAX_LOG_COUNT);
|
|
549
|
+
// Unit-separator delimited rather than a pretty format with spaces: a subject
|
|
550
|
+
// line contains anything, including whatever character seemed safe to split on.
|
|
551
|
+
const args = ['log', `-n${n}`, '--no-color', '--pretty=format:%h\u001f%an\u001f%ar\u001f%s'];
|
|
552
|
+
if (path) {
|
|
553
|
+
const norm = normalizeRelativePath(path);
|
|
554
|
+
if (!norm.ok) return { ok: false, error: `"${path}" is not a usable path: ${norm.reason}` };
|
|
555
|
+
args.push('--', norm.path);
|
|
556
|
+
} else if (repo.subdirectory) {
|
|
557
|
+
// ⭐ "recent commits" in a monorepo package means recent commits TO THIS
|
|
558
|
+
// PACKAGE. The whole-repo log is somebody else's history and would teach the
|
|
559
|
+
// model the wrong commit-message convention for the directory it is in.
|
|
560
|
+
args.push('--', '.');
|
|
561
|
+
}
|
|
562
|
+
const run = await git(root, args, { spawnImpl });
|
|
563
|
+
if (!run.ok) return run;
|
|
564
|
+
if (run.exitCode !== 0) {
|
|
565
|
+
const err = run.stderr.trim();
|
|
566
|
+
// A brand-new repo has no HEAD; that is not an error worth alarming about.
|
|
567
|
+
if (/does not have any commits yet|unknown revision/i.test(err)) {
|
|
568
|
+
return { ok: true, commits: [], empty: true, subdirectory: repo.subdirectory || null };
|
|
569
|
+
}
|
|
570
|
+
return { ok: false, error: err || 'git log failed' };
|
|
571
|
+
}
|
|
572
|
+
const commits = run.stdout
|
|
573
|
+
.split('\n')
|
|
574
|
+
.filter(Boolean)
|
|
575
|
+
.map((line) => {
|
|
576
|
+
const [hash, author, when, ...subject] = line.split('\u001f');
|
|
577
|
+
return { hash, author, when, subject: subject.join('\u001f') };
|
|
578
|
+
});
|
|
579
|
+
return { ok: true, commits, empty: commits.length === 0, subdirectory: repo.subdirectory || null };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Validate a model-authored commit message.
|
|
584
|
+
*
|
|
585
|
+
* Pure, and stricter than git is: git accepts almost anything, but a message
|
|
586
|
+
* that starts with `-` becomes a flag at some future call site, and control
|
|
587
|
+
* characters make a log unreadable in ways nobody debugs.
|
|
588
|
+
*/
|
|
589
|
+
/**
|
|
590
|
+
* @param {unknown} raw
|
|
591
|
+
* @returns {{ ok: true, message: string } | GitRefused}
|
|
592
|
+
*/
|
|
593
|
+
export function validateCommitMessage(raw) {
|
|
594
|
+
if (typeof raw !== 'string') return { ok: false, error: 'a commit message is required' };
|
|
595
|
+
const message = raw.trim();
|
|
596
|
+
if (!message) return { ok: false, error: 'the commit message is empty — say what changed and why' };
|
|
597
|
+
if (message.length > MAX_COMMIT_MESSAGE_CHARS) {
|
|
598
|
+
return { ok: false, error: `the commit message is ${message.length} characters, over the ${MAX_COMMIT_MESSAGE_CHARS} limit` };
|
|
599
|
+
}
|
|
600
|
+
if (message.startsWith('-')) {
|
|
601
|
+
return { ok: false, error: 'a commit message may not start with "-" — it would be read as a flag' };
|
|
602
|
+
}
|
|
603
|
+
// eslint-disable-next-line no-control-regex
|
|
604
|
+
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(message)) {
|
|
605
|
+
return { ok: false, error: 'the commit message contains control characters (newlines and tabs are fine, nothing else)' };
|
|
606
|
+
}
|
|
607
|
+
return { ok: true, message };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Stage the named paths and commit them.
|
|
612
|
+
*
|
|
613
|
+
* ── ⚠️ WHY `paths` IS REQUIRED AND THERE IS NO "COMMIT EVERYTHING" ──────────
|
|
614
|
+
* `git add -A` is one keystroke for a human who can see the file list, and a
|
|
615
|
+
* loaded gun for an agent that cannot. It sweeps up the scratch file, the
|
|
616
|
+
* accidental 40MB fixture, the other lane's half-finished work in the same
|
|
617
|
+
* checkout, and the `.env` written two tool calls ago. Requiring the paths
|
|
618
|
+
* forces the model to have looked — which is the behaviour we want anyway, and
|
|
619
|
+
* `git_status` is right there.
|
|
620
|
+
*/
|
|
621
|
+
/**
|
|
622
|
+
* @param {string} root
|
|
623
|
+
* @param {{ message?: unknown, paths?: unknown, dryRun?: boolean } & GitOpts} [opts]
|
|
624
|
+
* @returns {Promise<GitRefused | { ok: true, hash: string, message: string, files: string[], fileCount: number }>}
|
|
625
|
+
*/
|
|
626
|
+
export async function gitCommit(root, { message, paths, spawnImpl, dryRun = false } = {}) {
|
|
627
|
+
const repo = await resolveRepo(root, { spawnImpl });
|
|
628
|
+
if (!repo.ok) return repo;
|
|
629
|
+
|
|
630
|
+
const valid = validateCommitMessage(message);
|
|
631
|
+
if (!valid.ok) return valid;
|
|
632
|
+
|
|
633
|
+
if (!Array.isArray(paths) || paths.length === 0) {
|
|
634
|
+
return { ok: false, error: 'paths is required — name the files to commit. Call git_status first; there is no "commit everything".' };
|
|
635
|
+
}
|
|
636
|
+
if (paths.length > MAX_COMMIT_PATHS) {
|
|
637
|
+
return { ok: false, error: `${paths.length} paths is over the ${MAX_COMMIT_PATHS} limit for one commit` };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const clean = [];
|
|
641
|
+
for (const p of paths) {
|
|
642
|
+
const norm = normalizeRelativePath(p);
|
|
643
|
+
if (!norm.ok) return { ok: false, error: `"${p}" is not a usable path: ${norm.reason}` };
|
|
644
|
+
const refused = refusedCommitPath(norm.path);
|
|
645
|
+
if (refused) return { ok: false, error: refused };
|
|
646
|
+
/**
|
|
647
|
+
* ── ⚠️⚠️ THE CONTAINMENT CHECK, AND WHY IT CANNOT BE THE LEXICAL ONE ─────
|
|
648
|
+
*
|
|
649
|
+
* `normalizeRelativePath` above is PURE — it never touches the disk, so it
|
|
650
|
+
* refuses `../x` and `C:\x` and cannot possibly see that `link/` is a
|
|
651
|
+
* junction to somewhere else. For months that was the only guard on the one
|
|
652
|
+
* verb in this package that writes PERMANENT HISTORY, while `write_file`,
|
|
653
|
+
* `read_file` and `delete_file` all went through `resolveInWorkspace`.
|
|
654
|
+
*
|
|
655
|
+
* ⭐ Measured on 2026-08-11, `mklink /J` needing no elevation:
|
|
656
|
+
* repo/link -> ../outside ; gitCommit(paths:['link/secret.txt'])
|
|
657
|
+
* -> ok:true, and `git show HEAD:link/secret.txt` printed the outside
|
|
658
|
+
* file. Deleting it afterwards does not remove it from history.
|
|
659
|
+
*
|
|
660
|
+
* ⚠️ ON POSIX `git add` REFUSES "beyond a symbolic link" ITSELF, which is
|
|
661
|
+
* exactly why this went unnoticed: git covered for the missing guard on the
|
|
662
|
+
* platform nobody here runs on. A Windows directory junction is not a
|
|
663
|
+
* symlink to git — it walks through it as an ordinary directory.
|
|
664
|
+
*
|
|
665
|
+
* So the answer is the SAME mechanism the file tools use, not a second
|
|
666
|
+
* invention: realpath the deepest EXISTING ancestor and compare against the
|
|
667
|
+
* realpath'd root. That is also what keeps it from being too tight —
|
|
668
|
+
* · the root is realpath'd too, so a repo under a junctioned/symlinked home
|
|
669
|
+
* directory is normal rather than an escape;
|
|
670
|
+
* · a path that does not exist is contained by its parent, so staging a
|
|
671
|
+
* DELETION (the file is gone — that IS the commit) still works;
|
|
672
|
+
* · `isInside` folds case, so a drive letter in the other case is not a
|
|
673
|
+
* mismatch;
|
|
674
|
+
* · a link pointing back INSIDE the workspace resolves inside and is allowed.
|
|
675
|
+
*
|
|
676
|
+
* ⚠️ Intent is 'read', deliberately. 'write' would additionally refuse
|
|
677
|
+
* `node_modules/`, `.next/` and `.vercel/` with "it executes code on the
|
|
678
|
+
* owner's next command" — true of writing a file there and NOT true of
|
|
679
|
+
* recording one in history, and a repository that deliberately vendors its
|
|
680
|
+
* dependencies is somebody's real, legitimate commit. This check is about
|
|
681
|
+
* containment and nothing else.
|
|
682
|
+
*
|
|
683
|
+
* ⚠️ And it runs in the validation loop, BEFORE `git add`. Refusing after
|
|
684
|
+
* staging would leave the good paths of a mixed commit sitting in the index
|
|
685
|
+
* for the next commit to sweep up silently.
|
|
686
|
+
*/
|
|
687
|
+
const contained = resolveInWorkspace(repo.root, norm.path, 'read');
|
|
688
|
+
if (!contained.ok) {
|
|
689
|
+
return {
|
|
690
|
+
ok: false,
|
|
691
|
+
error:
|
|
692
|
+
`"${p}" cannot be committed: ${contained.reason}. It resolves outside the workspace `
|
|
693
|
+
+ `(${repo.root}) once links are followed, and committing it would put a file nobody `
|
|
694
|
+
+ 'pointed this agent at into permanent history. Commit only paths that live inside the workspace.',
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
clean.push(norm.path);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
if (dryRun) {
|
|
701
|
+
return { ok: false, error: 'this is a --dry-run, so nothing is staged or committed' };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// `--` again: a path called `main` must not be read as a revision.
|
|
705
|
+
const add = await git(root, ['add', '--', ...clean], { spawnImpl });
|
|
706
|
+
if (!add.ok) return add;
|
|
707
|
+
if (add.exitCode !== 0) {
|
|
708
|
+
const err = add.stderr.trim();
|
|
709
|
+
/**
|
|
710
|
+
* ⚠️ THE IGNORED-FILE REFUSAL IS A FEATURE AND MUST READ LIKE ONE. Git's own
|
|
711
|
+
* message suggests `-f`, which this agent will never pass; left unrewritten
|
|
712
|
+
* the model reads the hint, tries to obey it, and burns a round discovering
|
|
713
|
+
* the flag does not exist here.
|
|
714
|
+
*/
|
|
715
|
+
if (/ignored by one of your \.gitignore|is ignored/i.test(err)) {
|
|
716
|
+
return { ok: false, error: `${err}\n\nThat file is gitignored and this agent never force-adds — leave it out of the commit.` };
|
|
717
|
+
}
|
|
718
|
+
return { ok: false, error: err || 'git add failed' };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const staged = await git(root, ['diff', '--cached', '--name-only'], { spawnImpl });
|
|
722
|
+
const stagedFiles = staged.ok ? staged.stdout.split('\n').map((s) => s.trim()).filter(Boolean) : [];
|
|
723
|
+
if (stagedFiles.length === 0) {
|
|
724
|
+
/**
|
|
725
|
+
* ⚠️ CAUGHT BEFORE COMMITTING, because `git commit` with nothing staged
|
|
726
|
+
* exits non-zero with a wall of advice, and the model reads a failed commit
|
|
727
|
+
* as "something broke" when the truth is "those files are already
|
|
728
|
+
* committed, unchanged" — a completely different next action.
|
|
729
|
+
*/
|
|
730
|
+
return { ok: false, error: 'nothing to commit: those paths have no changes staged (they may already be committed, or identical to HEAD)' };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const commit = await git(root, ['commit', '-m', valid.message], { spawnImpl });
|
|
734
|
+
if (!commit.ok) return commit;
|
|
735
|
+
if (commit.exitCode !== 0) {
|
|
736
|
+
const err = `${commit.stdout}\n${commit.stderr}`.trim();
|
|
737
|
+
if (/Please tell me who you are|unable to auto-detect email|empty ident name/i.test(err)) {
|
|
738
|
+
return {
|
|
739
|
+
ok: false,
|
|
740
|
+
error: 'git has no author identity configured on this machine, so it cannot record a commit. Set user.name and user.email in your git config and ask again.',
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
return { ok: false, error: err || 'git commit failed' };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const head = await git(root, ['rev-parse', '--short', 'HEAD'], { spawnImpl });
|
|
747
|
+
return {
|
|
748
|
+
ok: true,
|
|
749
|
+
hash: head.ok && head.exitCode === 0 ? head.stdout.trim() : '',
|
|
750
|
+
message: valid.message,
|
|
751
|
+
files: stagedFiles,
|
|
752
|
+
// ⭐ Reported because it is the number a human checks first, and because it
|
|
753
|
+
// differs from `paths.length` whenever a named path was already clean.
|
|
754
|
+
fileCount: stagedFiles.length,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
759
|
+
// ⭐⭐ THE DELIVERY HALF — BRANCH, PUSH, PULL REQUEST
|
|
760
|
+
//
|
|
761
|
+
// ── THE MEASUREMENT THAT MOTIVATED IT (2026-08-14) ─────────────────────────
|
|
762
|
+
// node -e "import('./lib/git.mjs').then(m =>
|
|
763
|
+
// console.log(m.gitToolSchemas().map(t => t.function.name).join(', ')))"
|
|
764
|
+
// -> git_status, git_diff, git_log, git_commit
|
|
765
|
+
//
|
|
766
|
+
// The agent could commit and then could not deliver. "Finish the work and open
|
|
767
|
+
// a PR" is the single most common end-of-task instruction a coding agent gets,
|
|
768
|
+
// and the only way to obey it was `--shell`, i.e. handing over the entire
|
|
769
|
+
// machine to get one `git push`. A capability gate that is escaped by granting
|
|
770
|
+
// a strictly larger one is not a gate, it is a nuisance.
|
|
771
|
+
//
|
|
772
|
+
// ── ⚠️ AND THE THREE VERBS ARE NOT THE SAME KIND OF THING ──────────────────
|
|
773
|
+
// · `git_branch` is nearly free: it touches no remote, destroys nothing, and
|
|
774
|
+
// the worst outcome is a branch nobody wanted, deleted in one command. It
|
|
775
|
+
// rides with `allowRun`, like commit, and needs nothing else.
|
|
776
|
+
// · `git_push` LEAVES THE MACHINE. It is visible to colleagues, hard to undo,
|
|
777
|
+
// and it is the only verb in this file that can leak — a repository is
|
|
778
|
+
// exactly the kind of thing you do not want sent somewhere by accident. So it
|
|
779
|
+
// is off unless an operator turns it on BY NAME, refuses protected branches
|
|
780
|
+
// with no override reachable by the model, and can only ever reach a remote
|
|
781
|
+
// the repository already has configured.
|
|
782
|
+
// · The pull request needs a network call and a credential, and both already
|
|
783
|
+
// existed in `github.mjs` — unreached, which is this package's signature
|
|
784
|
+
// defect. It is folded into `git_push` rather than given its own schema: a
|
|
785
|
+
// PR without a push is meaningless, the gate is identical, and one fewer
|
|
786
|
+
// schema is ~250 tokens saved on every round of every run.
|
|
787
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* ⚠️ A BRANCH NAME IS A REF, AND GIT'S RULES FOR ONE ARE NOT OBVIOUS. This is
|
|
791
|
+
* `git check-ref-format --branch` reimplemented as a pure function, because the
|
|
792
|
+
* alternative — hand the string to git and read the error — means a model
|
|
793
|
+
* spends a paid round learning that `feature/../..` is not a name.
|
|
794
|
+
*
|
|
795
|
+
* ⭐ The load-bearing ones are the first two. A leading `-` becomes a FLAG at
|
|
796
|
+
* some call site, and `..`/`@{` are revision syntax: `git switch a..b` is not a
|
|
797
|
+
* branch operation at all. Everything after that is git's own list.
|
|
798
|
+
*/
|
|
799
|
+
export const MAX_BRANCH_NAME_CHARS = 200;
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* @param {unknown} raw
|
|
803
|
+
* @returns {{ ok: true, name: string } | GitRefused}
|
|
804
|
+
*/
|
|
805
|
+
export function validateBranchName(raw) {
|
|
806
|
+
if (typeof raw !== 'string') return { ok: false, error: 'a branch name is required' };
|
|
807
|
+
const name = raw.trim();
|
|
808
|
+
if (!name) return { ok: false, error: 'the branch name is empty — say what the branch is for, e.g. "fix/login-timeout"' };
|
|
809
|
+
if (name.length > MAX_BRANCH_NAME_CHARS) {
|
|
810
|
+
return { ok: false, error: `the branch name is ${name.length} characters, over the ${MAX_BRANCH_NAME_CHARS} limit` };
|
|
811
|
+
}
|
|
812
|
+
if (name.startsWith('-')) return { ok: false, error: 'a branch name may not start with "-" — it would be read as a flag' };
|
|
813
|
+
if (/\s/.test(name)) return { ok: false, error: 'a branch name may not contain whitespace — use "-" or "/" instead' };
|
|
814
|
+
// eslint-disable-next-line no-control-regex
|
|
815
|
+
if (/[\u0000-\u001f\u007f]/.test(name)) return { ok: false, error: 'the branch name contains control characters' };
|
|
816
|
+
if (/[~^:?*[\\]/.test(name)) return { ok: false, error: 'a branch name may not contain any of ~ ^ : ? * [ \\ — git refuses them' };
|
|
817
|
+
if (name.includes('..')) return { ok: false, error: 'a branch name may not contain ".." — that is git\'s range syntax, not a name' };
|
|
818
|
+
if (name.includes('@{')) return { ok: false, error: 'a branch name may not contain "@{" — that is git\'s reflog syntax' };
|
|
819
|
+
if (name === '@') return { ok: false, error: '"@" is not a branch name — it is shorthand for HEAD' };
|
|
820
|
+
if (name === 'HEAD') return { ok: false, error: '"HEAD" is not a branch name — it is the pointer to whichever branch you are on' };
|
|
821
|
+
if (name.startsWith('refs/')) return { ok: false, error: 'give the branch name only, without the "refs/heads/" prefix' };
|
|
822
|
+
if (name.startsWith('/') || name.endsWith('/')) return { ok: false, error: 'a branch name may not begin or end with "/"' };
|
|
823
|
+
for (const segment of name.split('/')) {
|
|
824
|
+
if (segment === '') return { ok: false, error: 'a branch name may not contain an empty path segment ("//")' };
|
|
825
|
+
if (segment.startsWith('.')) return { ok: false, error: 'no part of a branch name may begin with "." — git refuses it' };
|
|
826
|
+
if (segment.endsWith('.')) return { ok: false, error: 'no part of a branch name may end with "." — git refuses it' };
|
|
827
|
+
if (segment.endsWith('.lock')) return { ok: false, error: 'no part of a branch name may end with ".lock" — that is git\'s own lockfile suffix' };
|
|
828
|
+
}
|
|
829
|
+
return { ok: true, name };
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Create a branch and switch to it, or switch to one that already exists.
|
|
834
|
+
*
|
|
835
|
+
* ── ⚠️ WHAT IS DELIBERATELY NOT HERE ────────────────────────────────────────
|
|
836
|
+
* No delete, no rename, no `-B`, no `--force`, no `checkout <path>`. Every one
|
|
837
|
+
* of those can destroy work the agent did not create, and the whole argument
|
|
838
|
+
* for structured verbs (see the header) is that a verb you did not write cannot
|
|
839
|
+
* be reached by a model that guesses well.
|
|
840
|
+
*
|
|
841
|
+
* ⭐ REUSED, NOT CLOBBERED, when the branch exists — `github.mjs:createBranch`
|
|
842
|
+
* makes the same choice and states the reason: re-running after a failed
|
|
843
|
+
* attempt is completely normal, and `-B` would silently throw away whatever the
|
|
844
|
+
* last attempt left there.
|
|
845
|
+
*
|
|
846
|
+
* ⚠️ AND THE EXISTENCE PROBE ASKS FOR `refs/heads/<name>` RATHER THAN `<name>`.
|
|
847
|
+
* `git rev-parse --verify <name>` resolves ANY object — a tag, a remote-tracking
|
|
848
|
+
* branch, or a plain abbreviated SHA. A branch called `beef` would come back
|
|
849
|
+
* "exists" because an object starting `beef` does, and we would `switch` to a
|
|
850
|
+
* detached HEAD instead of creating the branch. (`github.mjs:230` still has the
|
|
851
|
+
* bare form; noted there, not fixed from here.)
|
|
852
|
+
*/
|
|
853
|
+
/**
|
|
854
|
+
* @param {string} root
|
|
855
|
+
* @param {{ name?: unknown, dryRun?: boolean } & GitOpts} [opts]
|
|
856
|
+
* @returns {Promise<GitRefused | { ok: true, branch: string, created: boolean, previous: string, switched: boolean }>}
|
|
857
|
+
*/
|
|
858
|
+
export async function gitBranch(root, { name, spawnImpl, dryRun = false } = {}) {
|
|
859
|
+
// ⚠️ STRICT: a ref is repository-wide. There is no such thing as creating a
|
|
860
|
+
// branch "for this subdirectory only", so the subdirectory allowance that the
|
|
861
|
+
// read verbs get would be a lie here.
|
|
862
|
+
const repo = await resolveRepo(root, { spawnImpl });
|
|
863
|
+
if (!repo.ok) return repo;
|
|
864
|
+
|
|
865
|
+
const valid = validateBranchName(name);
|
|
866
|
+
if (!valid.ok) return valid;
|
|
867
|
+
|
|
868
|
+
const head = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD'], { spawnImpl });
|
|
869
|
+
const previous = head.ok && head.exitCode === 0 ? head.stdout.trim() : '';
|
|
870
|
+
|
|
871
|
+
if (previous === valid.name) {
|
|
872
|
+
// ⚠️ A RESULT, NOT A FAILURE. The model asked to be on that branch and it
|
|
873
|
+
// is on that branch; reporting an error would send it hunting for a problem
|
|
874
|
+
// that does not exist, which is the same mistake `gitDiff` avoids for an
|
|
875
|
+
// empty diff.
|
|
876
|
+
return { ok: true, branch: valid.name, created: false, switched: false, previous };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (dryRun) return { ok: false, error: 'this is a --dry-run, so no branch is created and nothing is switched' };
|
|
880
|
+
|
|
881
|
+
const exists = await git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${valid.name}`], { spawnImpl });
|
|
882
|
+
if (!exists.ok) return exists;
|
|
883
|
+
const existed = exists.exitCode === 0;
|
|
884
|
+
|
|
885
|
+
const sw = await git(root, existed ? ['switch', valid.name] : ['switch', '-c', valid.name], { spawnImpl });
|
|
886
|
+
if (!sw.ok) return sw;
|
|
887
|
+
if (sw.exitCode !== 0) {
|
|
888
|
+
const err = `${sw.stdout}\n${sw.stderr}`.trim();
|
|
889
|
+
/**
|
|
890
|
+
* ⚠️ GIT'S OWN ADVICE HERE NAMES `git stash`, WHICH THIS AGENT DOES NOT
|
|
891
|
+
* HAVE. Left unrewritten the model reads the hint, tries to obey it, and
|
|
892
|
+
* burns a round discovering the verb does not exist — the same failure the
|
|
893
|
+
* gitignore branch of `gitCommit` was written to prevent.
|
|
894
|
+
*/
|
|
895
|
+
if (/would be overwritten|commit your changes or stash/i.test(err)) {
|
|
896
|
+
return {
|
|
897
|
+
ok: false,
|
|
898
|
+
error: `${err}\n\nThis agent has no stash and never discards changes. Commit the files first with git_commit, then branch.`,
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
return { ok: false, error: err || `could not switch to ${valid.name}` };
|
|
902
|
+
}
|
|
903
|
+
return { ok: true, branch: valid.name, created: !existed, switched: true, previous };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* ── ⚠️⚠️ THE PROTECTED BRANCHES, AND WHY THE MODEL CANNOT UNSET THEM ────────
|
|
908
|
+
*
|
|
909
|
+
* There was no branch-protection policy anywhere in this package before today
|
|
910
|
+
* — checked: `grep -rn "protected" lib/*.mjs` returned only compaction and
|
|
911
|
+
* lease matches. So this is a new policy, and a new policy in the one verb that
|
|
912
|
+
* leaves the machine should be conservative and boring.
|
|
913
|
+
*
|
|
914
|
+
* ⭐ THE LIST IS ADDITIVE ONLY. `ACUVO_PROTECTED_BRANCHES` can ADD names; there
|
|
915
|
+
* is no variable, flag or tool argument that removes one. That asymmetry is the
|
|
916
|
+
* point: a safety list a caller can empty is a safety list the model will
|
|
917
|
+
* eventually be told to empty. A human who genuinely means to push `main`
|
|
918
|
+
* types eleven characters in their own terminal.
|
|
919
|
+
*
|
|
920
|
+
* ⭐ AND THE REMOTE'S OWN DEFAULT BRANCH COUNTS, whatever it is called. That is
|
|
921
|
+
* the real protection this repository already has rather than one I invented:
|
|
922
|
+
* `origin/HEAD` is what the hosting provider says the trunk is, so a repo whose
|
|
923
|
+
* trunk is `acuvo` or `closer-local` is covered without anyone configuring
|
|
924
|
+
* anything. ⚠️ It is frequently ABSENT in a clone (git only writes it on
|
|
925
|
+
* `clone`, not on `remote add`), which is exactly why it supplements the static
|
|
926
|
+
* list instead of replacing it.
|
|
927
|
+
*/
|
|
928
|
+
export const PROTECTED_BRANCHES = Object.freeze([
|
|
929
|
+
'main', 'master', 'develop', 'development', 'trunk', 'release', 'prod', 'production',
|
|
930
|
+
]);
|
|
931
|
+
export const PROTECTED_BRANCHES_ENV = 'ACUVO_PROTECTED_BRANCHES';
|
|
932
|
+
|
|
933
|
+
export function protectedBranches(env = process.env) {
|
|
934
|
+
const extra = String(env?.[PROTECTED_BRANCHES_ENV] ?? '')
|
|
935
|
+
.split(',')
|
|
936
|
+
.map((s) => s.trim().toLowerCase())
|
|
937
|
+
.filter(Boolean);
|
|
938
|
+
return [...new Set([...PROTECTED_BRANCHES, ...extra])];
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** @returns {string | null} the refusal, or null if this branch may be pushed. */
|
|
942
|
+
export function protectedBranchRefusal(branch, { env = process.env, defaultBranch = null } = {}) {
|
|
943
|
+
const name = String(branch ?? '').trim();
|
|
944
|
+
if (!name) return 'could not work out which branch is checked out, so this push is refused';
|
|
945
|
+
const lower = name.toLowerCase();
|
|
946
|
+
const list = protectedBranches(env);
|
|
947
|
+
const isDefault = defaultBranch && lower === String(defaultBranch).trim().toLowerCase();
|
|
948
|
+
if (!list.includes(lower) && !isDefault) return null;
|
|
949
|
+
const why = isDefault && !list.includes(lower)
|
|
950
|
+
? `"${name}" is the default branch of the remote`
|
|
951
|
+
: `"${name}" is a protected branch`;
|
|
952
|
+
return [
|
|
953
|
+
`${why}, and this agent never pushes to one.`,
|
|
954
|
+
'Make a branch for the work and push that instead:',
|
|
955
|
+
` git_branch { "name": "fix/what-you-did" } then git_push`,
|
|
956
|
+
`(protected here: ${list.join(', ')}${defaultBranch ? `, and the remote default "${defaultBranch}"` : ''}.`,
|
|
957
|
+
`Add more with ${PROTECTED_BRANCHES_ENV}=a,b — there is no way to remove one.)`,
|
|
958
|
+
].join(' ');
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** The operator's switch. ⚠️ Absent means OFF — a push must be asked for. */
|
|
962
|
+
export const ALLOW_PUSH_ENV = 'ACUVO_ALLOW_PUSH';
|
|
963
|
+
|
|
964
|
+
export function pushEnabled(env = process.env) {
|
|
965
|
+
const raw = String(env?.[ALLOW_PUSH_ENV] ?? '').trim().toLowerCase();
|
|
966
|
+
return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* ⭐ THE OFFER FOLLOWS `mediaToolNames`: a tool whose configuration is absent is
|
|
971
|
+
* never MENTIONED to the model. That is not only safety — it is the cost
|
|
972
|
+
* argument. A schema the model is never shown costs zero tokens, so the default
|
|
973
|
+
* install pays nothing at all for a verb it has not enabled, and the ~170
|
|
974
|
+
* tokens per round are spent only by someone who asked for them.
|
|
975
|
+
*/
|
|
976
|
+
export function gitPushToolNames(env = process.env, { allowRun = true } = {}) {
|
|
977
|
+
return allowRun && pushEnabled(env) ? ['git_push'] : [];
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** A push can be slow on a big repository in a way `status` never is. */
|
|
981
|
+
export const PUSH_TIMEOUT_MS = 120_000;
|
|
982
|
+
|
|
983
|
+
/** What the remote calls its trunk, or null when the clone never recorded it. */
|
|
984
|
+
async function remoteDefaultBranch(root, remote, { spawnImpl } = {}) {
|
|
985
|
+
const r = await git(root, ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`], { spawnImpl });
|
|
986
|
+
if (!r.ok || r.exitCode !== 0) return null;
|
|
987
|
+
const value = r.stdout.trim();
|
|
988
|
+
if (!value) return null;
|
|
989
|
+
return value.startsWith(`${remote}/`) ? value.slice(remote.length + 1) : value;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Push the current branch, and optionally open a pull request for it.
|
|
994
|
+
*
|
|
995
|
+
* ── ⚠️ THE ARGV IS FIXED: `push --set-upstream <remote> <branch>` ───────────
|
|
996
|
+
* No `--force`, no `--force-with-lease`, no `--delete`, no `--mirror`, no
|
|
997
|
+
* `--tags`, no refspec. The model supplies a remote NAME that must already be
|
|
998
|
+
* configured and nothing else — which is what stops the obvious attack, since
|
|
999
|
+
* `git push https://somewhere-else/x HEAD` would post the entire repository to
|
|
1000
|
+
* an address of the model's choosing and looks exactly like a normal push in a
|
|
1001
|
+
* transcript.
|
|
1002
|
+
*
|
|
1003
|
+
* ⭐ `--set-upstream` is unconditional and idempotent: the FIRST push of a new
|
|
1004
|
+
* branch without it leaves the branch with no upstream, and the next `git pull`
|
|
1005
|
+
* a human runs in that clone fails with a paragraph of advice.
|
|
1006
|
+
*/
|
|
1007
|
+
/**
|
|
1008
|
+
* @param {string} root
|
|
1009
|
+
* @returns {Promise<GitRefused | { ok: true, remote: string, branch: string, output: string, pullRequest: object | null, nextSteps: string[] }>}
|
|
1010
|
+
*/
|
|
1011
|
+
export async function gitPush(root, {
|
|
1012
|
+
remote = 'origin',
|
|
1013
|
+
openPullRequest = false,
|
|
1014
|
+
pullRequestTitle = null,
|
|
1015
|
+
pullRequestBody = null,
|
|
1016
|
+
pullRequestBase = null,
|
|
1017
|
+
spawnImpl,
|
|
1018
|
+
dryRun = false,
|
|
1019
|
+
env = process.env,
|
|
1020
|
+
fetchImpl = fetch,
|
|
1021
|
+
detectImpl = null,
|
|
1022
|
+
tokenImpl = null,
|
|
1023
|
+
} = {}) {
|
|
1024
|
+
/**
|
|
1025
|
+
* ⚠️ THE GATE IS CHECKED HERE AS WELL AS AT THE OFFER, and that is not
|
|
1026
|
+
* belt-and-braces for its own sake: `tools.mjs` already learned this the hard
|
|
1027
|
+
* way with `repl` and `start_process` (see
|
|
1028
|
+
* `test/no-run-holds-at-dispatcher.test.mjs`) — a model can emit a call for a
|
|
1029
|
+
* tool it was never shown, from a resumed session or a provider echoing a
|
|
1030
|
+
* stale tool list, and a gate that lives only in the offer does not hold.
|
|
1031
|
+
*/
|
|
1032
|
+
if (!pushEnabled(env)) {
|
|
1033
|
+
return {
|
|
1034
|
+
ok: false,
|
|
1035
|
+
error: `pushing is turned off. This agent only pushes when the operator asks for it by name: set ${ALLOW_PUSH_ENV}=1 in the environment. Until then, commit the work and hand the branch over.`,
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const repo = await resolveRepo(root, { spawnImpl });
|
|
1040
|
+
if (!repo.ok) return repo;
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* ⚠️ VALIDATED BEFORE ANYTHING LEAVES THE MACHINE. Discovering that the PR
|
|
1044
|
+
* has no title AFTER the push has already happened means reporting a
|
|
1045
|
+
* half-done job for a reason that was knowable for free.
|
|
1046
|
+
*/
|
|
1047
|
+
if (openPullRequest) {
|
|
1048
|
+
const t = String(pullRequestTitle ?? '').trim();
|
|
1049
|
+
if (!t) return { ok: false, error: 'openPullRequest needs a pullRequestTitle — say what the pull request is for' };
|
|
1050
|
+
if (t.startsWith('-')) return { ok: false, error: 'a pull request title may not start with "-"' };
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const remoteName = String(remote ?? 'origin').trim() || 'origin';
|
|
1054
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(remoteName)) {
|
|
1055
|
+
return { ok: false, error: `"${remoteName}" is not a remote name. Pass the NAME of a configured remote (usually "origin") — a URL is not accepted here, because pushing to an arbitrary address would send the whole repository somewhere nobody chose.` };
|
|
1056
|
+
}
|
|
1057
|
+
const remotes = await git(root, ['remote'], { spawnImpl });
|
|
1058
|
+
if (!remotes.ok) return remotes;
|
|
1059
|
+
const configured = remotes.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
1060
|
+
if (!configured.includes(remoteName)) {
|
|
1061
|
+
return {
|
|
1062
|
+
ok: false,
|
|
1063
|
+
error: configured.length
|
|
1064
|
+
? `this repository has no remote called "${remoteName}". It has: ${configured.join(', ')}`
|
|
1065
|
+
: 'this repository has no remotes configured, so there is nowhere to push to',
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const head = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD'], { spawnImpl });
|
|
1070
|
+
if (!head.ok) return head;
|
|
1071
|
+
const branch = head.exitCode === 0 ? head.stdout.trim() : '';
|
|
1072
|
+
if (!branch || branch === 'HEAD') {
|
|
1073
|
+
// ⚠️ DETACHED HEAD. `push origin HEAD` from here creates a ref named after
|
|
1074
|
+
// whatever git guesses, on a remote, from a state the user probably did not
|
|
1075
|
+
// intend to be in.
|
|
1076
|
+
return { ok: false, error: 'HEAD is detached (not on a branch), so there is no branch to push. Use git_branch to make one first.' };
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const defaultBranch = await remoteDefaultBranch(root, remoteName, { spawnImpl });
|
|
1080
|
+
const refusal = protectedBranchRefusal(branch, { env, defaultBranch });
|
|
1081
|
+
if (refusal) return { ok: false, error: refusal };
|
|
1082
|
+
|
|
1083
|
+
if (dryRun) return { ok: false, error: 'this is a --dry-run, so nothing is pushed' };
|
|
1084
|
+
|
|
1085
|
+
const run = await git(root, ['push', '--set-upstream', remoteName, branch], {
|
|
1086
|
+
spawnImpl,
|
|
1087
|
+
timeoutMs: PUSH_TIMEOUT_MS,
|
|
1088
|
+
env: pushEnvironment(env),
|
|
1089
|
+
});
|
|
1090
|
+
if (!run.ok) return run;
|
|
1091
|
+
if (run.exitCode !== 0) {
|
|
1092
|
+
const err = `${run.stdout}\n${run.stderr}`.trim();
|
|
1093
|
+
if (/could not read Username|terminal prompts disabled|Authentication failed/i.test(err)) {
|
|
1094
|
+
return {
|
|
1095
|
+
ok: false,
|
|
1096
|
+
error: `${err}\n\nGit has no usable credential for ${remoteName} in this environment. Run the push yourself, or configure a credential helper / ssh agent for this machine.`,
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
if (/non-fast-forward|rejected|fetch first/i.test(err)) {
|
|
1100
|
+
return {
|
|
1101
|
+
ok: false,
|
|
1102
|
+
error: `${err}\n\nThe remote has commits this branch does not. This agent never force-pushes and cannot pull — a human needs to reconcile the two.`,
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
return { ok: false, error: err || 'git push failed' };
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const pushed = {
|
|
1109
|
+
ok: true,
|
|
1110
|
+
remote: remoteName,
|
|
1111
|
+
branch,
|
|
1112
|
+
// ⚠️ Git writes the interesting part of a push to STDERR ("* [new branch]",
|
|
1113
|
+
// the remote's own PR hint). Reporting only stdout would report nothing.
|
|
1114
|
+
output: clampOutput(`${run.stdout}\n${run.stderr}`.trim(), 2_000).text,
|
|
1115
|
+
pullRequest: null,
|
|
1116
|
+
nextSteps: [],
|
|
1117
|
+
};
|
|
1118
|
+
if (!openPullRequest) return pushed;
|
|
1119
|
+
|
|
1120
|
+
pushed.pullRequest = await openPr(root, {
|
|
1121
|
+
branch, base: pullRequestBase || defaultBranch, title: String(pullRequestTitle).trim(),
|
|
1122
|
+
body: pullRequestBody == null ? '' : String(pullRequestBody), env, fetchImpl, detectImpl, tokenImpl,
|
|
1123
|
+
});
|
|
1124
|
+
/**
|
|
1125
|
+
* ⭐ A FAILED PR DOES NOT UNDO A SUCCESSFUL PUSH, so it must not be reported
|
|
1126
|
+
* as a failed push. The commits ARE on the remote; the model needs to know
|
|
1127
|
+
* that and stop, not retry the push. The manual command rides along so the
|
|
1128
|
+
* human can finish it in one paste.
|
|
1129
|
+
*/
|
|
1130
|
+
if (!pushed.pullRequest.ok) {
|
|
1131
|
+
pushed.nextSteps = [`gh pr create --head ${branch}${pullRequestBase ? ` --base ${pullRequestBase}` : ''} --fill`];
|
|
1132
|
+
}
|
|
1133
|
+
return pushed;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* The pull request itself.
|
|
1138
|
+
*
|
|
1139
|
+
* ⭐ EVERY PIECE OF THIS ALREADY EXISTED IN `github.mjs` AND NOTHING CALLED IT
|
|
1140
|
+
* — `detectRepo` (which asks git rather than guessing from the folder name, and
|
|
1141
|
+
* reads `git config` rather than `remote get-url` so a URL rewrite does not
|
|
1142
|
+
* make a GitHub repo look like it is not on GitHub) and `findToken` (which
|
|
1143
|
+
* reuses `gh auth token`, resolved to an ABSOLUTE path after a `gh.exe` in the
|
|
1144
|
+
* working directory was measured beating the real one). Reusing them is the
|
|
1145
|
+
* whole point: the unreached-capability defect is fixed by CALLING the code,
|
|
1146
|
+
* not by writing a second copy of it.
|
|
1147
|
+
*/
|
|
1148
|
+
async function openPr(root, { branch, base, title, body, env, fetchImpl, detectImpl, tokenImpl }) {
|
|
1149
|
+
const { detectRepo, findToken } = await import('./github.mjs');
|
|
1150
|
+
const detect = detectImpl ?? detectRepo;
|
|
1151
|
+
const token = tokenImpl ?? findToken;
|
|
1152
|
+
|
|
1153
|
+
const where = detect(root);
|
|
1154
|
+
if (!where.ok) return { ok: false, error: `cannot open a pull request: ${where.error}` };
|
|
1155
|
+
const cred = token({ env });
|
|
1156
|
+
if (!cred.ok) return { ok: false, error: cred.error };
|
|
1157
|
+
|
|
1158
|
+
const payload = { title, head: branch, base: base || 'main', body: body.slice(0, 60_000) };
|
|
1159
|
+
let res;
|
|
1160
|
+
try {
|
|
1161
|
+
res = await fetchImpl(`https://api.github.com/repos/${where.owner}/${where.repo}/pulls`, {
|
|
1162
|
+
method: 'POST',
|
|
1163
|
+
headers: {
|
|
1164
|
+
accept: 'application/vnd.github+json',
|
|
1165
|
+
authorization: `Bearer ${cred.token}`,
|
|
1166
|
+
'content-type': 'application/json',
|
|
1167
|
+
'user-agent': 'acuvo-code',
|
|
1168
|
+
},
|
|
1169
|
+
body: JSON.stringify(payload),
|
|
1170
|
+
signal: AbortSignal.timeout(20_000),
|
|
1171
|
+
});
|
|
1172
|
+
} catch (err) {
|
|
1173
|
+
return { ok: false, error: `could not reach GitHub: ${err?.cause?.code ?? err?.name ?? err}` };
|
|
1174
|
+
}
|
|
1175
|
+
const json = await res.json().catch(() => null);
|
|
1176
|
+
if (res.status === 201 && json) {
|
|
1177
|
+
return { ok: true, number: json.number, url: json.html_url, base: payload.base };
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* ⚠️ 422 IS THE ONE THAT MATTERS AND IT IS AMBIGUOUS. GitHub returns it for
|
|
1181
|
+
* "a pull request already exists for this branch" AND for "the base branch
|
|
1182
|
+
* does not exist" AND for "no commits between the two". Its own `message`
|
|
1183
|
+
* distinguishes them, so it is passed through rather than replaced by a guess.
|
|
1184
|
+
*/
|
|
1185
|
+
const detail = json?.errors?.map?.((e) => e?.message).filter(Boolean).join('; ') || json?.message || '';
|
|
1186
|
+
return { ok: false, error: `GitHub refused the pull request (HTTP ${res.status})${detail ? `: ${detail}` : ''}` };
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/** Render for the model — compact, and leading with the fact that drives the next move. */
|
|
1190
|
+
export function formatStatusForModel(result) {
|
|
1191
|
+
if (!result.ok) return `git status: ${result.error}`;
|
|
1192
|
+
/**
|
|
1193
|
+
* ⭐ THE SCOPE IS SAID OUT LOUD. "working tree clean" about a workspace that
|
|
1194
|
+
* is one package of a monorepo is a true sentence a model will read as a
|
|
1195
|
+
* bigger claim than it is — and the next thing it does with that belief is
|
|
1196
|
+
* decide the repository has nothing to commit.
|
|
1197
|
+
*/
|
|
1198
|
+
const scope = result.subdirectory ? ` (scoped to ${result.subdirectory}/ inside a larger repository)` : '';
|
|
1199
|
+
if (result.clean) return `branch ${result.branch || '(no commits yet)'}${scope} — working tree clean, nothing to commit`;
|
|
1200
|
+
const lines = [`branch ${result.branch || '(no commits yet)'}${scope} — ${result.files.length} changed:`];
|
|
1201
|
+
for (const f of result.files.slice(0, 60)) {
|
|
1202
|
+
const tag = f.untracked ? 'untracked' : f.staged ? 'staged' : 'modified';
|
|
1203
|
+
lines.push(` ${tag.padEnd(9)} ${f.path}`);
|
|
1204
|
+
}
|
|
1205
|
+
if (result.files.length > 60) lines.push(` … and ${result.files.length - 60} more`);
|
|
1206
|
+
return lines.join('\n');
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
export function gitToolSchemas() {
|
|
1210
|
+
return [
|
|
1211
|
+
{
|
|
1212
|
+
type: 'function',
|
|
1213
|
+
function: {
|
|
1214
|
+
name: 'git_status',
|
|
1215
|
+
description: [
|
|
1216
|
+
'List what has changed in the workspace repository: branch, and every modified, staged or',
|
|
1217
|
+
'untracked file. Call this BEFORE git_commit — commit requires you to name the files, and',
|
|
1218
|
+
'this is how you know what they are.',
|
|
1219
|
+
].join(' '),
|
|
1220
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
1221
|
+
},
|
|
1222
|
+
},
|
|
1223
|
+
{
|
|
1224
|
+
type: 'function',
|
|
1225
|
+
function: {
|
|
1226
|
+
name: 'git_diff',
|
|
1227
|
+
description: [
|
|
1228
|
+
'Show the actual line-by-line changes in the working tree. This is how you CHECK your own',
|
|
1229
|
+
'edit did what you meant before committing it, and how you see changes that existed before',
|
|
1230
|
+
'this session. Optionally limit to one path, or pass staged=true for what is already staged.',
|
|
1231
|
+
].join(' '),
|
|
1232
|
+
parameters: {
|
|
1233
|
+
type: 'object',
|
|
1234
|
+
properties: {
|
|
1235
|
+
path: { type: 'string', description: 'Optional workspace-relative file or directory.' },
|
|
1236
|
+
staged: { type: 'boolean', description: 'true to diff what is staged rather than the working tree.' },
|
|
1237
|
+
},
|
|
1238
|
+
required: [],
|
|
1239
|
+
},
|
|
1240
|
+
},
|
|
1241
|
+
},
|
|
1242
|
+
{
|
|
1243
|
+
type: 'function',
|
|
1244
|
+
function: {
|
|
1245
|
+
name: 'git_log',
|
|
1246
|
+
description: [
|
|
1247
|
+
'Recent commits: hash, author, relative date and subject. Use it to learn a repository\'s',
|
|
1248
|
+
'commit-message conventions before writing one, or to see what recently changed in a file.',
|
|
1249
|
+
].join(' '),
|
|
1250
|
+
parameters: {
|
|
1251
|
+
type: 'object',
|
|
1252
|
+
properties: {
|
|
1253
|
+
count: { type: 'number', description: `How many commits, 1–${MAX_LOG_COUNT} (default 10).` },
|
|
1254
|
+
path: { type: 'string', description: 'Optional path — only commits touching it.' },
|
|
1255
|
+
},
|
|
1256
|
+
required: [],
|
|
1257
|
+
},
|
|
1258
|
+
},
|
|
1259
|
+
},
|
|
1260
|
+
{
|
|
1261
|
+
type: 'function',
|
|
1262
|
+
function: {
|
|
1263
|
+
name: 'git_commit',
|
|
1264
|
+
description: [
|
|
1265
|
+
'Stage the named files and commit them. You MUST list the paths — there is no "commit',
|
|
1266
|
+
'everything", because sweeping up files you have not looked at is how scratch files and',
|
|
1267
|
+
'secrets get committed. Call git_status and git_diff first, then commit deliberately.',
|
|
1268
|
+
'Match the repository\'s existing message style (git_log shows it).',
|
|
1269
|
+
'This agent cannot reset, merge, rebase or discard anything — commit, branch and push are the only write verbs.',
|
|
1270
|
+
].join(' '),
|
|
1271
|
+
parameters: {
|
|
1272
|
+
type: 'object',
|
|
1273
|
+
properties: {
|
|
1274
|
+
message: { type: 'string', description: 'The commit message. Multi-line is fine.' },
|
|
1275
|
+
paths: {
|
|
1276
|
+
type: 'array',
|
|
1277
|
+
items: { type: 'string' },
|
|
1278
|
+
description: 'Workspace-relative paths to stage and commit. Required, and non-empty.',
|
|
1279
|
+
},
|
|
1280
|
+
},
|
|
1281
|
+
required: ['message', 'paths'],
|
|
1282
|
+
},
|
|
1283
|
+
},
|
|
1284
|
+
},
|
|
1285
|
+
/**
|
|
1286
|
+
* ⭐ ~95 TOKENS, AND IT EARNS THEM ON THE FIRST TASK THAT SAYS "don't commit
|
|
1287
|
+
* to main". Without it the agent's only correct move was to refuse the work
|
|
1288
|
+
* or commit to whatever branch it happened to start on.
|
|
1289
|
+
*/
|
|
1290
|
+
{
|
|
1291
|
+
type: 'function',
|
|
1292
|
+
function: {
|
|
1293
|
+
name: 'git_branch',
|
|
1294
|
+
description: [
|
|
1295
|
+
'Create a branch and switch to it — or switch to one that already exists. Do this BEFORE',
|
|
1296
|
+
'committing work that should not land on the branch you started on. It never force-creates,',
|
|
1297
|
+
'never deletes a branch, and never discards commits.',
|
|
1298
|
+
].join(' '),
|
|
1299
|
+
parameters: {
|
|
1300
|
+
type: 'object',
|
|
1301
|
+
properties: {
|
|
1302
|
+
name: { type: 'string', description: 'The branch name, e.g. "fix/login-timeout". No spaces.' },
|
|
1303
|
+
},
|
|
1304
|
+
required: ['name'],
|
|
1305
|
+
},
|
|
1306
|
+
},
|
|
1307
|
+
},
|
|
1308
|
+
/**
|
|
1309
|
+
* ⚠️ THIS SCHEMA IS ~180 TOKENS AND IS SHOWN TO NOBODY BY DEFAULT — see
|
|
1310
|
+
* `gitPushToolNames`. It is declared here anyway so the registry, the
|
|
1311
|
+
* doctor and the policy validator can all SEE it; what varies per machine
|
|
1312
|
+
* is the offer, which is the convention `tools.mjs` states for the media
|
|
1313
|
+
* tools and follows here.
|
|
1314
|
+
*/
|
|
1315
|
+
{
|
|
1316
|
+
type: 'function',
|
|
1317
|
+
function: {
|
|
1318
|
+
name: 'git_push',
|
|
1319
|
+
description: [
|
|
1320
|
+
'Push the current branch to a remote, and optionally open a pull request for it.',
|
|
1321
|
+
'This is the one action that LEAVES THIS MACHINE and that other people will see, so use it',
|
|
1322
|
+
'when the task asked you to deliver the work — not as a reflex after every commit.',
|
|
1323
|
+
'It refuses to push a protected branch (main, master, develop, trunk, release, prod,',
|
|
1324
|
+
'production, and whatever the remote calls its default): branch with git_branch first.',
|
|
1325
|
+
'It never force-pushes and never deletes a remote branch.',
|
|
1326
|
+
].join(' '),
|
|
1327
|
+
parameters: {
|
|
1328
|
+
type: 'object',
|
|
1329
|
+
properties: {
|
|
1330
|
+
remote: { type: 'string', description: 'Name of a configured remote — default "origin". A URL is refused.' },
|
|
1331
|
+
openPullRequest: { type: 'boolean', description: 'true to open a GitHub pull request after the push succeeds.' },
|
|
1332
|
+
pullRequestTitle: { type: 'string', description: 'Required when openPullRequest is true.' },
|
|
1333
|
+
pullRequestBody: { type: 'string', description: 'The PR description. Say what changed and how you verified it.' },
|
|
1334
|
+
pullRequestBase: { type: 'string', description: 'Branch to merge into. Defaults to the remote\'s default branch.' },
|
|
1335
|
+
},
|
|
1336
|
+
required: [],
|
|
1337
|
+
},
|
|
1338
|
+
},
|
|
1339
|
+
},
|
|
1340
|
+
];
|
|
1341
|
+
}
|