@proagentstore/cli 0.4.58 → 0.4.59
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.
|
@@ -40,7 +40,7 @@ function requireWorkTree(workDir) {
|
|
|
40
40
|
* password prompt nobody can see: `GIT_TERMINAL_PROMPT=0` for https, `BatchMode=yes` for ssh.
|
|
41
41
|
* The user's own `GIT_SSH_COMMAND` wins when set — it may carry a key or a proxy we must keep.
|
|
42
42
|
*/
|
|
43
|
-
function networkGitEnv() {
|
|
43
|
+
export function networkGitEnv() {
|
|
44
44
|
return {
|
|
45
45
|
...process.env,
|
|
46
46
|
GIT_TERMINAL_PROMPT: "0",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
-
import { InspectError } from "./inspect.js";
|
|
4
|
+
import { InspectError, networkGitEnv } from "./inspect.js";
|
|
5
5
|
/**
|
|
6
6
|
* May this string become a git token?
|
|
7
7
|
*
|
|
@@ -32,11 +32,18 @@ export function gitWriteArgv(cmd, opts = {}) {
|
|
|
32
32
|
// discard work.
|
|
33
33
|
return ["checkout", opts.branch.trim(), "--"];
|
|
34
34
|
}
|
|
35
|
+
case "fast-forward":
|
|
36
|
+
// No caller-supplied token at all: the branch is whatever HEAD is (checked against the
|
|
37
|
+
// declared one BEFORE this runs), the remote is the upstream git records for it. `--ff-only`
|
|
38
|
+
// is the whole contract — git aborts, touching nothing, when the histories have diverged.
|
|
39
|
+
// `--no-rebase` so a `pull.rebase=true` config on the owner's machine cannot turn the
|
|
40
|
+
// abort into a rebase of their local commits.
|
|
41
|
+
return ["pull", "--ff-only", "--no-rebase"];
|
|
35
42
|
default:
|
|
36
43
|
throw new InspectError(`unsupported git write command: ${cmd}`);
|
|
37
44
|
}
|
|
38
45
|
}
|
|
39
|
-
function git(workDir, argv) {
|
|
46
|
+
function git(workDir, argv, opts = {}) {
|
|
40
47
|
// stderr is PIPED rather than inherited: git narrates a checkout on stderr, and the runner's
|
|
41
48
|
// console is a user-facing log, not a place for `Switched to branch 'main'`. Piping it is also
|
|
42
49
|
// what makes `e.stderr` available, which is the only honest sentence to put on the card when git
|
|
@@ -44,11 +51,22 @@ function git(workDir, argv) {
|
|
|
44
51
|
return execFileSync("git", argv, {
|
|
45
52
|
cwd: workDir,
|
|
46
53
|
encoding: "utf-8",
|
|
47
|
-
timeout: 15_000,
|
|
54
|
+
timeout: opts.timeout ?? 15_000,
|
|
48
55
|
maxBuffer: 1024 * 1024,
|
|
49
56
|
stdio: ["ignore", "pipe", "pipe"],
|
|
57
|
+
env: opts.env,
|
|
50
58
|
}).toString();
|
|
51
59
|
}
|
|
60
|
+
/** git's diagnosis is the FIRST `fatal:`/`error:` line of stderr; the tail is boilerplate. */
|
|
61
|
+
function gitFailureLine(e) {
|
|
62
|
+
const err = e;
|
|
63
|
+
const lines = String(err.stderr || err.message || "")
|
|
64
|
+
.split("\n")
|
|
65
|
+
.map((l) => l.trim())
|
|
66
|
+
.filter(Boolean);
|
|
67
|
+
const line = lines.find((l) => /^(fatal|error):/i.test(l)) ?? lines[0] ?? "git failed";
|
|
68
|
+
return line.replace(/^(fatal|error):\s*/i, "").slice(0, 200);
|
|
69
|
+
}
|
|
52
70
|
/** The current branch, or the short SHA when detached, or null when even that fails. */
|
|
53
71
|
function currentBranch(workDir) {
|
|
54
72
|
try {
|
|
@@ -134,3 +152,109 @@ export function switchRepoBranch(workDir, branch) {
|
|
|
134
152
|
}
|
|
135
153
|
return { ok: after === to, changed: after === to, from, to, branch: after, dirty: dirtyAfter };
|
|
136
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Bring a CLEAN checkout up to its upstream, or refuse and change nothing (#802).
|
|
157
|
+
*
|
|
158
|
+
* Every precondition is checked HERE, at the hands, not only in the cloud — the same rule
|
|
159
|
+
* `switchRepoBranch` follows and for the same reason: the cloud's picture of the tree is a read
|
|
160
|
+
* taken moments earlier over a relay, and the write has to be safe against the tree as it IS.
|
|
161
|
+
*
|
|
162
|
+
* dirty refused. A fast-forward that touches a file with uncommitted edits is aborted by
|
|
163
|
+
* git anyway, but one that does not touch it silently succeeds and leaves the diff
|
|
164
|
+
* sitting on a base it was not written against. Refusing is the only answer that
|
|
165
|
+
* cannot change what somebody's uncommitted work means.
|
|
166
|
+
* off-branch refused when `branch` is given and HEAD is elsewhere. The declared branch is the
|
|
167
|
+
* one the cloud judged stale; pulling whatever the checkout happens to be on would
|
|
168
|
+
* be acting on a verdict about a different ref.
|
|
169
|
+
* detached refused — there is no branch for a pull to advance.
|
|
170
|
+
* no-upstream refused — `git pull` with no tracking information is a prompt, and a guess about
|
|
171
|
+
* which remote was meant is exactly the decision this module must never make.
|
|
172
|
+
* diverged NOT a precondition: `--ff-only` is the check, and git makes it atomically against
|
|
173
|
+
* the real history. It surfaces as `error`, with the tree untouched.
|
|
174
|
+
*
|
|
175
|
+
* Never creates a branch, never sets an upstream, never stashes.
|
|
176
|
+
*/
|
|
177
|
+
export function fastForwardRepo(workDir, opts = {}) {
|
|
178
|
+
const want = typeof opts.branch === "string" && opts.branch.trim() ? opts.branch.trim() : null;
|
|
179
|
+
if (want !== null && !isSwitchableBranchName(want))
|
|
180
|
+
throw new InspectError(`unusable branch name: ${String(opts.branch)}`);
|
|
181
|
+
const base = { ok: false, changed: false, branch: null, upstream: null, from: null, to: null, commits: null, dirty: false };
|
|
182
|
+
if (!existsSync(resolve(workDir, ".git")))
|
|
183
|
+
return { ...base, refused: "not-a-repo" };
|
|
184
|
+
let abbrev;
|
|
185
|
+
try {
|
|
186
|
+
abbrev = git(workDir, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return { ...base, refused: "unknown-head" };
|
|
190
|
+
}
|
|
191
|
+
if (!abbrev)
|
|
192
|
+
return { ...base, refused: "unknown-head" };
|
|
193
|
+
if (abbrev === "HEAD")
|
|
194
|
+
return { ...base, refused: "detached" };
|
|
195
|
+
const branch = abbrev;
|
|
196
|
+
let dirty;
|
|
197
|
+
try {
|
|
198
|
+
dirty = isDirty(workDir);
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
return { ...base, branch, error: gitFailureLine(e) };
|
|
202
|
+
}
|
|
203
|
+
if (dirty)
|
|
204
|
+
return { ...base, branch, dirty: true, refused: "dirty" };
|
|
205
|
+
if (want !== null && branch !== want)
|
|
206
|
+
return { ...base, branch, refused: "off-branch" };
|
|
207
|
+
let upstream;
|
|
208
|
+
try {
|
|
209
|
+
upstream = git(workDir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).trim();
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return { ...base, branch, refused: "no-upstream" };
|
|
213
|
+
}
|
|
214
|
+
if (!upstream)
|
|
215
|
+
return { ...base, branch, refused: "no-upstream" };
|
|
216
|
+
let from;
|
|
217
|
+
try {
|
|
218
|
+
from = git(workDir, ["rev-parse", "HEAD"]).trim() || null;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
from = null;
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
// The one network call in this file: the no-prompt environment `inspect.ts` uses for
|
|
225
|
+
// fetches, and a timeout above the runner's fetch cap, because a pull IS a fetch first.
|
|
226
|
+
git(workDir, gitWriteArgv("fast-forward"), { timeout: 30_000, env: networkGitEnv() });
|
|
227
|
+
}
|
|
228
|
+
catch (e) {
|
|
229
|
+
// `--ff-only` refused, the network failed, or auth was needed and (correctly) not prompted
|
|
230
|
+
// for. In every case git left the tree as it was; the sentence says which.
|
|
231
|
+
return { ...base, branch, upstream, from, to: from, error: gitFailureLine(e) };
|
|
232
|
+
}
|
|
233
|
+
// CONFIRM, do not assume — read HEAD back and count what arrived from git, not from the
|
|
234
|
+
// exit code.
|
|
235
|
+
let to;
|
|
236
|
+
try {
|
|
237
|
+
to = git(workDir, ["rev-parse", "HEAD"]).trim() || null;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
to = null;
|
|
241
|
+
}
|
|
242
|
+
let commits = null;
|
|
243
|
+
if (from && to) {
|
|
244
|
+
try {
|
|
245
|
+
const n = Number.parseInt(git(workDir, ["rev-list", "--count", `${from}..${to}`]).trim(), 10);
|
|
246
|
+
commits = Number.isFinite(n) ? n : null;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
commits = null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
let dirtyAfter;
|
|
253
|
+
try {
|
|
254
|
+
dirtyAfter = isDirty(workDir);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
dirtyAfter = null;
|
|
258
|
+
}
|
|
259
|
+
return { ok: to !== null, changed: Boolean(from && to && from !== to), branch, upstream, from, to, commits, dirty: dirtyAfter };
|
|
260
|
+
}
|
|
@@ -3,7 +3,7 @@ import { join, resolve } from "node:path";
|
|
|
3
3
|
import { RunnerInputError } from "../errors.js";
|
|
4
4
|
import { defaultStatePath, HeadlessSession } from "./headless.js";
|
|
5
5
|
import { InspectError, readGitRemoteOrigin, readRepoFile, repoSearch, repoSync, repoTree, runRepoGit } from "./inspect.js";
|
|
6
|
-
import { switchRepoBranch } from "./repo-write.js";
|
|
6
|
+
import { fastForwardRepo, switchRepoBranch } from "./repo-write.js";
|
|
7
7
|
import { checkWorkdir, ensureRepo, sanitizeSessionName } from "./repo.js";
|
|
8
8
|
import { asTurnAuthor } from "./turn-author.js";
|
|
9
9
|
/** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
|
|
@@ -65,14 +65,22 @@ export class CodingRuntime {
|
|
|
65
65
|
return repoSync(this.resolveWorkDir(input), { branch: input.branch, forceFetch: input.forceFetch });
|
|
66
66
|
}
|
|
67
67
|
/**
|
|
68
|
-
* The
|
|
69
|
-
*
|
|
70
|
-
* `checkout .`/`reset`/`clean`/`stash` family
|
|
68
|
+
* The writes the platform may make in a checkout by itself — put it back on a branch it
|
|
69
|
+
* declared (#322), or fast-forward it to its upstream (#802) — or refuse. See `repo-write.ts`:
|
|
70
|
+
* fixed argv, clean tree required, nothing in the `checkout .`/`reset`/`clean`/`stash` family
|
|
71
|
+
* exists to be reached.
|
|
71
72
|
*/
|
|
72
73
|
gitWrite(input) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
const workDir = this.resolveWorkDir(input);
|
|
75
|
+
if (input.cmd === "switch-branch") {
|
|
76
|
+
if (typeof input.branch !== "string")
|
|
77
|
+
throw new InspectError("switch-branch needs a branch");
|
|
78
|
+
return switchRepoBranch(workDir, input.branch);
|
|
79
|
+
}
|
|
80
|
+
// The second verb (#802): bring a clean checkout that is ON `branch` up to its upstream.
|
|
81
|
+
if (input.cmd === "fast-forward")
|
|
82
|
+
return fastForwardRepo(workDir, { branch: input.branch });
|
|
83
|
+
throw new InspectError(`unsupported git write command: ${String(input.cmd)}`);
|
|
76
84
|
}
|
|
77
85
|
/**
|
|
78
86
|
* Find something in the repo — by file CONTENT or by file NAME (#508).
|
|
@@ -343,9 +343,11 @@ async function route(runner, req, res) {
|
|
|
343
343
|
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
|
-
// The ONE write surface (#322). A standing policy may put a checkout back on the branch
|
|
347
|
-
//
|
|
348
|
-
//
|
|
346
|
+
// The ONE write surface (#322, #802). A standing policy may put a checkout back on the branch
|
|
347
|
+
// it declared, and a run may fast-forward a clean checkout to its upstream before it starts; it
|
|
348
|
+
// may not commit, discard, or push. An older runner 404s the endpoint (and a 0.4.58 runner
|
|
349
|
+
// 400s the `fast-forward` verb), which the cloud reports as "asked, not confirmed" rather than
|
|
350
|
+
// as done.
|
|
349
351
|
if (req.method === "POST" && path === "/coding/git-write") {
|
|
350
352
|
const b = await readJson(req);
|
|
351
353
|
try {
|