@proagentstore/cli 0.4.58 → 0.4.60

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,114 @@ 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 NOT refused (#804 — it was at 0.4.59). Unlike a checkout, a fast-forward carries
163
+ * nothing across: an uncommitted edit to a file the incoming commits do not touch is
164
+ * the same edit afterwards, and one they DO touch makes git abort before writing a
165
+ * byte ("Your local changes … would be overwritten"). Untracked files likewise —
166
+ * left alone, unless an incoming commit adds that path, which git refuses by name.
167
+ * The check is therefore git's own, against the real history, and arrives as
168
+ * `error`. Nothing here stashes, cleans or commits on the owner's behalf.
169
+ * off-branch refused when `branch` is given and HEAD is elsewhere. The declared branch is the
170
+ * one the cloud judged stale; pulling whatever the checkout happens to be on would
171
+ * be acting on a verdict about a different ref.
172
+ * detached refused — there is no branch for a pull to advance.
173
+ * no-upstream refused — `git pull` with no tracking information is a prompt, and a guess about
174
+ * which remote was meant is exactly the decision this module must never make.
175
+ * diverged NOT a precondition: `--ff-only` is the check, and git makes it atomically against
176
+ * the real history. It surfaces as `error`, with the tree untouched.
177
+ *
178
+ * Never creates a branch, never sets an upstream, never stashes.
179
+ */
180
+ export function fastForwardRepo(workDir, opts = {}) {
181
+ const want = typeof opts.branch === "string" && opts.branch.trim() ? opts.branch.trim() : null;
182
+ if (want !== null && !isSwitchableBranchName(want))
183
+ throw new InspectError(`unusable branch name: ${String(opts.branch)}`);
184
+ const base = { ok: false, changed: false, branch: null, upstream: null, from: null, to: null, commits: null, dirty: false };
185
+ if (!existsSync(resolve(workDir, ".git")))
186
+ return { ...base, refused: "not-a-repo" };
187
+ let abbrev;
188
+ try {
189
+ abbrev = git(workDir, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
190
+ }
191
+ catch {
192
+ return { ...base, refused: "unknown-head" };
193
+ }
194
+ if (!abbrev)
195
+ return { ...base, refused: "unknown-head" };
196
+ if (abbrev === "HEAD")
197
+ return { ...base, refused: "detached" };
198
+ const branch = abbrev;
199
+ // Read, reported, never a refusal (see the doc above). A status git will not give is `null`,
200
+ // not `false` — "clean" is a claim, and this is the field the owner's sentence is built from.
201
+ let dirty;
202
+ try {
203
+ dirty = isDirty(workDir);
204
+ }
205
+ catch {
206
+ dirty = null;
207
+ }
208
+ if (want !== null && branch !== want)
209
+ return { ...base, branch, dirty, refused: "off-branch" };
210
+ let upstream;
211
+ try {
212
+ upstream = git(workDir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]).trim();
213
+ }
214
+ catch {
215
+ return { ...base, branch, dirty, refused: "no-upstream" };
216
+ }
217
+ if (!upstream)
218
+ return { ...base, branch, dirty, refused: "no-upstream" };
219
+ let from;
220
+ try {
221
+ from = git(workDir, ["rev-parse", "HEAD"]).trim() || null;
222
+ }
223
+ catch {
224
+ from = null;
225
+ }
226
+ try {
227
+ // The one network call in this file: the no-prompt environment `inspect.ts` uses for
228
+ // fetches, and a timeout above the runner's fetch cap, because a pull IS a fetch first.
229
+ git(workDir, gitWriteArgv("fast-forward"), { timeout: 30_000, env: networkGitEnv() });
230
+ }
231
+ catch (e) {
232
+ // `--ff-only` refused, an incoming commit would overwrite a local change or an untracked
233
+ // path, the network failed, or auth was needed and (correctly) not prompted for. In every
234
+ // case git left the tree as it was; the sentence says which, and names the file when there
235
+ // is one.
236
+ return { ...base, branch, upstream, from, to: from, dirty, error: gitFailureLine(e) };
237
+ }
238
+ // CONFIRM, do not assume — read HEAD back and count what arrived from git, not from the
239
+ // exit code.
240
+ let to;
241
+ try {
242
+ to = git(workDir, ["rev-parse", "HEAD"]).trim() || null;
243
+ }
244
+ catch {
245
+ to = null;
246
+ }
247
+ let commits = null;
248
+ if (from && to) {
249
+ try {
250
+ const n = Number.parseInt(git(workDir, ["rev-list", "--count", `${from}..${to}`]).trim(), 10);
251
+ commits = Number.isFinite(n) ? n : null;
252
+ }
253
+ catch {
254
+ commits = null;
255
+ }
256
+ }
257
+ let dirtyAfter;
258
+ try {
259
+ dirtyAfter = isDirty(workDir);
260
+ }
261
+ catch {
262
+ dirtyAfter = null;
263
+ }
264
+ return { ok: to !== null, changed: Boolean(from && to && from !== to), branch, upstream, from, to, commits, dirty: dirtyAfter };
265
+ }
@@ -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 ONE write the platform may make in a checkout by itself (#322) — put it back on a branch
69
- * it declared, or refuse. See `repo-write.ts`: fixed argv, clean tree required, nothing in the
70
- * `checkout .`/`reset`/`clean`/`stash` family exists to be reached.
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
- if (input.cmd !== "switch-branch")
74
- throw new InspectError(`unsupported git write command: ${String(input.cmd)}`);
75
- return switchRepoBranch(this.resolveWorkDir(input), input.branch);
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 it
347
- // declared; it may not commit, discard, or touch a remote. An older runner 404s this, which the
348
- // cloud reports as "asked, not confirmed" rather than as done.
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.58",
3
+ "version": "0.4.60",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",