@saadnvd1/agent-os 0.3.465 → 0.3.466
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/README.md +7 -0
- package/app/api/git/commit/route.ts +4 -3
- package/app/api/git/file-content/route.ts +4 -15
- package/app/api/git/history/[hash]/diff/route.ts +1 -1
- package/app/api/git/history/[hash]/route.ts +1 -1
- package/app/api/git/history/route.ts +1 -1
- package/app/api/git/push/route.ts +2 -2
- package/app/api/sessions/[id]/pr/route.ts +2 -2
- package/lib/away/deploy.ts +3 -7
- package/lib/away/run.ts +6 -5
- package/lib/build.ts +2 -6
- package/lib/chat/files.ts +31 -9
- package/lib/done/remote.ts +5 -6
- package/lib/done/testing.ts +2 -7
- package/lib/done/worktree.ts +2 -2
- package/lib/git-exec.ts +334 -0
- package/lib/git-history.ts +29 -41
- package/lib/git-status.ts +45 -124
- package/lib/git.ts +44 -159
- package/lib/lumifyhub/publish.ts +2 -2
- package/lib/orchestrator/diff.ts +15 -13
- package/lib/orchestrator/rebase.ts +20 -24
- package/lib/orchestrator/repo-slug.ts +4 -7
- package/lib/orchestrator/repo.ts +24 -35
- package/lib/pr-generation.ts +2 -1
- package/lib/pr.ts +16 -57
- package/lib/sessions/worktree-setup.ts +17 -13
- package/lib/stacks/start.ts +7 -11
- package/lib/tasks/branch-worktree.ts +3 -2
- package/lib/tasks/finish.ts +5 -6
- package/lib/tasks/gh.ts +5 -0
- package/lib/tasks/move-testing.ts +2 -2
- package/lib/tasks/move.ts +3 -2
- package/lib/tasks/project-ref.ts +12 -19
- package/lib/tasks/session.ts +5 -5
- package/lib/worktree-hooks.ts +3 -7
- package/lib/worktree-placed.ts +3 -10
- package/lib/worktrees.ts +4 -42
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1258,6 +1258,13 @@ One Node process serves every terminal, chat stream and request, so a route
|
|
|
1258
1258
|
handler in `app/api/` may not call a `*Sync` child_process or fs function:
|
|
1259
1259
|
ESLint refuses it. Use the async form with a timeout.
|
|
1260
1260
|
|
|
1261
|
+
Every git process starts in `lib/git-exec.ts`: `git()`, `gitResult()`,
|
|
1262
|
+
`gitOk()` and named operations (`revParse`, `worktreeAdd`, `gitPush`, …). It
|
|
1263
|
+
sets one timeout, kills a hung git, never lets git prompt, keeps the server's
|
|
1264
|
+
tmux out of hooks and rejects with a `GitError` carrying the exit code and
|
|
1265
|
+
stderr. ESLint refuses git spawned anywhere else in `lib/`, `app/` or
|
|
1266
|
+
`server.ts`.
|
|
1267
|
+
|
|
1261
1268
|
`scripts/redeploy` (and `scripts/autodeploy`, which calls it) builds into
|
|
1262
1269
|
`.next-build` while the live server keeps serving `.next`. The build is swapped
|
|
1263
1270
|
in by rename only once it finished with a `BUILD_ID`, right before the restart.
|
|
@@ -45,7 +45,8 @@ export async function POST(request: NextRequest) {
|
|
|
45
45
|
|
|
46
46
|
if (
|
|
47
47
|
branchName &&
|
|
48
|
-
(typeof branchName !== "string" ||
|
|
48
|
+
(typeof branchName !== "string" ||
|
|
49
|
+
!(await isValidBranchName(path, branchName)))
|
|
49
50
|
) {
|
|
50
51
|
return NextResponse.json(
|
|
51
52
|
{ error: "Invalid branch name" },
|
|
@@ -64,8 +65,8 @@ export async function POST(request: NextRequest) {
|
|
|
64
65
|
|
|
65
66
|
// Create new branch if on main/master and branch name provided
|
|
66
67
|
let newBranch = false;
|
|
67
|
-
if (branchName && isMainBranch(path)) {
|
|
68
|
-
createBranch(path, branchName);
|
|
68
|
+
if (branchName && (await isMainBranch(path))) {
|
|
69
|
+
await createBranch(path, branchName);
|
|
69
70
|
newBranch = true;
|
|
70
71
|
}
|
|
71
72
|
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
-
import {
|
|
3
|
-
import { promisify } from "util";
|
|
2
|
+
import { git } from "@/lib/git-exec";
|
|
4
3
|
import { expandPath } from "@/lib/git-status";
|
|
5
4
|
|
|
6
|
-
const execFileAsync = promisify(execFile);
|
|
7
|
-
|
|
8
5
|
/**
|
|
9
6
|
* GET /api/git/file-content?path=...&file=...
|
|
10
7
|
* Get file content from git HEAD (original version before changes)
|
|
@@ -33,17 +30,9 @@ export async function GET(request: NextRequest) {
|
|
|
33
30
|
|
|
34
31
|
try {
|
|
35
32
|
// Get file content from HEAD
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
{
|
|
40
|
-
cwd: expandedPath,
|
|
41
|
-
encoding: "utf-8",
|
|
42
|
-
maxBuffer: 10 * 1024 * 1024, // 10MB
|
|
43
|
-
timeout: 30_000,
|
|
44
|
-
killSignal: "SIGKILL",
|
|
45
|
-
}
|
|
46
|
-
);
|
|
33
|
+
const content = await git(expandedPath, ["show", `HEAD:${file}`], {
|
|
34
|
+
timeout: 30_000,
|
|
35
|
+
});
|
|
47
36
|
|
|
48
37
|
return NextResponse.json({ content });
|
|
49
38
|
} catch (gitError: unknown) {
|
|
@@ -31,7 +31,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|
|
31
31
|
);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const diff = getCommitFileDiff(path, hash, file);
|
|
34
|
+
const diff = await getCommitFileDiff(path, hash, file);
|
|
35
35
|
return NextResponse.json({ diff });
|
|
36
36
|
} catch (error) {
|
|
37
37
|
console.error("Error getting commit file diff:", error);
|
|
@@ -23,7 +23,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|
|
23
23
|
);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const commit = getCommitDetail(path, hash);
|
|
26
|
+
const commit = await getCommitDetail(path, hash);
|
|
27
27
|
if (!commit) {
|
|
28
28
|
return NextResponse.json({ error: "Commit not found" }, { status: 404 });
|
|
29
29
|
}
|
|
@@ -15,7 +15,7 @@ export async function GET(request: NextRequest) {
|
|
|
15
15
|
);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
const commits = getCommitHistory(path, limit);
|
|
18
|
+
const commits = await getCommitHistory(path, limit);
|
|
19
19
|
return NextResponse.json({ commits });
|
|
20
20
|
} catch (error) {
|
|
21
21
|
console.error("Error getting commit history:", error);
|
|
@@ -28,7 +28,7 @@ export async function POST(request: NextRequest) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
// Check if remote exists
|
|
31
|
-
const remoteUrl = getRemoteUrl(path);
|
|
31
|
+
const remoteUrl = await getRemoteUrl(path);
|
|
32
32
|
if (!remoteUrl) {
|
|
33
33
|
return NextResponse.json(
|
|
34
34
|
{ error: "No remote origin configured" },
|
|
@@ -48,7 +48,7 @@ export async function POST(request: NextRequest) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
// Push (set upstream if needed)
|
|
51
|
-
const needsUpstream = !hasUpstream(path);
|
|
51
|
+
const needsUpstream = !(await hasUpstream(path));
|
|
52
52
|
const output = await push(path, needsUpstream);
|
|
53
53
|
|
|
54
54
|
forgetGitStatus(path);
|
|
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
2
2
|
import { execFile } from "child_process";
|
|
3
3
|
import { promisify } from "util";
|
|
4
4
|
import { getDb, queries, type Session } from "@/lib/db";
|
|
5
|
+
import { gitPush } from "@/lib/git-exec";
|
|
5
6
|
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
|
|
@@ -69,8 +70,7 @@ async function createPR(
|
|
|
69
70
|
): Promise<PRInfo> {
|
|
70
71
|
// First push the branch if not already pushed
|
|
71
72
|
try {
|
|
72
|
-
await
|
|
73
|
-
cwd: projectPath,
|
|
73
|
+
await gitPush(projectPath, ["-u", "origin", "--", branchName], {
|
|
74
74
|
timeout: 30000,
|
|
75
75
|
});
|
|
76
76
|
} catch {
|
package/lib/away/deploy.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import fs from "fs";
|
|
11
11
|
import type { ProjectConfig } from "../project-config";
|
|
12
|
-
import {
|
|
12
|
+
import { gitFetch, isAncestor } from "../git-exec";
|
|
13
13
|
import { expandHome } from "../tasks/session";
|
|
14
14
|
import { selfUrl } from "../agents/self-env";
|
|
15
15
|
import { commitTime } from "../orchestrator/repo";
|
|
@@ -61,15 +61,11 @@ async function contains(
|
|
|
61
61
|
sha: string
|
|
62
62
|
): Promise<boolean> {
|
|
63
63
|
if (tip === sha) return true;
|
|
64
|
-
const ancestor = () =>
|
|
65
|
-
run("git", ["merge-base", "--is-ancestor", sha, tip], repo).then(
|
|
66
|
-
() => true,
|
|
67
|
-
() => false
|
|
68
|
-
);
|
|
64
|
+
const ancestor = () => isAncestor(repo, sha, tip);
|
|
69
65
|
if (await ancestor()) return true;
|
|
70
66
|
// The deployed commit may be newer than this checkout knows. A fetch that
|
|
71
67
|
// fails throws, so stale refs never read as "not deployed".
|
|
72
|
-
await
|
|
68
|
+
await gitFetch(repo, ["--quiet", "origin"]);
|
|
73
69
|
return ancestor();
|
|
74
70
|
}
|
|
75
71
|
|
package/lib/away/run.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { notifyPhone } from "../notify";
|
|
|
23
23
|
import { getProject } from "../projects";
|
|
24
24
|
import { loadProjectConfig } from "../project-config";
|
|
25
25
|
import { run } from "../tasks/gh";
|
|
26
|
+
import { git } from "../git-exec";
|
|
26
27
|
import { expandHome } from "../tasks/session";
|
|
27
28
|
import { auditId, auditsRoot, writeAtomic } from "../session-audit/store";
|
|
28
29
|
import {
|
|
@@ -199,11 +200,11 @@ async function dirtySessions(ids: string[]): Promise<Set<string>> {
|
|
|
199
200
|
// A linked machine's worktree isn't on this disk.
|
|
200
201
|
if (!s || (s.host_id && s.host_id !== "local")) return;
|
|
201
202
|
const dir = expandHome(s.worktree_path || s.working_directory);
|
|
202
|
-
const out = await
|
|
203
|
-
"
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
).catch(() => "");
|
|
203
|
+
const out = await git(dir, [
|
|
204
|
+
"status",
|
|
205
|
+
"--porcelain",
|
|
206
|
+
"--untracked-files=normal",
|
|
207
|
+
]).catch(() => "");
|
|
207
208
|
if (out.trim()) dirty.add(id);
|
|
208
209
|
})
|
|
209
210
|
);
|
package/lib/build.ts
CHANGED
|
@@ -3,20 +3,16 @@
|
|
|
3
3
|
// A worker whose build isn't the server's is running code from before a
|
|
4
4
|
// redeploy.
|
|
5
5
|
|
|
6
|
-
import { execFileSync } from "child_process";
|
|
7
6
|
import fs from "fs";
|
|
8
7
|
import path from "path";
|
|
9
8
|
import type { ChatState } from "./chat/events";
|
|
9
|
+
import { gitSync } from "./git-exec";
|
|
10
10
|
|
|
11
11
|
export function buildId(): string {
|
|
12
12
|
if (process.env.AGENTOS_BUILD) return process.env.AGENTOS_BUILD;
|
|
13
13
|
let id = "";
|
|
14
14
|
try {
|
|
15
|
-
id =
|
|
16
|
-
cwd: process.cwd(),
|
|
17
|
-
encoding: "utf8",
|
|
18
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
19
|
-
}).trim();
|
|
15
|
+
id = gitSync(process.cwd(), ["rev-parse", "HEAD"]).trim();
|
|
20
16
|
} catch {}
|
|
21
17
|
if (!id) {
|
|
22
18
|
try {
|
package/lib/chat/files.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 type { FileSuggestion } from "./events";
|
|
4
5
|
import {
|
|
5
6
|
indexPaths,
|
|
@@ -33,11 +34,36 @@ export function sweepFileIndexes(now = Date.now()): void {
|
|
|
33
34
|
|
|
34
35
|
export const fileIndexCount = () => cache.size;
|
|
35
36
|
|
|
37
|
+
const LIST_TIMEOUT_MS = 5000;
|
|
38
|
+
|
|
39
|
+
export interface Lister {
|
|
40
|
+
// The program it needs.
|
|
41
|
+
cmd: string;
|
|
42
|
+
list: (cwd: string) => Promise<string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
// Ways to list a folder's files with ignored ones left out: ripgrep, or
|
|
37
46
|
// git where ripgrep isn't installed.
|
|
38
|
-
export const LISTERS: [
|
|
39
|
-
|
|
40
|
-
|
|
47
|
+
export const LISTERS: Lister[] = [
|
|
48
|
+
{
|
|
49
|
+
cmd: "rg",
|
|
50
|
+
list: async (cwd) =>
|
|
51
|
+
(
|
|
52
|
+
await execFileAsync("rg", ["--files", "--hidden", "-g", "!.git"], {
|
|
53
|
+
cwd,
|
|
54
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
55
|
+
timeout: LIST_TIMEOUT_MS,
|
|
56
|
+
})
|
|
57
|
+
).stdout,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
cmd: "git",
|
|
61
|
+
list: (cwd) =>
|
|
62
|
+
git(cwd, ["ls-files", "--cached", "--others", "--exclude-standard"], {
|
|
63
|
+
timeout: LIST_TIMEOUT_MS,
|
|
64
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
41
67
|
];
|
|
42
68
|
|
|
43
69
|
// The folder's files, for @mentions when no agent is running to match them
|
|
@@ -46,13 +72,9 @@ export async function listFiles(
|
|
|
46
72
|
cwd: string,
|
|
47
73
|
listers = LISTERS
|
|
48
74
|
): Promise<IndexedPath[]> {
|
|
49
|
-
for (const
|
|
75
|
+
for (const { list } of listers) {
|
|
50
76
|
try {
|
|
51
|
-
const
|
|
52
|
-
cwd,
|
|
53
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
54
|
-
timeout: 5000,
|
|
55
|
-
});
|
|
77
|
+
const stdout = await list(cwd);
|
|
56
78
|
return indexPaths(
|
|
57
79
|
withFolders(stdout.split("\n").filter(Boolean).slice(0, MAX_FILES))
|
|
58
80
|
);
|
package/lib/done/remote.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { Session } from "../db";
|
|
6
6
|
import type { TaskPR } from "../tasks";
|
|
7
|
-
import {
|
|
7
|
+
import { git, gitPush } from "../git-exec";
|
|
8
8
|
import { projectPathFor } from "../tasks/session";
|
|
9
9
|
import { PROTECTED_BRANCHES } from "../tasks/finish";
|
|
10
10
|
import { liveChildren } from "../stacks/guard";
|
|
@@ -23,15 +23,14 @@ export async function deleteMergedRemote(
|
|
|
23
23
|
liveChildren(s.id).length
|
|
24
24
|
)
|
|
25
25
|
return false;
|
|
26
|
-
const line = await
|
|
27
|
-
"git",
|
|
28
|
-
["ls-remote", "--heads", "origin", `refs/heads/${branch}`],
|
|
26
|
+
const line = await git(
|
|
29
27
|
repo,
|
|
30
|
-
|
|
28
|
+
["ls-remote", "--heads", "origin", `refs/heads/${branch}`],
|
|
29
|
+
{ timeout: 30000 }
|
|
31
30
|
).catch(() => "");
|
|
32
31
|
const tip = line.trim().split(/\s+/)[0];
|
|
33
32
|
if (!tip || tip !== pr.head) return false;
|
|
34
|
-
return
|
|
33
|
+
return gitPush(repo, ["origin", "--delete", branch], { timeout: 60000 })
|
|
35
34
|
.then(() => true)
|
|
36
35
|
.catch(() => false);
|
|
37
36
|
}
|
package/lib/done/testing.ts
CHANGED
|
@@ -4,18 +4,13 @@
|
|
|
4
4
|
import fs from "fs";
|
|
5
5
|
import os from "os";
|
|
6
6
|
import path from "path";
|
|
7
|
-
import {
|
|
7
|
+
import { gitSync } from "../git-exec";
|
|
8
8
|
|
|
9
9
|
// Old enough that CI counts as settled on every commit.
|
|
10
10
|
const env = { ...process.env, GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z" };
|
|
11
11
|
|
|
12
12
|
export const git = (cwd: string, ...args: string[]) =>
|
|
13
|
-
|
|
14
|
-
cwd,
|
|
15
|
-
encoding: "utf8",
|
|
16
|
-
stdio: "pipe",
|
|
17
|
-
env,
|
|
18
|
-
}).trim();
|
|
13
|
+
gitSync(cwd, args, { env }).trim();
|
|
19
14
|
|
|
20
15
|
export function commitFile(cwd: string, file: string, text: string): string {
|
|
21
16
|
fs.mkdirSync(path.dirname(path.join(cwd, file)), { recursive: true });
|
package/lib/done/worktree.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import fs from "fs";
|
|
8
8
|
import type { Session } from "../db";
|
|
9
9
|
import { getDefaultBranch } from "../git";
|
|
10
|
-
import {
|
|
10
|
+
import { git as runGit } from "../git-exec";
|
|
11
11
|
import { mergePolicy } from "../tasks/merge-policy";
|
|
12
12
|
import { getProject } from "../projects";
|
|
13
13
|
import {
|
|
@@ -24,7 +24,7 @@ export type WorktreeFate =
|
|
|
24
24
|
| { action: "kept"; why: string; path: string };
|
|
25
25
|
|
|
26
26
|
const git = (cwd: string, ...args: string[]) =>
|
|
27
|
-
|
|
27
|
+
runGit(cwd, args, { timeout: 30000 }).then((out) => out.trim());
|
|
28
28
|
|
|
29
29
|
// The main checkout a worktree belongs to.
|
|
30
30
|
async function repoOf(worktree: string): Promise<string> {
|
package/lib/git-exec.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one way AgentOS runs git. Every git process starts here, so they all
|
|
3
|
+
* share the same hardening:
|
|
4
|
+
*
|
|
5
|
+
* - argv only, never a shell: a branch name, path or message is always data;
|
|
6
|
+
* - off the event loop, with a ceiling (a hung ssh, a stalled mount or a
|
|
7
|
+
* stuck hook is killed at it, with SIGKILL);
|
|
8
|
+
* - no prompt can ever wait for an answer (GIT_TERMINAL_PROMPT=0), and stdin
|
|
9
|
+
* is always closed;
|
|
10
|
+
* - no optional locks, so a read never takes the index lock a commit or
|
|
11
|
+
* rebase needs;
|
|
12
|
+
* - the server's own tmux (TMUX, TMUX_PANE) never reaches a hook;
|
|
13
|
+
* - a failure is a GitError carrying the exit code, stderr and stdout.
|
|
14
|
+
*
|
|
15
|
+
* A lint rule (eslint.config.mjs) refuses git spawned anywhere else.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { execFile, execFileSync } from "child_process";
|
|
19
|
+
|
|
20
|
+
export const GIT_TIMEOUT_MS = 60_000;
|
|
21
|
+
const MAX_BUFFER = 16 * 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
export interface GitOptions {
|
|
24
|
+
// Milliseconds before git is killed. Default GIT_TIMEOUT_MS.
|
|
25
|
+
timeout?: number;
|
|
26
|
+
// Written to stdin, which is closed either way.
|
|
27
|
+
input?: string;
|
|
28
|
+
// Added to the shared environment.
|
|
29
|
+
env?: Record<string, string>;
|
|
30
|
+
// Paths are names, never globs or pathspec magic: "[draft].md" or "*"
|
|
31
|
+
// must not reach other files.
|
|
32
|
+
literalPathspecs?: boolean;
|
|
33
|
+
// Bytes of output before git is killed. Default 16MB.
|
|
34
|
+
maxBuffer?: number;
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class GitError extends Error {
|
|
39
|
+
override readonly name = "GitError";
|
|
40
|
+
constructor(
|
|
41
|
+
message: string,
|
|
42
|
+
readonly args: string[],
|
|
43
|
+
readonly cwd: string,
|
|
44
|
+
// git's exit status; null when it never exited on its own.
|
|
45
|
+
readonly exitCode: number | null,
|
|
46
|
+
readonly signal: NodeJS.Signals | null,
|
|
47
|
+
readonly stdout: string,
|
|
48
|
+
readonly stderr: string,
|
|
49
|
+
readonly timedOut: boolean,
|
|
50
|
+
options?: { cause?: unknown }
|
|
51
|
+
) {
|
|
52
|
+
super(message, options);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// What execFile's errors carry, for callers written against them.
|
|
56
|
+
get code(): number | string | null {
|
|
57
|
+
const cause = this.cause as { code?: unknown } | undefined;
|
|
58
|
+
if (this.exitCode !== null) return this.exitCode;
|
|
59
|
+
return typeof cause?.code === "string" ? cause.code : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get killed(): boolean {
|
|
63
|
+
return this.timedOut || this.signal !== null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const isGitError = (error: unknown): error is GitError =>
|
|
68
|
+
error instanceof GitError;
|
|
69
|
+
|
|
70
|
+
/** The environment every git runs with. */
|
|
71
|
+
export function gitEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
|
|
72
|
+
const env: NodeJS.ProcessEnv = {
|
|
73
|
+
...process.env,
|
|
74
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
75
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
76
|
+
...extra,
|
|
77
|
+
};
|
|
78
|
+
delete env.TMUX;
|
|
79
|
+
delete env.TMUX_PANE;
|
|
80
|
+
return env;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const argv = (args: string[], opts: GitOptions) =>
|
|
84
|
+
opts.literalPathspecs ? ["--literal-pathspecs", ...args] : args;
|
|
85
|
+
|
|
86
|
+
// The subcommand, for messages: "git worktree add", "git push".
|
|
87
|
+
const named = (args: string[]) => {
|
|
88
|
+
const words = args.filter((a) => !a.startsWith("-")).slice(0, 2);
|
|
89
|
+
return ["git", ...(words[0] === "worktree" ? words : words.slice(0, 1))]
|
|
90
|
+
.join(" ")
|
|
91
|
+
.trim();
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function toGitError(
|
|
95
|
+
error: unknown,
|
|
96
|
+
args: string[],
|
|
97
|
+
cwd: string,
|
|
98
|
+
stdout: string,
|
|
99
|
+
stderr: string,
|
|
100
|
+
timeout: number
|
|
101
|
+
): GitError {
|
|
102
|
+
const e = error as {
|
|
103
|
+
code?: unknown;
|
|
104
|
+
status?: unknown;
|
|
105
|
+
killed?: boolean;
|
|
106
|
+
signal?: NodeJS.Signals | null;
|
|
107
|
+
name?: string;
|
|
108
|
+
message?: string;
|
|
109
|
+
};
|
|
110
|
+
// execFile reports the exit status as `code`, execFileSync as `status`.
|
|
111
|
+
const exitCode =
|
|
112
|
+
typeof e.code === "number"
|
|
113
|
+
? e.code
|
|
114
|
+
: typeof e.status === "number"
|
|
115
|
+
? e.status
|
|
116
|
+
: null;
|
|
117
|
+
const aborted = e.name === "AbortError" || e.code === "ABORT_ERR";
|
|
118
|
+
const overflow = e.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
|
|
119
|
+
const timedOut =
|
|
120
|
+
e.code === "ETIMEDOUT" ||
|
|
121
|
+
(exitCode === null && !aborted && !overflow && e.killed === true);
|
|
122
|
+
const what = named(args);
|
|
123
|
+
const reason =
|
|
124
|
+
stderr.trim() || stdout.trim() || e.message || String(error ?? "");
|
|
125
|
+
const message = timedOut
|
|
126
|
+
? `${what} timed out after ${Math.round(timeout / 1000)}s`
|
|
127
|
+
: `${what} failed: ${reason}`;
|
|
128
|
+
return new GitError(
|
|
129
|
+
message,
|
|
130
|
+
args,
|
|
131
|
+
cwd,
|
|
132
|
+
exitCode,
|
|
133
|
+
e.signal ?? null,
|
|
134
|
+
stdout,
|
|
135
|
+
stderr,
|
|
136
|
+
timedOut,
|
|
137
|
+
{ cause: error }
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Runs git in `cwd`; resolves stdout and stderr, rejects with a GitError. */
|
|
142
|
+
export function gitResult(
|
|
143
|
+
cwd: string,
|
|
144
|
+
args: string[],
|
|
145
|
+
opts: GitOptions = {}
|
|
146
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
147
|
+
const full = argv(args, opts);
|
|
148
|
+
const timeout = opts.timeout ?? GIT_TIMEOUT_MS;
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const child = execFile(
|
|
151
|
+
"git",
|
|
152
|
+
full,
|
|
153
|
+
{
|
|
154
|
+
cwd,
|
|
155
|
+
encoding: "utf-8",
|
|
156
|
+
maxBuffer: opts.maxBuffer ?? MAX_BUFFER,
|
|
157
|
+
timeout,
|
|
158
|
+
killSignal: "SIGKILL",
|
|
159
|
+
env: gitEnv(opts.env),
|
|
160
|
+
signal: opts.signal,
|
|
161
|
+
},
|
|
162
|
+
(error, stdout, stderr) => {
|
|
163
|
+
if (!error) return resolve({ stdout, stderr });
|
|
164
|
+
reject(toGitError(error, args, cwd, stdout, stderr, timeout));
|
|
165
|
+
}
|
|
166
|
+
);
|
|
167
|
+
// git exiting before it read its input is its failure, not a crash.
|
|
168
|
+
child.stdin?.on("error", () => {});
|
|
169
|
+
child.stdin?.end(opts.input ?? "");
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Runs git in `cwd` and resolves what it printed, untrimmed. */
|
|
174
|
+
export async function git(
|
|
175
|
+
cwd: string,
|
|
176
|
+
args: string[],
|
|
177
|
+
opts: GitOptions = {}
|
|
178
|
+
): Promise<string> {
|
|
179
|
+
return (await gitResult(cwd, args, opts)).stdout;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Whether git exits 0. Never throws. */
|
|
183
|
+
export function gitOk(
|
|
184
|
+
cwd: string,
|
|
185
|
+
args: string[],
|
|
186
|
+
opts: GitOptions = {}
|
|
187
|
+
): Promise<boolean> {
|
|
188
|
+
return gitResult(cwd, args, opts).then(
|
|
189
|
+
() => true,
|
|
190
|
+
() => false
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The blocking form, for what runs once outside a request (the build id at
|
|
196
|
+
* startup) and test setup. Never in a request handler.
|
|
197
|
+
*/
|
|
198
|
+
export function gitSync(
|
|
199
|
+
cwd: string,
|
|
200
|
+
args: string[],
|
|
201
|
+
opts: Omit<GitOptions, "signal"> = {}
|
|
202
|
+
): string {
|
|
203
|
+
const full = argv(args, opts);
|
|
204
|
+
const timeout = opts.timeout ?? GIT_TIMEOUT_MS;
|
|
205
|
+
try {
|
|
206
|
+
return execFileSync("git", full, {
|
|
207
|
+
cwd,
|
|
208
|
+
encoding: "utf-8",
|
|
209
|
+
input: opts.input ?? "",
|
|
210
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
211
|
+
maxBuffer: opts.maxBuffer ?? MAX_BUFFER,
|
|
212
|
+
timeout,
|
|
213
|
+
killSignal: "SIGKILL",
|
|
214
|
+
env: gitEnv(opts.env),
|
|
215
|
+
});
|
|
216
|
+
} catch (error) {
|
|
217
|
+
const e = error as { stdout?: unknown; stderr?: unknown };
|
|
218
|
+
const text = (v: unknown) =>
|
|
219
|
+
typeof v === "string" ? v : Buffer.isBuffer(v) ? v.toString() : "";
|
|
220
|
+
throw toGitError(error, args, cwd, text(e.stdout), text(e.stderr), timeout);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// The operations AgentOS actually runs. Names reach git as arguments after
|
|
226
|
+
// "--" or --end-of-options wherever git allows it, so none is read as an
|
|
227
|
+
// option.
|
|
228
|
+
|
|
229
|
+
const trimmed = (p: Promise<string>) => p.then((s) => s.trim());
|
|
230
|
+
|
|
231
|
+
/** `rev-parse <ref>`, trimmed. Throws when it doesn't resolve. */
|
|
232
|
+
export const revParse = (cwd: string, ref: string, opts?: GitOptions) =>
|
|
233
|
+
trimmed(git(cwd, ["rev-parse", ref], opts));
|
|
234
|
+
|
|
235
|
+
/** The object `ref` names, or null when it names none. */
|
|
236
|
+
export const resolveRef = (cwd: string, ref: string, opts?: GitOptions) =>
|
|
237
|
+
git(cwd, ["rev-parse", "--verify", "--quiet", ref], opts).then(
|
|
238
|
+
(out) => out.trim() || null,
|
|
239
|
+
() => null
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
/** The checked-out branch, "" when HEAD is detached. */
|
|
243
|
+
export const currentBranch = (cwd: string, opts?: GitOptions) =>
|
|
244
|
+
trimmed(git(cwd, ["branch", "--show-current"], opts));
|
|
245
|
+
|
|
246
|
+
/** `status --porcelain` as printed. */
|
|
247
|
+
export const statusPorcelain = (cwd: string, opts?: GitOptions) =>
|
|
248
|
+
git(cwd, ["status", "--porcelain"], opts);
|
|
249
|
+
|
|
250
|
+
/** Whether `ancestor` is reachable from `ref`; false when git can't say. */
|
|
251
|
+
export const isAncestor = (
|
|
252
|
+
cwd: string,
|
|
253
|
+
ancestor: string,
|
|
254
|
+
ref: string,
|
|
255
|
+
opts?: GitOptions
|
|
256
|
+
) => gitOk(cwd, ["merge-base", "--is-ancestor", ancestor, ref], opts);
|
|
257
|
+
|
|
258
|
+
/** The best common ancestor of `a` and `b`. */
|
|
259
|
+
export const mergeBase = (
|
|
260
|
+
cwd: string,
|
|
261
|
+
a: string,
|
|
262
|
+
b: string,
|
|
263
|
+
opts?: GitOptions
|
|
264
|
+
) => trimmed(git(cwd, ["merge-base", "--end-of-options", a, b], opts));
|
|
265
|
+
|
|
266
|
+
export const gitFetch = (cwd: string, args: string[] = [], opts?: GitOptions) =>
|
|
267
|
+
git(cwd, ["fetch", ...args], opts);
|
|
268
|
+
|
|
269
|
+
/** Pushes; resolves stdout then stderr, where git reports what it did. */
|
|
270
|
+
export const gitPush = (cwd: string, args: string[], opts?: GitOptions) =>
|
|
271
|
+
gitResult(cwd, ["push", ...args], opts).then(
|
|
272
|
+
({ stdout, stderr }) => `${stdout}${stderr}`
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
export const gitRebase = (cwd: string, args: string[], opts?: GitOptions) =>
|
|
276
|
+
git(cwd, ["rebase", ...args], opts);
|
|
277
|
+
|
|
278
|
+
/** `diff --stat` of `args` (a range, paths), as printed. */
|
|
279
|
+
export const diffStat = (cwd: string, args: string[], opts?: GitOptions) =>
|
|
280
|
+
git(cwd, ["diff", "--stat", ...args], opts);
|
|
281
|
+
|
|
282
|
+
/** Deletes a local branch; `force` deletes it unmerged (-D). */
|
|
283
|
+
export const deleteBranch = (
|
|
284
|
+
cwd: string,
|
|
285
|
+
branch: string,
|
|
286
|
+
{ force = false, ...opts }: GitOptions & { force?: boolean } = {}
|
|
287
|
+
) => git(cwd, ["branch", force ? "-D" : "-d", "--", branch], opts);
|
|
288
|
+
|
|
289
|
+
export const worktreeAdd = (repo: string, args: string[], opts?: GitOptions) =>
|
|
290
|
+
git(repo, ["worktree", "add", ...args], opts);
|
|
291
|
+
|
|
292
|
+
export const worktreeRemove = (
|
|
293
|
+
repo: string,
|
|
294
|
+
dir: string,
|
|
295
|
+
{ force = 1, ...opts }: GitOptions & { force?: 0 | 1 | 2 } = {}
|
|
296
|
+
) =>
|
|
297
|
+
git(
|
|
298
|
+
repo,
|
|
299
|
+
["worktree", "remove", ...Array(force).fill("--force"), "--", dir],
|
|
300
|
+
opts
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
export interface WorktreeEntry {
|
|
304
|
+
path: string;
|
|
305
|
+
// Short name, "" when detached.
|
|
306
|
+
branch: string;
|
|
307
|
+
head: string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** `worktree list --porcelain`, parsed. The main checkout is first. */
|
|
311
|
+
export async function worktreeList(
|
|
312
|
+
repo: string,
|
|
313
|
+
opts?: GitOptions
|
|
314
|
+
): Promise<WorktreeEntry[]> {
|
|
315
|
+
const out = await git(repo, ["worktree", "list", "--porcelain"], opts);
|
|
316
|
+
return parseWorktreeList(out);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function parseWorktreeList(out: string): WorktreeEntry[] {
|
|
320
|
+
const entries: WorktreeEntry[] = [];
|
|
321
|
+
for (const block of out.split("\n\n")) {
|
|
322
|
+
let path = "";
|
|
323
|
+
let branch = "";
|
|
324
|
+
let head = "";
|
|
325
|
+
for (const line of block.split("\n")) {
|
|
326
|
+
if (line.startsWith("worktree ")) path = line.slice(9);
|
|
327
|
+
else if (line.startsWith("branch "))
|
|
328
|
+
branch = line.slice(7).replace(/^refs\/heads\//, "");
|
|
329
|
+
else if (line.startsWith("HEAD ")) head = line.slice(5);
|
|
330
|
+
}
|
|
331
|
+
if (path) entries.push({ path, branch, head });
|
|
332
|
+
}
|
|
333
|
+
return entries;
|
|
334
|
+
}
|