@saadnvd1/agent-os 0.3.465 → 0.3.467

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.
Files changed (45) hide show
  1. package/README.md +14 -4
  2. package/app/api/git/commit/route.ts +4 -3
  3. package/app/api/git/file-content/route.ts +4 -15
  4. package/app/api/git/history/[hash]/diff/route.ts +1 -1
  5. package/app/api/git/history/[hash]/route.ts +1 -1
  6. package/app/api/git/history/route.ts +1 -1
  7. package/app/api/git/push/route.ts +2 -2
  8. package/app/api/sessions/[id]/pr/route.ts +2 -2
  9. package/lib/away/deploy.ts +3 -7
  10. package/lib/away/run.ts +6 -5
  11. package/lib/build.ts +2 -6
  12. package/lib/chat/files.ts +31 -9
  13. package/lib/db/migrations.ts +31 -0
  14. package/lib/done/remote.ts +5 -6
  15. package/lib/done/testing.ts +2 -7
  16. package/lib/done/worktree.ts +2 -2
  17. package/lib/git-exec.ts +334 -0
  18. package/lib/git-history.ts +29 -41
  19. package/lib/git-status.ts +45 -124
  20. package/lib/git.ts +44 -159
  21. package/lib/lumifyhub/publish.ts +2 -2
  22. package/lib/orchestrator/armed.ts +2 -2
  23. package/lib/orchestrator/brief.ts +1 -1
  24. package/lib/orchestrator/diff.ts +15 -13
  25. package/lib/orchestrator/escalate.ts +7 -7
  26. package/lib/orchestrator/gates.ts +36 -16
  27. package/lib/orchestrator/rebase.ts +20 -24
  28. package/lib/orchestrator/repo-slug.ts +4 -7
  29. package/lib/orchestrator/repo.ts +24 -35
  30. package/lib/orchestrator/signoff.ts +1 -1
  31. package/lib/pr-generation.ts +2 -1
  32. package/lib/pr.ts +16 -57
  33. package/lib/sessions/worktree-setup.ts +17 -13
  34. package/lib/stacks/start.ts +7 -11
  35. package/lib/tasks/branch-worktree.ts +3 -2
  36. package/lib/tasks/finish.ts +5 -6
  37. package/lib/tasks/gh.ts +5 -0
  38. package/lib/tasks/move-testing.ts +2 -2
  39. package/lib/tasks/move.ts +3 -2
  40. package/lib/tasks/project-ref.ts +12 -19
  41. package/lib/tasks/session.ts +5 -5
  42. package/lib/worktree-hooks.ts +3 -7
  43. package/lib/worktree-placed.ts +3 -10
  44. package/lib/worktrees.ts +4 -42
  45. package/package.json +1 -1
@@ -2,10 +2,12 @@
2
2
  * The merge gates, ported from dispatch orchestrate. Each one passes, waits
3
3
  * (not ready yet: CI running or settling, no review of this commit yet),
4
4
  * fails, or goes straight to Saad (no CI at all). Only a failure counts
5
- * against the task, and the second failure of the same gate goes to Saad
6
- * instead of being retried. The count is per task and gate: a review that
7
- * blocks counts when its verdict arrives, whether or not sign_off ever
8
- * reads it, and reading the same commit's verdict again isn't another.
5
+ * against the task, and a gate failing again on a different commit goes to
6
+ * Saad instead of being retried. A failure counts once per task, gate and
7
+ * head commit, whichever path judged it (the armed watcher, CI finishing,
8
+ * sign_off, done, land, a review's verdict arriving): judging the same head
9
+ * again is never another failure. A pass doesn't reset the count, so a gate
10
+ * that fails on A, passes on B and fails on C has failed twice.
9
11
  */
10
12
 
11
13
  import { db } from "../db";
@@ -170,12 +172,10 @@ export interface FailureRow {
170
172
  last_sha: string | null;
171
173
  }
172
174
 
173
- // The gates whose failure is a verdict stored against one commit: the same
174
- // verdict read again is the same failure.
175
- export const PER_COMMIT_GATES: readonly string[] = ["review", "scope"];
176
-
177
- // Counts one failure; returns the count now. With `sha`, a failure of the
178
- // commit last counted for this gate isn't counted again.
175
+ // Counts one failure; returns the count now. With `sha`, a failure is
176
+ // counted once per commit: the (task, gate, sha) key in
177
+ // orchestrator_gate_failure_commits decides, so neither a restart nor two
178
+ // evaluations at once count the same head twice.
179
179
  export function recordFailure(
180
180
  workspaceId: string,
181
181
  sessionId: string,
@@ -183,14 +183,34 @@ export function recordFailure(
183
183
  reason: string,
184
184
  sha: string | null = null
185
185
  ): number {
186
+ return db
187
+ .transaction(countOnce)
188
+ .immediate(workspaceId, sessionId, gate, reason, sha);
189
+ }
190
+
191
+ function countOnce(
192
+ workspaceId: string,
193
+ sessionId: string,
194
+ gate: string,
195
+ reason: string,
196
+ sha: string | null
197
+ ): number {
198
+ const fresh =
199
+ sha === null ||
200
+ db
201
+ .prepare(
202
+ `INSERT OR IGNORE INTO orchestrator_gate_failure_commits (session_id, gate, sha, workspace_id, reason)
203
+ VALUES (?, ?, ?, ?, ?)`
204
+ )
205
+ .run(sessionId, gate, sha, workspaceId, reason).changes > 0;
186
206
  db.prepare(
187
207
  `INSERT INTO orchestrator_gate_failures (session_id, gate, workspace_id, count, last_reason, last_sha)
188
- VALUES (?, ?, ?, 1, ?, ?)
189
- ON CONFLICT(session_id, gate) DO UPDATE SET
190
- count = count + CASE WHEN excluded.last_sha IS NOT NULL AND last_sha IS excluded.last_sha THEN 0 ELSE 1 END,
191
- last_reason = excluded.last_reason,
192
- last_sha = excluded.last_sha`
193
- ).run(sessionId, gate, workspaceId, reason, sha);
208
+ VALUES (?, ?, ?, 1, ?, ?)
209
+ ON CONFLICT(session_id, gate) DO UPDATE SET
210
+ count = count + ?,
211
+ last_reason = excluded.last_reason,
212
+ last_sha = excluded.last_sha`
213
+ ).run(sessionId, gate, workspaceId, reason, sha, fresh ? 1 : 0);
194
214
  return failureOf(sessionId, gate)!.count;
195
215
  }
196
216
 
@@ -19,7 +19,6 @@
19
19
  * hears one event either way.
20
20
  */
21
21
 
22
- import { execFile } from "child_process";
23
22
  import fs from "fs";
24
23
  import os from "os";
25
24
  import path from "path";
@@ -28,6 +27,7 @@ import { sendMessage } from "../bus";
28
27
  import { hostLink } from "../hosts/remote-api";
29
28
  import type { TaskPR } from "../tasks/state";
30
29
  import { run } from "../tasks/gh";
30
+ import { git as runGit, gitFetch, revParse } from "../git-exec";
31
31
  import { withRestackedLine } from "../tasks/code-review";
32
32
  import { outputOf, type Runner } from "../stacks/git";
33
33
  import { restackedHeads, stackNotice } from "../stacks/restack";
@@ -145,7 +145,7 @@ export async function mergesCleanly(
145
145
  head: string
146
146
  ): Promise<boolean> {
147
147
  try {
148
- await run("git", ["merge-tree", "--write-tree", base, head], repo);
148
+ await runGit(repo, ["merge-tree", "--write-tree", base, head]);
149
149
  return true;
150
150
  } catch (error) {
151
151
  if ((error as { code?: unknown }).code === 1) return false;
@@ -163,14 +163,11 @@ async function rebaseOne(
163
163
  const repo = repoOf(task);
164
164
  const branch = task.base_branch || "main";
165
165
  // A failed fetch throws: nothing is judged against a stale base.
166
- await run(
167
- "git",
168
- ["fetch", "--quiet", "--", "origin", branch, task.branch_name!],
169
- repo,
170
- 60000
171
- );
166
+ await gitFetch(repo, ["--quiet", "--", "origin", branch, task.branch_name!], {
167
+ timeout: 60000,
168
+ });
172
169
  const onto = `origin/${branch}`;
173
- const baseTip = (await run("git", ["rev-parse", onto], repo)).trim();
170
+ const baseTip = await revParse(repo, onto);
174
171
  const attempt = `${pr.head}:${baseTip}`;
175
172
  if (tried.get(task.id) === attempt) return;
176
173
 
@@ -344,22 +341,21 @@ async function rebaseAt(
344
341
 
345
342
  // The PR's own patch against the base, as `git patch-id` sees it.
346
343
  async function patchId(repo: string, range: string): Promise<string | null> {
347
- const diff = await run(
348
- "git",
349
- ["diff", "--no-color", "--no-ext-diff", "--full-index", range],
350
- repo
351
- ).catch(() => null);
344
+ const diff = await runGit(repo, [
345
+ "diff",
346
+ "--no-color",
347
+ "--no-ext-diff",
348
+ "--full-index",
349
+ range,
350
+ ]).catch(() => null);
352
351
  if (!diff) return null;
353
- return new Promise((resolve) => {
354
- const child = execFile(
355
- "git",
356
- ["patch-id", "--verbatim"],
357
- { cwd: repo, timeout: 30000 },
358
- (error, out) => resolve(error ? null : out.split(" ")[0] || null)
359
- );
360
- child.stdin?.on("error", () => {});
361
- child.stdin?.end(diff);
362
- });
352
+ return runGit(repo, ["patch-id", "--verbatim"], {
353
+ input: diff,
354
+ timeout: 30000,
355
+ }).then(
356
+ (out) => out.split(" ")[0] || null,
357
+ () => null
358
+ );
363
359
  }
364
360
 
365
361
  // The review of the head before the rebase, carried to the new head when
@@ -4,7 +4,7 @@
4
4
  // of its projects'.
5
5
 
6
6
  import type { Project } from "../db";
7
- import { run } from "../tasks/gh";
7
+ import { git } from "../git-exec";
8
8
  import { expandHome } from "../tasks/session";
9
9
  import { workspaceProjects } from "./brief";
10
10
 
@@ -24,12 +24,9 @@ const slugs = new Map<string, { at: number; slug: string | null }>();
24
24
  export async function repoSlug(dir: string): Promise<string | null> {
25
25
  const hit = slugs.get(dir);
26
26
  if (hit && Date.now() - hit.at < SLUG_TTL_MS) return hit.slug;
27
- const url = await run(
28
- "git",
29
- ["config", "--get", "remote.origin.url"],
30
- dir,
31
- 10000
32
- ).catch(() => "");
27
+ const url = await git(dir, ["config", "--get", "remote.origin.url"], {
28
+ timeout: 10000,
29
+ }).catch(() => "");
33
30
  const slug = slugOfRemote(url);
34
31
  slugs.set(dir, { at: Date.now(), slug });
35
32
  return slug;
@@ -8,7 +8,7 @@ import path from "path";
8
8
  import type { Session } from "../db";
9
9
  import { SKILL_DIRS } from "../agents/skill-dirs";
10
10
  import { getProject } from "../projects";
11
- import { run } from "../tasks/gh";
11
+ import { git, gitFetch, gitOk, worktreeAdd, worktreeRemove } from "../git-exec";
12
12
  import { expandHome } from "../tasks/session";
13
13
 
14
14
  export function repoOf(task: Session): string {
@@ -23,26 +23,21 @@ export async function fetchRefs(
23
23
  task: Pick<Session, "base_branch" | "branch_name">
24
24
  ): Promise<string> {
25
25
  const base = task.base_branch || "main";
26
- await run(
27
- "git",
28
- ["fetch", "--quiet", "--", "origin", base, task.branch_name ?? base],
29
- repo
30
- ).catch(() => {});
26
+ await gitFetch(repo, [
27
+ "--quiet",
28
+ "--",
29
+ "origin",
30
+ base,
31
+ task.branch_name ?? base,
32
+ ]).catch(() => {});
31
33
  const remote = `origin/${base}`;
32
- const has = await run(
33
- "git",
34
- ["rev-parse", "--verify", "--quiet", remote],
35
- repo
36
- ).then(
37
- () => true,
38
- () => false
39
- );
34
+ const has = await gitOk(repo, ["rev-parse", "--verify", "--quiet", remote]);
40
35
  return has ? remote : base;
41
36
  }
42
37
 
43
38
  // When the commit was made, in seconds.
44
39
  export async function commitTime(repo: string, sha: string): Promise<number> {
45
- const out = await run("git", ["show", "-s", "--format=%ct", sha], repo);
40
+ const out = await git(repo, ["show", "-s", "--format=%ct", sha]);
46
41
  return Number(out.trim()) || 0;
47
42
  }
48
43
 
@@ -61,25 +56,21 @@ export async function baseReviewSkill(
61
56
  repo: string,
62
57
  base: string
63
58
  ): Promise<{ path: string; text: string } | null> {
64
- const listed = await run(
65
- "git",
66
- [
67
- "ls-tree",
68
- "-r",
69
- "--name-only",
70
- base,
71
- "--",
72
- ".claude/agents",
73
- ...SKILL_DIRS,
74
- ],
75
- repo
76
- ).catch(() => "");
59
+ const listed = await git(repo, [
60
+ "ls-tree",
61
+ "-r",
62
+ "--name-only",
63
+ base,
64
+ "--",
65
+ ".claude/agents",
66
+ ...SKILL_DIRS,
67
+ ]).catch(() => "");
77
68
  const files = listed.split("\n").map((f) => f.trim());
78
69
  const agents = files.filter((f) => AGENT_RULES.test(f));
79
70
  if (agents.length) {
80
71
  const parts = await Promise.all(
81
72
  agents.map(async (f) => {
82
- const text = await run("git", ["show", `${base}:${f}`], repo);
73
+ const text = await git(repo, ["show", `${base}:${f}`]);
83
74
  return `## ${f.slice(".claude/agents/".length, -3)}\n${rulesOf(text)}`;
84
75
  })
85
76
  );
@@ -94,7 +85,7 @@ export async function baseReviewSkill(
94
85
  /(^|\/)[^/]*review[^/]*(\/SKILL\.md|\.md)$/i.test(f)
95
86
  );
96
87
  if (!file) return null;
97
- const text = await run("git", ["show", `${base}:${file}`], repo);
88
+ const text = await git(repo, ["show", `${base}:${file}`]);
98
89
  return { path: file, text: text.slice(0, 20000) };
99
90
  }
100
91
 
@@ -113,8 +104,8 @@ export async function checkout(repo: string, sha: string): Promise<string> {
113
104
  const dir = fs.realpathSync(
114
105
  fs.mkdtempSync(path.join(os.tmpdir(), "aos-review-"))
115
106
  );
116
- await run("git", ["worktree", "add", "--detach", dir, sha], repo);
117
- const staged = await run("git", ["ls-files", "-s"], dir);
107
+ await worktreeAdd(repo, ["--detach", dir, sha]);
108
+ const staged = await git(dir, ["ls-files", "-s"]);
118
109
  for (const line of staged.split("\n"))
119
110
  if (line.startsWith("120000 "))
120
111
  fs.rmSync(path.join(dir, line.split("\t")[1]), { force: true });
@@ -122,8 +113,6 @@ export async function checkout(repo: string, sha: string): Promise<string> {
122
113
  }
123
114
 
124
115
  export async function removeCheckout(repo: string, dir: string) {
125
- await run("git", ["worktree", "remove", "--force", dir], repo).catch(
126
- () => {}
127
- );
116
+ await worktreeRemove(repo, dir).catch(() => {});
128
117
  fs.rmSync(dir, { recursive: true, force: true });
129
118
  }
@@ -3,7 +3,7 @@
3
3
  * commit that was judged. With merge approvals on (merge-approvals.ts),
4
4
  * anything touching CI config, build and hook scripts, agent config, deploys
5
5
  * or secrets handling goes to Saad whatever the gates say. A repository with
6
- * no CI and a gate failing twice on a task go to him either way. Once a task
6
+ * no CI and a gate failing on two different commits go to him either way. Once a task
7
7
  * is with Saad the orchestrator doesn't merge it; he signs it off or drops it.
8
8
  */
9
9
 
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "child_process";
2
+ import { git as runGit } from "./git-exec";
2
3
  import { run } from "./tasks/gh";
3
4
  import { UNTRUSTED_RULE, untrusted } from "./orchestrator/untrusted";
4
5
 
@@ -56,7 +57,7 @@ async function getGitContext(
56
57
 
57
58
  // Names and text here come from the repository; every one is an
58
59
  // argument to git, never shell text or an option.
59
- const git = (args: string[]) => run("git", args, workingDir, 10_000);
60
+ const git = (args: string[]) => runGit(workingDir, args, { timeout: 10_000 });
60
61
  const lines = (out: string) =>
61
62
  out
62
63
  .split("\n")
package/lib/pr.ts CHANGED
@@ -1,26 +1,8 @@
1
- import { execFileSync } from "child_process";
1
+ import { getDefaultBranch } from "./git";
2
+ import { currentBranch, git, gitPush, mergeBase } from "./git-exec";
2
3
  import { run } from "./tasks/gh";
3
4
  import { GhBackoffError, isRateLimit } from "./tasks/gh-limit";
4
5
 
5
- /** The remote's default branch, or "main" when it has none. */
6
- export function remoteDefaultBranch(workingDir: string): string {
7
- try {
8
- return execFileSync(
9
- "git",
10
- ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
11
- {
12
- cwd: workingDir,
13
- encoding: "utf-8",
14
- stdio: ["ignore", "pipe", "ignore"],
15
- }
16
- )
17
- .trim()
18
- .replace(/^origin\//, "");
19
- } catch {
20
- return "main";
21
- }
22
- }
23
-
24
6
  export interface PRInfo {
25
7
  number: number;
26
8
  url: string;
@@ -62,26 +44,20 @@ export async function getCommitsSinceBase(
62
44
  // Get the merge base
63
45
  // Names come from the repository (its remote's default branch): they
64
46
  // are arguments, never shell text or options.
65
- const mergeBase = (
66
- await run(
67
- "git",
68
- ["merge-base", "--end-of-options", baseBranch, "HEAD"],
69
- workingDir,
70
- 10_000
71
- )
72
- ).trim();
47
+ const base = await mergeBase(workingDir, baseBranch, "HEAD", {
48
+ timeout: 10_000,
49
+ });
73
50
 
74
51
  // Get commits since merge base
75
- const output = await run(
76
- "git",
52
+ const output = await git(
53
+ workingDir,
77
54
  [
78
55
  "log",
79
- `${mergeBase}..HEAD`,
56
+ `${base}..HEAD`,
80
57
  "--format=COMMIT_START%n%H%n%s%n%b%nCOMMIT_END",
81
58
  "--",
82
59
  ],
83
- workingDir,
84
- 10_000
60
+ { timeout: 10_000 }
85
61
  );
86
62
 
87
63
  const commits: CommitInfo[] = [];
@@ -214,12 +190,9 @@ export async function createPR(
214
190
  ): Promise<PRInfo> {
215
191
  // First ensure branch is pushed
216
192
  try {
217
- await run(
218
- "git",
219
- ["push", "-u", "origin", "--", branchName],
220
- workingDir,
221
- 30_000
222
- );
193
+ await gitPush(workingDir, ["-u", "origin", "--", branchName], {
194
+ timeout: 30_000,
195
+ });
223
196
  } catch {
224
197
  // Branch might already be pushed
225
198
  }
@@ -251,27 +224,13 @@ export async function createPR(
251
224
  }
252
225
 
253
226
  /**
254
- * Get current branch name
227
+ * Get current branch name, "" when HEAD is detached.
255
228
  */
256
229
  export async function getCurrentBranch(workingDir: string): Promise<string> {
257
- return (
258
- await run("git", ["branch", "--show-current"], workingDir, 10_000)
259
- ).trim();
230
+ return currentBranch(workingDir, { timeout: 10_000 });
260
231
  }
261
232
 
262
233
  /**
263
- * Get the default base branch: the remote's, or "main" when it has none.
234
+ * The PR's default base: the repository's default branch.
264
235
  */
265
- export async function getBaseBranch(workingDir: string): Promise<string> {
266
- try {
267
- const ref = await run(
268
- "git",
269
- ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
270
- workingDir,
271
- 10_000
272
- );
273
- return ref.trim().replace(/^origin\//, "");
274
- } catch {
275
- return "main";
276
- }
277
- }
236
+ export const getBaseBranch = getDefaultBranch;
@@ -6,10 +6,9 @@
6
6
  * nothing.
7
7
  */
8
8
 
9
- import { execFile } from "child_process";
10
- import { promisify } from "util";
11
9
  import { db } from "../db";
12
10
  import { createWorktree } from "../worktrees";
11
+ import { git as runGit } from "../git-exec";
13
12
  import { setupWorktree } from "../env-setup";
14
13
  import {
15
14
  generateBranchName,
@@ -35,8 +34,6 @@ import {
35
34
  type SetupView,
36
35
  } from "./setup-progress";
37
36
 
38
- const execFileAsync = promisify(execFile);
39
-
40
37
  export interface WorktreeSetupInput {
41
38
  sessionId: string;
42
39
  projectPath: string;
@@ -50,7 +47,18 @@ export interface WorktreeSetupInput {
50
47
  }
51
48
 
52
49
  const git = (cwd: string, args: string[], timeout = 60_000) =>
53
- execFileAsync("git", ["-C", cwd, ...args], { timeout });
50
+ runGit(cwd, args, { timeout });
51
+
52
+ // git's own last line says it best ("not a git repository ..."), without
53
+ // the "git fetch failed: " a GitError puts before a one-line stderr (or
54
+ // createWorktree's "Failed to create worktree: " before that).
55
+ const lastLine = (message: string) =>
56
+ (message.trim().split("\n").pop() ?? "")
57
+ .replace(
58
+ /^(?:Failed to create worktree: )?git [\w-]+(?: [\w-]+)? failed: /,
59
+ ""
60
+ )
61
+ .replace(/^fatal: /, "");
54
62
 
55
63
  // No remote: nothing to fetch. A fetch that fails fails the setup: a
56
64
  // worktree cut from an old base would start the agent on stale code.
@@ -64,9 +72,7 @@ async function fetchBase(view: SetupView, cwd: string, base: string) {
64
72
  try {
65
73
  await git(cwd, ["fetch", "origin", "--", base]);
66
74
  } catch (error) {
67
- const why = (
68
- (error as Error).message.trim().split("\n").pop() ?? ""
69
- ).replace(/^fatal: /, "");
75
+ const why = lastLine((error as Error).message);
70
76
  throw new Error(`Fetching ${base} failed: ${why}`);
71
77
  }
72
78
  }
@@ -140,11 +146,9 @@ export async function setUpWorktree(input: WorktreeSetupInput): Promise<void> {
140
146
  // No worktree: the agent doesn't start. Its message stays queued, and
141
147
  // the session falls back to the project's folder if sent anyway.
142
148
  // git's own last line says it best ("not a git repository ...").
143
- const message = (error instanceof Error ? error.message : String(error))
144
- .trim()
145
- .split("\n")
146
- .pop()!
147
- .replace(/^fatal: /, "");
149
+ const message = lastLine(
150
+ error instanceof Error ? error.message : String(error)
151
+ );
148
152
  db.prepare(`UPDATE sessions SET working_directory = ? WHERE id = ?`).run(
149
153
  projectPath,
150
154
  sessionId
@@ -13,10 +13,10 @@ import { getProject } from "../projects";
13
13
  import { requireClient } from "../lumifyhub/connection";
14
14
  import { promptFromCard } from "../lumifyhub/task-cards";
15
15
  import { createTask } from "../tasks";
16
- import { run } from "../tasks/gh";
17
16
  import { expandHome } from "../tasks/session";
18
17
  import { itemName } from "./guard";
19
18
  import { outputOf } from "./git";
19
+ import { gitFetch, revParse } from "../git-exec";
20
20
 
21
21
  async function stackedBase(
22
22
  parent: StackItemRow,
@@ -31,16 +31,12 @@ async function stackedBase(
31
31
  if (!branch) throw new Error(`${itemName(parent)} has no branch to stack on`);
32
32
  // The branch must be on ORIGIN: the PR's base is the remote branch, and a
33
33
  // child cut from unpushed commits opens a PR whose diff includes them.
34
- await run("git", ["fetch", "origin", branch], repo).catch(
35
- (error: unknown) => {
36
- throw new Error(
37
- `${branch} is not on origin: ${outputOf(error).slice(0, 200)}`
38
- );
39
- }
40
- );
41
- const tip = (
42
- await run("git", ["rev-parse", `origin/${branch}`], repo)
43
- ).trim();
34
+ await gitFetch(repo, ["origin", branch]).catch((error: unknown) => {
35
+ throw new Error(
36
+ `${branch} is not on origin: ${outputOf(error).slice(0, 200)}`
37
+ );
38
+ });
39
+ const tip = await revParse(repo, `origin/${branch}`);
44
40
  const also = itemIds(item.also_item_ids)
45
41
  .map((id) => items.find((i) => i.id === id))
46
42
  .filter((i): i is StackItemRow => !!i && i.status !== "merged")
@@ -8,10 +8,11 @@ import fs from "fs";
8
8
  import path from "path";
9
9
  import { getRepoName, slugify } from "../git";
10
10
  import { WORKTREES_DIR } from "../worktrees";
11
- import { run } from "./gh";
11
+ import { git as runGit } from "../git-exec";
12
12
  import { pushedRef } from "./move";
13
13
 
14
- const git = (cwd: string, ...args: string[]) => run("git", args, cwd, 120000);
14
+ const git = (cwd: string, ...args: string[]) =>
15
+ runGit(cwd, args, { timeout: 120000 });
15
16
 
16
17
  /** The worktree that has this branch checked out, if one does. */
17
18
  export function worktreeOnBranch(
@@ -12,6 +12,7 @@ import { itemName, liveChildren, signOffRefusal } from "../stacks/guard";
12
12
  import { resolveMergedTaskAsks } from "../orchestrator/ask-settle";
13
13
  import { stopChat } from "../chat/runner";
14
14
  import { run } from "./gh";
15
+ import { gitFetch, gitPush } from "../git-exec";
15
16
  import {
16
17
  mergePolicy,
17
18
  type MergeMethod,
@@ -61,13 +62,11 @@ async function cleanup(
61
62
  !keepRemoteBranch &&
62
63
  !PROTECTED_BRANCHES.has(session.branch_name)
63
64
  ) {
64
- await run(
65
- "git",
66
- ["push", "origin", "--delete", session.branch_name],
67
- repo
68
- ).catch(() => {});
65
+ await gitPush(repo, ["origin", "--delete", session.branch_name]).catch(
66
+ () => {}
67
+ );
69
68
  }
70
- await run("git", ["fetch", "--prune", "--quiet"], repo).catch(() => {});
69
+ await gitFetch(repo, ["--prune", "--quiet"]).catch(() => {});
71
70
  forgetPR(session.id);
72
71
  }
73
72
 
package/lib/tasks/gh.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFile } from "child_process";
2
2
  import { promisify } from "util";
3
+ import { git } from "../git-exec";
3
4
  import { parseCodeReview } from "./code-review";
4
5
  import { parseScopeChange } from "./scope-change";
5
6
  import { assertGhAllowed, noteGhFailure, noteGhSuccess } from "./gh-limit";
@@ -16,12 +17,16 @@ import {
16
17
 
17
18
  const execFileAsync = promisify(execFile);
18
19
 
20
+ // Runs gh (or another CLI) and resolves stdout. git goes through
21
+ // lib/git-exec, so an injected runner (stacks, the orchestrator's rebase)
22
+ // gets the same git as everything else.
19
23
  export async function run(
20
24
  cmd: string,
21
25
  args: string[],
22
26
  cwd: string,
23
27
  timeout = 60000
24
28
  ): Promise<string> {
29
+ if (cmd === "git") return git(cwd, args, { timeout });
25
30
  if (cmd === "gh") assertGhAllowed(args);
26
31
  try {
27
32
  const { stdout } = await execFileAsync(cmd, args, {
@@ -5,16 +5,16 @@
5
5
  import fs from "fs";
6
6
  import os from "os";
7
7
  import path from "path";
8
- import { execFileSync } from "child_process";
9
8
  import { randomUUID } from "crypto";
10
9
  import { db, type Session } from "../db";
11
10
  import { createProject } from "../projects";
12
11
  import { writeTranscript } from "./transcript";
12
+ import { gitSync } from "../git-exec";
13
13
 
14
14
  export const CLAUDE_ID = "11111111-2222-3333-4444-555555555555";
15
15
 
16
16
  export const git = (cwd: string, ...args: string[]) =>
17
- execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
17
+ gitSync(cwd, args).trim();
18
18
 
19
19
  export const row = (id: string) =>
20
20
  db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as Session;
package/lib/tasks/move.ts CHANGED
@@ -13,7 +13,7 @@ import { db, type Session } from "../db";
13
13
  import { getProject } from "../projects";
14
14
  import { launchClaude } from "../agents/launch";
15
15
  import { buildTaskBrief } from "./brief";
16
- import { run } from "./gh";
16
+ import { git as runGit } from "../git-exec";
17
17
  import { getTaskSession } from "./session";
18
18
  import { projectRef } from "./project-ref";
19
19
  import { moveRefusal } from "./move-guard";
@@ -32,7 +32,8 @@ import {
32
32
  export * from "./move-bundle";
33
33
 
34
34
  const execFileAsync = promisify(execFile);
35
- const git = (cwd: string, ...args: string[]) => run("git", args, cwd, 120000);
35
+ const git = (cwd: string, ...args: string[]) =>
36
+ runGit(cwd, args, { timeout: 120000 });
36
37
 
37
38
  // Moves in flight in this process; the row's 'moving' outlives a restart.
38
39
  const g = globalThis as unknown as { __agentosExporting?: Set<string> };