@saadnvd1/agent-os 0.3.639 → 0.3.644
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/app/api/bus/branches/route.ts +25 -0
- package/app/api/bus/resurrect/route.test.ts +3 -6
- package/app/api/worktree-sweep/route.test.ts +40 -0
- package/app/api/worktree-sweep/route.ts +31 -0
- package/app/api/worktree-sweep/run/route.ts +26 -0
- package/components/Health/HealthPanel.tsx +4 -1
- package/components/Health/SweepRuns.tsx +125 -0
- package/components/Merge/GlobalMergeSection.tsx +2 -0
- package/components/Merge/ProjectMergeSection.tsx +2 -0
- package/components/Merge/WorktreeSweepSection.tsx +125 -0
- package/components/Settings/NoApprovalsSection.tsx +1 -1
- package/components/Settings/SettingsDialog.tsx +1 -1
- package/data/worktree-sweep/index.ts +56 -0
- package/lib/away/report.ts +1 -1
- package/lib/db/backup.ts +49 -0
- package/lib/db/migrations/20261015T1733_gate_loop_guard.ts +40 -0
- package/lib/db/migrations/20261015T1830_drop_moved_session_columns.ts +173 -0
- package/lib/db/migrations/20261015T1900_worktree_sweep.ts +30 -0
- package/lib/db/migrations/README.md +7 -3
- package/lib/db/session-parts/columns.ts +3 -3
- package/lib/db/session-parts/index.ts +1 -0
- package/lib/db/session-parts/rows.ts +69 -0
- package/lib/db/session-parts/view.ts +14 -7
- package/lib/db/session-parts/write.ts +4 -12
- package/lib/done/remote.ts +9 -20
- package/lib/orchestrator/armed.test-helpers.ts +228 -0
- package/lib/orchestrator/armed.ts +1 -1
- package/lib/orchestrator/ask-verdict.ts +111 -0
- package/lib/orchestrator/asks.ts +26 -0
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/escalate.ts +25 -12
- package/lib/orchestrator/external-gates.ts +1 -0
- package/lib/orchestrator/gates.ts +94 -25
- package/lib/orchestrator/held-asks.ts +5 -8
- package/lib/orchestrator/held-review.ts +8 -4
- package/lib/orchestrator/loop-guard.ts +145 -0
- package/lib/orchestrator/merge-approvals.ts +2 -2
- package/lib/orchestrator/quick.ts +1 -1
- package/lib/orchestrator/review-prompt.ts +2 -0
- package/lib/orchestrator/review.ts +14 -4
- package/lib/orchestrator/signoff.test-helpers.ts +322 -0
- package/lib/orchestrator/signoff.ts +46 -10
- package/lib/orchestrator/testing.ts +11 -14
- package/lib/orchestrator/watcher.ts +4 -0
- package/lib/recipes/test-fixtures.ts +2 -3
- package/lib/resurrect/index.ts +20 -2
- package/lib/resurrect/swept.ts +61 -0
- package/lib/stacks/testing.ts +17 -16
- package/lib/tasks/finish.ts +20 -14
- package/lib/tasks/move-testing.ts +16 -7
- package/lib/tasks/pr-activity.ts +11 -2
- package/lib/tasks/prune-branches.ts +193 -0
- package/lib/tasks/prune-scope.ts +22 -0
- package/lib/tasks/remote-branch.ts +170 -0
- package/lib/tasks/testing.ts +13 -7
- package/lib/worktree-sweep/commits.ts +65 -0
- package/lib/worktree-sweep/findOrphans.ts +139 -0
- package/lib/worktree-sweep/findSettled.ts +112 -0
- package/lib/worktree-sweep/index.ts +10 -0
- package/lib/worktree-sweep/judge.ts +31 -0
- package/lib/worktree-sweep/panes.ts +18 -0
- package/lib/worktree-sweep/runSweep.ts +243 -0
- package/lib/worktree-sweep/settings.ts +83 -0
- package/lib/worktree-sweep/start.ts +64 -0
- package/lib/worktree-sweep/state.ts +36 -0
- package/lib/worktree-sweep/store.ts +96 -0
- package/lib/worktree-sweep/types.ts +53 -0
- package/package.json +1 -1
- package/scripts/screenshots/seed.ts +19 -18
- package/server.ts +3 -0
package/lib/stacks/testing.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// Test fixtures: a project, task sessions and a stack in the test database.
|
|
2
2
|
|
|
3
3
|
import { randomUUID } from "crypto";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
db,
|
|
6
|
+
insertSessionRow,
|
|
7
|
+
stackQueries as q,
|
|
8
|
+
type StackItemStatus,
|
|
9
|
+
} from "../db";
|
|
5
10
|
import { createProject } from "../projects";
|
|
6
11
|
|
|
7
12
|
export interface SeedItem {
|
|
@@ -28,22 +33,18 @@ export function seedStack(repo: string, specs: SeedItem[]) {
|
|
|
28
33
|
if (!s.branch) continue;
|
|
29
34
|
const id = randomUUID();
|
|
30
35
|
sessions.set(s.key, id);
|
|
31
|
-
db
|
|
32
|
-
`INSERT INTO sessions (id, name, tmux_name, working_directory, project_id, task_status,
|
|
33
|
-
branch_name, worktree_path, base_branch, pr_number)
|
|
34
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
35
|
-
).run(
|
|
36
|
+
insertSessionRow(db, {
|
|
36
37
|
id,
|
|
37
|
-
s.key,
|
|
38
|
-
`claude-${id}`,
|
|
39
|
-
s.worktree ?? repo,
|
|
40
|
-
project.id,
|
|
41
|
-
s.status === "merged" ? "merged" : "running",
|
|
42
|
-
s.branch,
|
|
43
|
-
s.worktree ?? null,
|
|
44
|
-
s.baseBranch ?? null,
|
|
45
|
-
s.pr ?? null
|
|
46
|
-
);
|
|
38
|
+
name: s.key,
|
|
39
|
+
tmux_name: `claude-${id}`,
|
|
40
|
+
working_directory: s.worktree ?? repo,
|
|
41
|
+
project_id: project.id,
|
|
42
|
+
task_status: s.status === "merged" ? "merged" : "running",
|
|
43
|
+
branch_name: s.branch,
|
|
44
|
+
worktree_path: s.worktree ?? null,
|
|
45
|
+
base_branch: s.baseBranch ?? null,
|
|
46
|
+
pr_number: s.pr ?? null,
|
|
47
|
+
});
|
|
47
48
|
}
|
|
48
49
|
q.create(
|
|
49
50
|
db,
|
package/lib/tasks/finish.ts
CHANGED
|
@@ -16,7 +16,8 @@ import { resolveMergedTaskAsks } from "../orchestrator/ask-settle";
|
|
|
16
16
|
import { endSessionAgent } from "../sessions/end";
|
|
17
17
|
import { run } from "./gh";
|
|
18
18
|
import { pushMerged } from "../notify/push/events";
|
|
19
|
-
import { gitFetch
|
|
19
|
+
import { gitFetch } from "../git-exec";
|
|
20
|
+
import { deleteTaskBranch } from "./remote-branch";
|
|
20
21
|
import {
|
|
21
22
|
mergePolicy,
|
|
22
23
|
type MergeMethod,
|
|
@@ -40,9 +41,6 @@ import { snapshotWorktree } from "../resurrect/snapshot";
|
|
|
40
41
|
import { keepSessionFiles } from "../session-artifacts/collect";
|
|
41
42
|
import { setPr, setTask } from "../db/session-parts";
|
|
42
43
|
|
|
43
|
-
// Never deleted, whatever a setting says.
|
|
44
|
-
export const PROTECTED_BRANCHES = new Set(["main", "master", "HEAD"]);
|
|
45
|
-
|
|
46
44
|
export const sessionMergePolicy = (session: Session): MergePolicy =>
|
|
47
45
|
mergePolicy(session.project_id ? getProject(session.project_id) : null);
|
|
48
46
|
|
|
@@ -52,7 +50,14 @@ export const sessionMergePolicy = (session: Session): MergePolicy =>
|
|
|
52
50
|
async function cleanup(
|
|
53
51
|
session: Session,
|
|
54
52
|
repo: string,
|
|
55
|
-
opts: {
|
|
53
|
+
opts: {
|
|
54
|
+
keepRemoteBranch?: boolean;
|
|
55
|
+
merged?: MergedAs;
|
|
56
|
+
// The head the PR merged or closed at.
|
|
57
|
+
prHead?: string | null;
|
|
58
|
+
// A drop of a task that never had a PR.
|
|
59
|
+
noPr?: boolean;
|
|
60
|
+
} = {}
|
|
56
61
|
): Promise<void> {
|
|
57
62
|
const keepRemoteBranch = opts.keepRemoteBranch ?? false;
|
|
58
63
|
await endSessionAgent(session);
|
|
@@ -68,14 +73,14 @@ async function cleanup(
|
|
|
68
73
|
}
|
|
69
74
|
// A stacked child's PR targets this branch: deleting it on origin would
|
|
70
75
|
// make GitHub close that PR, and a closed PR can't be retargeted.
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
);
|
|
76
|
+
if (session.branch_name && !keepRemoteBranch) {
|
|
77
|
+
await deleteTaskBranch({
|
|
78
|
+
repo,
|
|
79
|
+
branch: session.branch_name,
|
|
80
|
+
prHead: opts.prHead,
|
|
81
|
+
noPr: opts.noPr,
|
|
82
|
+
keep: [session.base_branch],
|
|
83
|
+
});
|
|
79
84
|
}
|
|
80
85
|
await gitFetch(repo, ["--prune", "--quiet"]).catch(() => {});
|
|
81
86
|
forgetPR(session.id);
|
|
@@ -112,6 +117,7 @@ async function finishMerge(
|
|
|
112
117
|
await cleanup(session, repo, {
|
|
113
118
|
keepRemoteBranch: stuck || !policy.delete_remote_branch,
|
|
114
119
|
merged: { prHead },
|
|
120
|
+
prHead,
|
|
115
121
|
});
|
|
116
122
|
// After cleanup's fetch, so the base branch holds the merged files.
|
|
117
123
|
inBackground(`re-publish docs after task ${session.id}`, () =>
|
|
@@ -257,5 +263,5 @@ export async function dropTask(id: string): Promise<void> {
|
|
|
257
263
|
).run(id);
|
|
258
264
|
syncTaskCardInBackground(session, "dropped", pr);
|
|
259
265
|
await snapshot;
|
|
260
|
-
await cleanup(session, repo);
|
|
266
|
+
await cleanup(session, repo, { prHead: pr?.head, noPr: !pr });
|
|
261
267
|
}
|
|
@@ -6,7 +6,7 @@ import fs from "fs";
|
|
|
6
6
|
import os from "os";
|
|
7
7
|
import path from "path";
|
|
8
8
|
import { randomUUID } from "crypto";
|
|
9
|
-
import { db, type Session } from "../db";
|
|
9
|
+
import { db, insertSessionRow, type Session } from "../db";
|
|
10
10
|
import { createProject } from "../projects";
|
|
11
11
|
import { writeTranscript } from "./transcript";
|
|
12
12
|
import { gitSync } from "../git-exec";
|
|
@@ -17,7 +17,7 @@ export const git = (cwd: string, ...args: string[]) =>
|
|
|
17
17
|
gitSync(cwd, args).trim();
|
|
18
18
|
|
|
19
19
|
export const row = (id: string) =>
|
|
20
|
-
db.prepare(`SELECT * FROM
|
|
20
|
+
db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as Session;
|
|
21
21
|
|
|
22
22
|
export interface MoveFixture {
|
|
23
23
|
tmp: string;
|
|
@@ -71,11 +71,20 @@ export async function seedTask(
|
|
|
71
71
|
git(f.repo, "worktree", "add", "-q", "-b", branch, cwd, "main");
|
|
72
72
|
fs.writeFileSync(path.join(cwd, "work.txt"), "half done\n");
|
|
73
73
|
const id = randomUUID();
|
|
74
|
-
db
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
insertSessionRow(db, {
|
|
75
|
+
id,
|
|
76
|
+
name: "Fix it",
|
|
77
|
+
tmux_name: `claude-${id}`,
|
|
78
|
+
working_directory: cwd,
|
|
79
|
+
project_id: f.projectId,
|
|
80
|
+
task_status: "running",
|
|
81
|
+
task_prompt: "fix the thing",
|
|
82
|
+
branch_name: branch,
|
|
83
|
+
worktree_path: cwd,
|
|
84
|
+
base_branch: "main",
|
|
85
|
+
claude_session_id: CLAUDE_ID,
|
|
86
|
+
model: "sonnet",
|
|
87
|
+
});
|
|
79
88
|
await writeTranscript(
|
|
80
89
|
cwd,
|
|
81
90
|
CLAUDE_ID,
|
package/lib/tasks/pr-activity.ts
CHANGED
|
@@ -275,9 +275,19 @@ export async function failedLog(
|
|
|
275
275
|
repo: Repo,
|
|
276
276
|
url: string | null | undefined
|
|
277
277
|
): Promise<FailedLog | null> {
|
|
278
|
+
const raw = await failedJobLog(dir, repo, url);
|
|
279
|
+
return raw === null ? null : trimLog(raw);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// The same log whole, as gh prints it.
|
|
283
|
+
export async function failedJobLog(
|
|
284
|
+
dir: string,
|
|
285
|
+
repo: Repo,
|
|
286
|
+
url: string | null | undefined
|
|
287
|
+
): Promise<string | null> {
|
|
278
288
|
const job = jobOf(url);
|
|
279
289
|
if (!job) return null;
|
|
280
|
-
|
|
290
|
+
return run(
|
|
281
291
|
"gh",
|
|
282
292
|
[
|
|
283
293
|
"run",
|
|
@@ -291,5 +301,4 @@ export async function failedLog(
|
|
|
291
301
|
dir,
|
|
292
302
|
60000
|
|
293
303
|
);
|
|
294
|
-
return trimLog(raw);
|
|
295
304
|
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// `aos branches prune`: the stale remote branches AgentOS tasks left behind.
|
|
2
|
+
// A branch is stale when it is a task's recorded branch, its latest PR is
|
|
3
|
+
// merged or closed at the branch's current commit, and no open PR builds on
|
|
4
|
+
// it. Dry run by default; deleting goes through the same checks as a merge.
|
|
5
|
+
|
|
6
|
+
import { db } from "../db";
|
|
7
|
+
import { run } from "./gh";
|
|
8
|
+
import { PROTECTED_BRANCHES, deleteTaskBranch } from "./remote-branch";
|
|
9
|
+
|
|
10
|
+
export interface PruneRepo {
|
|
11
|
+
dir: string;
|
|
12
|
+
slug: string;
|
|
13
|
+
projectIds: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StaleBranch {
|
|
17
|
+
branch: string;
|
|
18
|
+
pr: number;
|
|
19
|
+
state: "MERGED" | "CLOSED";
|
|
20
|
+
head: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RepoPrune {
|
|
24
|
+
slug: string;
|
|
25
|
+
stale: StaleBranch[];
|
|
26
|
+
kept: { branch: string; why: string }[];
|
|
27
|
+
deleted: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ListedPR {
|
|
31
|
+
number: number;
|
|
32
|
+
state: string;
|
|
33
|
+
headRefName: string;
|
|
34
|
+
headRefOid: string;
|
|
35
|
+
baseRefName: string;
|
|
36
|
+
isCrossRepository: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const taskBranches = (projectIds: string[]): Set<string> => {
|
|
40
|
+
if (!projectIds.length) return new Set();
|
|
41
|
+
const rows = db
|
|
42
|
+
.prepare(
|
|
43
|
+
`SELECT DISTINCT branch_name FROM session_rows
|
|
44
|
+
WHERE branch_name IS NOT NULL
|
|
45
|
+
AND project_id IN (${projectIds.map(() => "?").join(", ")})`
|
|
46
|
+
)
|
|
47
|
+
.all(...projectIds) as { branch_name: string }[];
|
|
48
|
+
return new Set(rows.map((r) => r.branch_name));
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
async function remoteBranches(repo: PruneRepo): Promise<Map<string, string>> {
|
|
52
|
+
const out = await run(
|
|
53
|
+
"gh",
|
|
54
|
+
[
|
|
55
|
+
"api",
|
|
56
|
+
`repos/${repo.slug}/branches`,
|
|
57
|
+
"--paginate",
|
|
58
|
+
"--jq",
|
|
59
|
+
".[] | [.name, .commit.sha] | @tsv",
|
|
60
|
+
],
|
|
61
|
+
repo.dir,
|
|
62
|
+
120000
|
|
63
|
+
);
|
|
64
|
+
const tips = new Map<string, string>();
|
|
65
|
+
for (const line of out.split("\n")) {
|
|
66
|
+
const [name, sha] = line.split("\t");
|
|
67
|
+
if (name && sha) tips.set(name, sha);
|
|
68
|
+
}
|
|
69
|
+
return tips;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function pruneRepo(
|
|
73
|
+
repo: PruneRepo,
|
|
74
|
+
opts: { yes?: boolean } = {}
|
|
75
|
+
): Promise<RepoPrune> {
|
|
76
|
+
const [tips, prsOut, defaultOut] = await Promise.all([
|
|
77
|
+
remoteBranches(repo),
|
|
78
|
+
run(
|
|
79
|
+
"gh",
|
|
80
|
+
[
|
|
81
|
+
"pr",
|
|
82
|
+
"list",
|
|
83
|
+
"--repo",
|
|
84
|
+
repo.slug,
|
|
85
|
+
"--state",
|
|
86
|
+
"all",
|
|
87
|
+
"--limit",
|
|
88
|
+
"1000",
|
|
89
|
+
"--json",
|
|
90
|
+
"number,state,headRefName,headRefOid,baseRefName,isCrossRepository",
|
|
91
|
+
],
|
|
92
|
+
repo.dir,
|
|
93
|
+
120000
|
|
94
|
+
),
|
|
95
|
+
run(
|
|
96
|
+
"gh",
|
|
97
|
+
[
|
|
98
|
+
"repo",
|
|
99
|
+
"view",
|
|
100
|
+
repo.slug,
|
|
101
|
+
"--json",
|
|
102
|
+
"defaultBranchRef",
|
|
103
|
+
"--jq",
|
|
104
|
+
".defaultBranchRef.name",
|
|
105
|
+
],
|
|
106
|
+
repo.dir,
|
|
107
|
+
20000
|
|
108
|
+
),
|
|
109
|
+
]);
|
|
110
|
+
const prs = JSON.parse(prsOut) as ListedPR[];
|
|
111
|
+
const defaultBranch = defaultOut.trim();
|
|
112
|
+
const mine = taskBranches(repo.projectIds);
|
|
113
|
+
const result: RepoPrune = {
|
|
114
|
+
slug: repo.slug,
|
|
115
|
+
stale: [],
|
|
116
|
+
kept: [],
|
|
117
|
+
deleted: [],
|
|
118
|
+
};
|
|
119
|
+
const keep = (branch: string, why: string) =>
|
|
120
|
+
result.kept.push({ branch, why });
|
|
121
|
+
|
|
122
|
+
for (const [branch, tip] of [...tips].sort()) {
|
|
123
|
+
if (branch === defaultBranch || PROTECTED_BRANCHES.has(branch)) continue;
|
|
124
|
+
if (!mine.has(branch)) {
|
|
125
|
+
keep(branch, "not an AgentOS task's branch");
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const own = prs
|
|
129
|
+
.filter((p) => p.headRefName === branch && !p.isCrossRepository)
|
|
130
|
+
.sort((a, b) => b.number - a.number);
|
|
131
|
+
if (!own.length) {
|
|
132
|
+
keep(branch, "no PR");
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const open = prs.find(
|
|
136
|
+
(p) =>
|
|
137
|
+
p.state === "OPEN" &&
|
|
138
|
+
(p.baseRefName === branch ||
|
|
139
|
+
(p.headRefName === branch && !p.isCrossRepository))
|
|
140
|
+
);
|
|
141
|
+
if (open) {
|
|
142
|
+
keep(branch, `open PR #${open.number}`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const latest = own[0];
|
|
146
|
+
if (latest.headRefOid !== tip) {
|
|
147
|
+
keep(branch, `moved to ${tip.slice(0, 7)} after PR #${latest.number}`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
result.stale.push({
|
|
151
|
+
branch,
|
|
152
|
+
pr: latest.number,
|
|
153
|
+
state: latest.state as StaleBranch["state"],
|
|
154
|
+
head: latest.headRefOid,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (opts.yes)
|
|
159
|
+
for (const s of result.stale) {
|
|
160
|
+
const out = await deleteTaskBranch({
|
|
161
|
+
repo: repo.dir,
|
|
162
|
+
slug: repo.slug,
|
|
163
|
+
branch: s.branch,
|
|
164
|
+
prHead: s.head,
|
|
165
|
+
keep: [defaultBranch],
|
|
166
|
+
});
|
|
167
|
+
if (out.deleted) result.deleted.push(s.branch);
|
|
168
|
+
else keep(s.branch, out.why);
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function pruneText(results: RepoPrune[], yes: boolean): string {
|
|
174
|
+
const lines: string[] = [];
|
|
175
|
+
let total = 0;
|
|
176
|
+
for (const r of results) {
|
|
177
|
+
lines.push(`${r.slug}:`);
|
|
178
|
+
const list = yes ? r.deleted : r.stale.map((s) => s.branch);
|
|
179
|
+
total += list.length;
|
|
180
|
+
for (const s of r.stale)
|
|
181
|
+
if (!yes || r.deleted.includes(s.branch))
|
|
182
|
+
lines.push(
|
|
183
|
+
` ${yes ? "deleted" : "stale"} ${s.branch} (PR #${s.pr} ${s.state.toLowerCase()})`
|
|
184
|
+
);
|
|
185
|
+
for (const k of r.kept) lines.push(` kept ${k.branch} (${k.why})`);
|
|
186
|
+
}
|
|
187
|
+
lines.push(
|
|
188
|
+
yes
|
|
189
|
+
? `Deleted ${total} branch${total === 1 ? "" : "es"}.`
|
|
190
|
+
: `${total} stale branch${total === 1 ? "" : "es"}. Dry run: aos branches prune --yes deletes them.`
|
|
191
|
+
);
|
|
192
|
+
return lines.join("\n");
|
|
193
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getProject } from "../projects";
|
|
2
|
+
import { repoSlug } from "../git-remote";
|
|
3
|
+
import { scopeProjectIds, type DoneScope } from "../done/bulk";
|
|
4
|
+
import { expandHome } from "./session";
|
|
5
|
+
import type { PruneRepo } from "./prune-branches";
|
|
6
|
+
|
|
7
|
+
// The scope's local GitHub clones, one entry per repository (projects that
|
|
8
|
+
// share one are merged, so its branches are listed once).
|
|
9
|
+
export async function prunableRepos(scope: DoneScope): Promise<PruneRepo[]> {
|
|
10
|
+
const bySlug = new Map<string, PruneRepo>();
|
|
11
|
+
for (const id of scopeProjectIds(scope)) {
|
|
12
|
+
const p = getProject(id);
|
|
13
|
+
if (!p || (p.host_id && p.host_id !== "local")) continue;
|
|
14
|
+
const dir = expandHome(p.working_directory);
|
|
15
|
+
const slug = await repoSlug(dir);
|
|
16
|
+
if (!slug) continue;
|
|
17
|
+
const hit = bySlug.get(slug);
|
|
18
|
+
if (hit) hit.projectIds.push(id);
|
|
19
|
+
else bySlug.set(slug, { dir, slug, projectIds: [id] });
|
|
20
|
+
}
|
|
21
|
+
return [...bySlug.values()];
|
|
22
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Deleting a task's own branch on GitHub once its PR is merged or closed.
|
|
2
|
+
// Never throws: a branch that stays is a logged line, not a failed merge.
|
|
3
|
+
|
|
4
|
+
import { git, gitPush } from "../git-exec";
|
|
5
|
+
import { repoSlug } from "../git-remote";
|
|
6
|
+
import { run } from "./gh";
|
|
7
|
+
|
|
8
|
+
// Never deleted, whatever a setting says.
|
|
9
|
+
export const PROTECTED_BRANCHES = new Set(["main", "master", "HEAD"]);
|
|
10
|
+
|
|
11
|
+
export interface BranchDelete {
|
|
12
|
+
deleted: boolean;
|
|
13
|
+
why: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DeleteBranchOpts {
|
|
17
|
+
repo: string;
|
|
18
|
+
branch: string;
|
|
19
|
+
// The head the PR merged or closed at; the branch goes only if it is still
|
|
20
|
+
// exactly this commit.
|
|
21
|
+
prHead: string | null | undefined;
|
|
22
|
+
// A dropped task that never opened a PR has no head to compare with: its
|
|
23
|
+
// branch is rejected work and goes whatever it holds.
|
|
24
|
+
noPr?: boolean;
|
|
25
|
+
// owner/repo, when known; read from the checkout's origin otherwise.
|
|
26
|
+
slug?: string | null;
|
|
27
|
+
// Another open PR in the repo builds on this branch (decided by the caller
|
|
28
|
+
// when it knows from its own records; GitHub is asked too).
|
|
29
|
+
hasChildren?: boolean;
|
|
30
|
+
// Branches this one must never be (the default and base branches).
|
|
31
|
+
keep?: (string | null | undefined)[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const gone = (error: unknown) =>
|
|
35
|
+
/HTTP (404|422)|Not Found|Reference does not exist/i.test(
|
|
36
|
+
error instanceof Error ? error.message : String(error)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// repos/o/r/git/ref(s)/heads/<branch>, each path segment encoded.
|
|
40
|
+
export const refPath = (slug: string, branch: string, plural: boolean) =>
|
|
41
|
+
`repos/${slug}/git/${plural ? "refs" : "ref"}/heads/${branch
|
|
42
|
+
.split("/")
|
|
43
|
+
.map(encodeURIComponent)
|
|
44
|
+
.join("/")}`;
|
|
45
|
+
|
|
46
|
+
// The branch's commit on GitHub; null when it is gone.
|
|
47
|
+
export async function remoteTip(
|
|
48
|
+
repo: string,
|
|
49
|
+
slug: string,
|
|
50
|
+
branch: string
|
|
51
|
+
): Promise<string | null> {
|
|
52
|
+
try {
|
|
53
|
+
const out = await run(
|
|
54
|
+
"gh",
|
|
55
|
+
["api", refPath(slug, branch, false), "--jq", ".object.sha"],
|
|
56
|
+
repo,
|
|
57
|
+
20000
|
|
58
|
+
);
|
|
59
|
+
return out.trim() || null;
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (gone(error)) return null;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Open PRs targeting the branch: a stacked child, which GitHub would close
|
|
67
|
+
// (and could no longer retarget) if its base vanished.
|
|
68
|
+
export async function openChildren(
|
|
69
|
+
repo: string,
|
|
70
|
+
slug: string,
|
|
71
|
+
branch: string
|
|
72
|
+
): Promise<number[]> {
|
|
73
|
+
const out = await run(
|
|
74
|
+
"gh",
|
|
75
|
+
[
|
|
76
|
+
"pr",
|
|
77
|
+
"list",
|
|
78
|
+
"--repo",
|
|
79
|
+
slug,
|
|
80
|
+
"--base",
|
|
81
|
+
branch,
|
|
82
|
+
"--state",
|
|
83
|
+
"open",
|
|
84
|
+
"--json",
|
|
85
|
+
"number",
|
|
86
|
+
],
|
|
87
|
+
repo,
|
|
88
|
+
20000
|
|
89
|
+
);
|
|
90
|
+
return (JSON.parse(out) as { number: number }[]).map((p) => p.number);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function decide(o: DeleteBranchOpts): Promise<BranchDelete> {
|
|
94
|
+
const { repo, branch, prHead } = o;
|
|
95
|
+
if (PROTECTED_BRANCHES.has(branch))
|
|
96
|
+
return { deleted: false, why: "a protected branch" };
|
|
97
|
+
if (o.keep?.includes(branch))
|
|
98
|
+
return { deleted: false, why: "it is a base branch" };
|
|
99
|
+
if (!prHead && !o.noPr)
|
|
100
|
+
return { deleted: false, why: "no PR head was recorded" };
|
|
101
|
+
if (o.hasChildren)
|
|
102
|
+
return { deleted: false, why: "stacked cards still build on it" };
|
|
103
|
+
const slug = o.slug ?? (await repoSlug(repo));
|
|
104
|
+
if (!slug) return deleteViaGit(repo, branch, prHead || null);
|
|
105
|
+
|
|
106
|
+
const kids = await openChildren(repo, slug, branch);
|
|
107
|
+
if (kids.length)
|
|
108
|
+
return {
|
|
109
|
+
deleted: false,
|
|
110
|
+
why: `open PR ${kids.map((n) => `#${n}`).join(", ")} is based on it`,
|
|
111
|
+
};
|
|
112
|
+
const tip = await remoteTip(repo, slug, branch);
|
|
113
|
+
if (!tip) return { deleted: false, why: "already gone" };
|
|
114
|
+
if (prHead && tip !== prHead)
|
|
115
|
+
return {
|
|
116
|
+
deleted: false,
|
|
117
|
+
why: `it moved to ${tip.slice(0, 7)} after the PR's ${prHead.slice(0, 7)}`,
|
|
118
|
+
};
|
|
119
|
+
try {
|
|
120
|
+
await run(
|
|
121
|
+
"gh",
|
|
122
|
+
["api", "-X", "DELETE", refPath(slug, branch, true)],
|
|
123
|
+
repo,
|
|
124
|
+
30000
|
|
125
|
+
);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (!gone(error)) throw error;
|
|
128
|
+
}
|
|
129
|
+
return { deleted: true, why: "deleted" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A checkout whose origin isn't on GitHub: ask and delete through git.
|
|
133
|
+
async function deleteViaGit(
|
|
134
|
+
repo: string,
|
|
135
|
+
branch: string,
|
|
136
|
+
prHead: string | null
|
|
137
|
+
): Promise<BranchDelete> {
|
|
138
|
+
const line = await git(
|
|
139
|
+
repo,
|
|
140
|
+
["ls-remote", "--heads", "origin", `refs/heads/${branch}`],
|
|
141
|
+
{ timeout: 30000 }
|
|
142
|
+
);
|
|
143
|
+
const tip = line.trim().split(/\s+/)[0];
|
|
144
|
+
if (!tip) return { deleted: false, why: "already gone" };
|
|
145
|
+
if (prHead && tip !== prHead)
|
|
146
|
+
return {
|
|
147
|
+
deleted: false,
|
|
148
|
+
why: `it moved to ${tip.slice(0, 7)} after the PR's ${prHead.slice(0, 7)}`,
|
|
149
|
+
};
|
|
150
|
+
await gitPush(repo, ["origin", "--delete", branch], { timeout: 60000 });
|
|
151
|
+
return { deleted: true, why: "deleted" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function deleteTaskBranch(
|
|
155
|
+
o: DeleteBranchOpts
|
|
156
|
+
): Promise<BranchDelete> {
|
|
157
|
+
let result: BranchDelete;
|
|
158
|
+
try {
|
|
159
|
+
result = await decide(o);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
162
|
+
result = {
|
|
163
|
+
deleted: false,
|
|
164
|
+
why: `couldn't delete it: ${text.split("\n")[0].slice(0, 200)}`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (!result.deleted)
|
|
168
|
+
console.log(`[tasks] kept origin/${o.branch}: ${result.why}`);
|
|
169
|
+
return result;
|
|
170
|
+
}
|
package/lib/tasks/testing.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Test fixtures for task PR lookups: real repositories and worktrees, with
|
|
2
2
|
// gh faked by branch.
|
|
3
3
|
|
|
4
|
-
import { db, type Session } from "../db";
|
|
4
|
+
import { db, insertSessionRow, type Session } from "../db";
|
|
5
5
|
import { createProject } from "../projects";
|
|
6
6
|
import type { FindPROpts } from "./gh";
|
|
7
7
|
import type { TaskPR } from "./state";
|
|
@@ -53,13 +53,19 @@ export function seedTask(
|
|
|
53
53
|
workingDirectory: repo,
|
|
54
54
|
});
|
|
55
55
|
const id = crypto.randomUUID();
|
|
56
|
-
db
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
insertSessionRow(db, {
|
|
57
|
+
id,
|
|
58
|
+
name: "Read and execute the brief",
|
|
59
|
+
tmux_name: `claude-${id}`,
|
|
60
|
+
working_directory: worktree,
|
|
61
|
+
project_id: project.id,
|
|
62
|
+
task_status: "running",
|
|
63
|
+
branch_name: branch,
|
|
64
|
+
base_branch: "main",
|
|
65
|
+
worktree_path: worktree,
|
|
66
|
+
});
|
|
61
67
|
return taskRow(id);
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
export const taskRow = (id: string) =>
|
|
65
|
-
db.prepare(`SELECT * FROM
|
|
71
|
+
db.prepare(`SELECT * FROM session_rows WHERE id = ?`).get(id) as Session;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getDefaultBranch } from "../git";
|
|
2
|
+
import { git as runGit } from "../git-exec";
|
|
3
|
+
|
|
4
|
+
const git = (cwd: string, ...args: string[]) =>
|
|
5
|
+
runGit(cwd, args, { timeout: 60_000 }).then((out) => out.trim());
|
|
6
|
+
|
|
7
|
+
const lines = (out: string) => out.split("\n").filter(Boolean);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Commits on the worktree's HEAD that nothing else holds: not on any remote
|
|
11
|
+
* branch, not in the PR a merge took, and with no patch-equivalent commit on
|
|
12
|
+
* the base or its own remote branch (a rewritten history re-lands the same
|
|
13
|
+
* changes under new hashes). The shas, so a caller can count and name them.
|
|
14
|
+
*/
|
|
15
|
+
export async function unpushedCommits(
|
|
16
|
+
worktree: string,
|
|
17
|
+
repo: string,
|
|
18
|
+
opts: { prHead?: string | null; branch?: string | null } = {}
|
|
19
|
+
): Promise<string[]> {
|
|
20
|
+
const head =
|
|
21
|
+
opts.prHead &&
|
|
22
|
+
(await git(
|
|
23
|
+
worktree,
|
|
24
|
+
"rev-parse",
|
|
25
|
+
"--verify",
|
|
26
|
+
"--quiet",
|
|
27
|
+
`${opts.prHead}^{commit}`
|
|
28
|
+
)
|
|
29
|
+
.then(() => [opts.prHead!])
|
|
30
|
+
.catch(() => []));
|
|
31
|
+
const stray = lines(
|
|
32
|
+
await git(
|
|
33
|
+
worktree,
|
|
34
|
+
"rev-list",
|
|
35
|
+
"HEAD",
|
|
36
|
+
"--not",
|
|
37
|
+
...(head || []),
|
|
38
|
+
"--remotes"
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
if (!stray.length) return [];
|
|
42
|
+
const base = await getDefaultBranch(repo).catch(() => "main");
|
|
43
|
+
const upstreams: string[] = [];
|
|
44
|
+
for (const ref of [
|
|
45
|
+
`origin/${base}`,
|
|
46
|
+
opts.branch && `origin/${opts.branch}`,
|
|
47
|
+
]) {
|
|
48
|
+
if (
|
|
49
|
+
ref &&
|
|
50
|
+
(await git(worktree, "rev-parse", "--verify", "--quiet", ref)
|
|
51
|
+
.then(() => true)
|
|
52
|
+
.catch(() => false))
|
|
53
|
+
)
|
|
54
|
+
upstreams.push(ref);
|
|
55
|
+
}
|
|
56
|
+
const left = new Set(stray);
|
|
57
|
+
for (const ref of upstreams) {
|
|
58
|
+
if (!left.size) break;
|
|
59
|
+
// "- <sha>" is a commit the upstream already has the same patch for.
|
|
60
|
+
const cherry = lines(await git(worktree, "cherry", ref, "HEAD"));
|
|
61
|
+
for (const l of cherry)
|
|
62
|
+
if (l.startsWith("- ")) left.delete(l.slice(2).trim());
|
|
63
|
+
}
|
|
64
|
+
return [...left];
|
|
65
|
+
}
|