@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 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.59",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",