@nurix/nustack 0.10.0-dev.19 → 0.10.0-dev.20
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/dist/commands/review.js +21 -7
- package/dist/commands/sessions.js +137 -1
- package/dist/index.js +14 -1
- package/dist/lib/session-engine/frames.js +5 -1
- package/dist/lib/session-engine/prune.js +233 -0
- package/dist/lib/session-engine/registry.js +83 -0
- package/dist/lib/session-engine/supervisor.js +87 -20
- package/dist/lib/session-engine/worktree.js +28 -5
- package/package.json +1 -1
package/dist/commands/review.js
CHANGED
|
@@ -8,16 +8,30 @@ const EXIT_FOR = {
|
|
|
8
8
|
none: 1,
|
|
9
9
|
unreachable: 2,
|
|
10
10
|
};
|
|
11
|
+
const latestAnswered = (items) => items.find((item) => item.answer?.verdict === "signed" || item.answer?.verdict === "refused");
|
|
11
12
|
export function decisionStateOf(items) {
|
|
12
|
-
const latest = items
|
|
13
|
+
const latest = latestAnswered(items);
|
|
13
14
|
if (!latest)
|
|
14
15
|
return "none";
|
|
15
16
|
return latest.answer?.verdict === "signed" ? "signed" : "refused";
|
|
16
17
|
}
|
|
17
|
-
function
|
|
18
|
+
export function waivedKeysOf(items) {
|
|
19
|
+
const latest = latestAnswered(items);
|
|
20
|
+
if (latest?.answer?.verdict !== "signed")
|
|
21
|
+
return [];
|
|
22
|
+
const waivers = latest.answer.waivers;
|
|
23
|
+
if (!Array.isArray(waivers))
|
|
24
|
+
return [];
|
|
25
|
+
return waivers
|
|
26
|
+
.map((waiver) => waiver?.criterionKey)
|
|
27
|
+
.filter((key) => typeof key === "string" && key.length > 0);
|
|
28
|
+
}
|
|
29
|
+
function checkLine(state, commit, waived) {
|
|
18
30
|
switch (state) {
|
|
19
31
|
case "signed":
|
|
20
|
-
return
|
|
32
|
+
return waived.length
|
|
33
|
+
? `review check: ${commit} is signed off — ${waived.length} ${waived.length === 1 ? "criterion" : "criteria"} waived: ${waived.join(", ")}\n`
|
|
34
|
+
: `review check: ${commit} is signed off\n`;
|
|
21
35
|
case "refused":
|
|
22
36
|
return `review check: ${commit} was refused\n`;
|
|
23
37
|
case "none":
|
|
@@ -34,11 +48,11 @@ export async function runReviewCheck(options, globals) {
|
|
|
34
48
|
process.exitCode = 2;
|
|
35
49
|
return;
|
|
36
50
|
}
|
|
37
|
-
const report = (state) => {
|
|
51
|
+
const report = (state, waived = []) => {
|
|
38
52
|
if (json)
|
|
39
|
-
printJsonSuccess("review check", { commit, state, exitCode: EXIT_FOR[state] });
|
|
53
|
+
printJsonSuccess("review check", { commit, state, exitCode: EXIT_FOR[state], waived });
|
|
40
54
|
else
|
|
41
|
-
process.stdout.write(checkLine(state, commit));
|
|
55
|
+
process.stdout.write(checkLine(state, commit, waived));
|
|
42
56
|
process.exitCode = EXIT_FOR[state];
|
|
43
57
|
};
|
|
44
58
|
const baseUrl = resolveNuStackUrl(globals);
|
|
@@ -47,7 +61,7 @@ export async function runReviewCheck(options, globals) {
|
|
|
47
61
|
return report("unreachable");
|
|
48
62
|
try {
|
|
49
63
|
const items = await makeReviewClient(baseUrl, deviceAuth.accessToken).readDecisions(commit, options.repository?.trim() || null);
|
|
50
|
-
report(decisionStateOf(items));
|
|
64
|
+
report(decisionStateOf(items), waivedKeysOf(items));
|
|
51
65
|
}
|
|
52
66
|
catch (error) {
|
|
53
67
|
if (error instanceof ReviewUnreachableError)
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import * as p from "@clack/prompts";
|
|
2
4
|
import { ensureAccessToken } from "../lib/auth_grant.js";
|
|
5
|
+
import { isInteractive } from "../lib/interactive.js";
|
|
3
6
|
import { resolveNuStackUrl } from "../lib/nustack_client.js";
|
|
4
7
|
import { makeSessionsClient, sessionsClientFromEnv } from "../lib/sessions/client.js";
|
|
5
8
|
import { recordOutsideSessions, } from "../lib/sessions/outsideSessions.js";
|
|
9
|
+
import { inspectSession, readOrphanedWorktrees, reclaimSession, } from "../lib/session-engine/prune.js";
|
|
10
|
+
import { readSessionRegistry, sessionRegistryPath } from "../lib/session-engine/registry.js";
|
|
6
11
|
import { SessionSupervisor } from "../lib/session-engine/supervisor.js";
|
|
7
|
-
import { printJsonSuccess } from "../lib/json_output.js";
|
|
12
|
+
import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
|
|
8
13
|
export async function runSessionsServe(engineVersion) {
|
|
9
14
|
const supervisor = new SessionSupervisor({
|
|
10
15
|
input: process.stdin,
|
|
@@ -75,3 +80,134 @@ export async function runSessionsRecord(options, globals) {
|
|
|
75
80
|
});
|
|
76
81
|
report("sessions record", summary, json);
|
|
77
82
|
}
|
|
83
|
+
async function readSessions() {
|
|
84
|
+
const { records, dropped } = await readSessionRegistry();
|
|
85
|
+
const orphans = await readOrphanedWorktrees();
|
|
86
|
+
const sessions = [];
|
|
87
|
+
for (const record of [...records, ...orphans.records]) {
|
|
88
|
+
sessions.push({ record, facts: await inspectSession(record) });
|
|
89
|
+
}
|
|
90
|
+
return { sessions, unreadable: dropped, undecipherableOrphans: orphans.undecipherable };
|
|
91
|
+
}
|
|
92
|
+
function statusWord(entry) {
|
|
93
|
+
if (entry.facts.orphaned)
|
|
94
|
+
return "orphaned";
|
|
95
|
+
if (entry.facts.stale)
|
|
96
|
+
return "stale";
|
|
97
|
+
return entry.record.status;
|
|
98
|
+
}
|
|
99
|
+
function formatSessionRow(entry) {
|
|
100
|
+
const checkout = path.basename(entry.record.projectPath);
|
|
101
|
+
return (`${entry.record.sessionId.padEnd(38)} ${checkout.padEnd(22)} ${(entry.record.branch ?? "—").padEnd(31)} ` +
|
|
102
|
+
`${statusWord(entry).padEnd(10)} ${entry.facts.reason}`);
|
|
103
|
+
}
|
|
104
|
+
function toJsonView(entry) {
|
|
105
|
+
const { record, facts } = entry;
|
|
106
|
+
return {
|
|
107
|
+
sessionId: record.sessionId,
|
|
108
|
+
projectPath: record.projectPath,
|
|
109
|
+
worktreePath: record.worktreePath,
|
|
110
|
+
branch: record.branch,
|
|
111
|
+
status: record.status,
|
|
112
|
+
nustack: record.nustack,
|
|
113
|
+
createdAt: record.createdAt,
|
|
114
|
+
closedAt: record.closedAt ?? null,
|
|
115
|
+
permissionMode: record.permissionMode ?? null,
|
|
116
|
+
baseBranch: record.baseBranch ?? null,
|
|
117
|
+
isolated: facts.isolated,
|
|
118
|
+
checkoutPresent: facts.checkoutPresent,
|
|
119
|
+
worktreePresent: facts.worktreePresent,
|
|
120
|
+
branchExists: facts.branchExists,
|
|
121
|
+
reading: facts.reading,
|
|
122
|
+
dirty: facts.dirty,
|
|
123
|
+
stale: facts.stale,
|
|
124
|
+
orphaned: facts.orphaned,
|
|
125
|
+
reclaimable: facts.reclaimable,
|
|
126
|
+
reason: facts.reason,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
export async function runSessionsList(options) {
|
|
130
|
+
const json = options.json === true;
|
|
131
|
+
const { sessions, unreadable, undecipherableOrphans } = await readSessions();
|
|
132
|
+
if (json) {
|
|
133
|
+
printJsonSuccess("sessions list", {
|
|
134
|
+
registryPath: sessionRegistryPath(),
|
|
135
|
+
unreadable,
|
|
136
|
+
undecipherableOrphans,
|
|
137
|
+
sessions: sessions.map(toJsonView),
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (sessions.length === 0 && unreadable === 0 && undecipherableOrphans.length === 0) {
|
|
142
|
+
console.log("No engine-opened sessions on this machine.");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (sessions.length > 0) {
|
|
146
|
+
console.log(`${"SESSION".padEnd(38)} ${"CHECKOUT".padEnd(22)} ${"BRANCH".padEnd(31)} ${"STATUS".padEnd(10)} HOLDS`);
|
|
147
|
+
for (const entry of sessions)
|
|
148
|
+
console.log(formatSessionRow(entry));
|
|
149
|
+
}
|
|
150
|
+
if (unreadable > 0)
|
|
151
|
+
console.log(`${unreadable} row(s) in the registry could not be read and were skipped.`);
|
|
152
|
+
for (const orphan of undecipherableOrphans) {
|
|
153
|
+
console.log(`1 worktree under the pre-rename engine home names no checkout and must be removed by hand: ${orphan}.`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function previewLine(entry) {
|
|
157
|
+
const verb = entry.facts.reclaimable ? "reclaim" : "refuse";
|
|
158
|
+
return ` ${verb} ${entry.record.sessionId} ${entry.record.branch ?? "—"} — ${entry.facts.reason}`;
|
|
159
|
+
}
|
|
160
|
+
export async function runSessionsPrune(options) {
|
|
161
|
+
const json = options.json === true;
|
|
162
|
+
const force = options.force === true;
|
|
163
|
+
const { sessions } = await readSessions();
|
|
164
|
+
const scoped = options.session ? sessions.filter((entry) => entry.record.sessionId === options.session) : sessions;
|
|
165
|
+
if (options.session && scoped.length === 0) {
|
|
166
|
+
const message = `No session ${options.session} in this machine's registry — pass one from \`nustack sessions list\`.`;
|
|
167
|
+
if (json)
|
|
168
|
+
return printJsonError("sessions prune", { code: "SESSION_NOT_FOUND", message });
|
|
169
|
+
p.log.error(message);
|
|
170
|
+
process.exitCode = 1;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const candidates = scoped.filter((entry) => force || entry.facts.reclaimable);
|
|
174
|
+
if (options.yes !== true) {
|
|
175
|
+
const plan = candidates.map((entry) => ({
|
|
176
|
+
sessionId: entry.record.sessionId,
|
|
177
|
+
branch: entry.record.branch,
|
|
178
|
+
worktreePath: entry.record.worktreePath,
|
|
179
|
+
orphaned: entry.facts.orphaned,
|
|
180
|
+
reclaimable: entry.facts.reclaimable,
|
|
181
|
+
reason: entry.facts.reason,
|
|
182
|
+
}));
|
|
183
|
+
if (json)
|
|
184
|
+
return printJsonSuccess("sessions prune", { applied: false, plan, note: "preview only — pass --yes to apply" });
|
|
185
|
+
if (plan.length === 0) {
|
|
186
|
+
console.log("Nothing to reclaim.");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
console.log(`Would reclaim ${plan.length} session(s):`);
|
|
190
|
+
for (const entry of candidates)
|
|
191
|
+
console.log(previewLine(entry));
|
|
192
|
+
if (!isInteractive()) {
|
|
193
|
+
console.log("Preview only — re-run with --yes to apply.");
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const answer = await p.confirm({ message: "Remove these worktrees and delete their branches?" });
|
|
197
|
+
if (p.isCancel(answer) || !answer) {
|
|
198
|
+
p.outro("Cancelled — nothing reclaimed.");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const reclaimed = [];
|
|
203
|
+
const refused = [];
|
|
204
|
+
for (const entry of candidates) {
|
|
205
|
+
const outcome = await reclaimSession(entry.record, { force });
|
|
206
|
+
(outcome.reason === null ? reclaimed : refused).push(outcome);
|
|
207
|
+
}
|
|
208
|
+
if (json)
|
|
209
|
+
return printJsonSuccess("sessions prune", { applied: true, reclaimed, refused });
|
|
210
|
+
console.log(`Reclaimed ${reclaimed.length} session(s); refused ${refused.length}.`);
|
|
211
|
+
for (const outcome of refused)
|
|
212
|
+
console.log(` refused ${outcome.sessionId} — ${outcome.reason ?? "unknown"}`);
|
|
213
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -24,7 +24,7 @@ import { runOrganizationList, runOrganizationCreate, runOrganizationUse } from "
|
|
|
24
24
|
import { runDocsList, runDocsShow, runDocsStatus } from "./commands/docs.js";
|
|
25
25
|
import { runHandoffList, runHandoffShow } from "./commands/handoff.js";
|
|
26
26
|
import { runToolkitStatus, runToolkitReconcile } from "./commands/toolkit.js";
|
|
27
|
-
import { runSessionsRecord, runSessionsServe } from "./commands/sessions.js";
|
|
27
|
+
import { runSessionsList, runSessionsPrune, runSessionsRecord, runSessionsServe, } from "./commands/sessions.js";
|
|
28
28
|
import { registerReviewCommands } from "./commands/review.js";
|
|
29
29
|
import { runAgentContext, runAgentHookInstall, runAgentHookRemove } from "./commands/agent.js";
|
|
30
30
|
import { printJsonError } from "./lib/json_output.js";
|
|
@@ -262,6 +262,19 @@ sessions
|
|
|
262
262
|
.option("--scan <json>", "the scan payload as JSON, or @<path> to read it from a file")
|
|
263
263
|
.option("--json", "emit the versioned machine envelope")
|
|
264
264
|
.action(async (options) => runSessionsRecord(options, program.opts()));
|
|
265
|
+
sessions
|
|
266
|
+
.command("list")
|
|
267
|
+
.description("List the sessions this machine's engine opened, and what each one's worktree and branch still hold")
|
|
268
|
+
.option("--json", "emit the versioned machine envelope")
|
|
269
|
+
.action(async (options) => runSessionsList(options));
|
|
270
|
+
sessions
|
|
271
|
+
.command("prune")
|
|
272
|
+
.description("Reclaim the worktree and branch of a session that has ended (preview unless --yes)")
|
|
273
|
+
.option("--session <id>", "reclaim one session instead of every reclaimable one")
|
|
274
|
+
.option("--yes", "apply the preview without prompting")
|
|
275
|
+
.option("--force", "also reclaim an unmerged or dirty session — never one whose engine is running")
|
|
276
|
+
.option("--json", "emit the versioned machine envelope")
|
|
277
|
+
.action(async (options) => runSessionsPrune(options));
|
|
265
278
|
const toolkit = program.command("toolkit").description("Inspect and reconcile the Project's toolkit pin");
|
|
266
279
|
toolkit
|
|
267
280
|
.command("status")
|
|
@@ -5,6 +5,8 @@ const INBOUND_TYPES = new Set([
|
|
|
5
5
|
"session.approval",
|
|
6
6
|
"session.interrupt",
|
|
7
7
|
"session.close",
|
|
8
|
+
"session.list",
|
|
9
|
+
"session.reclaim",
|
|
8
10
|
"engine.shutdown",
|
|
9
11
|
]);
|
|
10
12
|
export function parseInboundFrame(line) {
|
|
@@ -31,6 +33,7 @@ export function parseInboundFrame(line) {
|
|
|
31
33
|
const requireString = (key) => (typeof frame[key] === "string" && frame[key] !== "" ? frame[key] : null);
|
|
32
34
|
switch (type) {
|
|
33
35
|
case "engine.shutdown":
|
|
36
|
+
case "session.list":
|
|
34
37
|
return { ok: true, frame: { v: FRAME_VERSION, type } };
|
|
35
38
|
case "session.open": {
|
|
36
39
|
const sessionId = requireString("sessionId");
|
|
@@ -58,7 +61,8 @@ export function parseInboundFrame(line) {
|
|
|
58
61
|
return { ok: true, frame: { v: FRAME_VERSION, type, sessionId, requestId, verdict } };
|
|
59
62
|
}
|
|
60
63
|
case "session.interrupt":
|
|
61
|
-
case "session.close":
|
|
64
|
+
case "session.close":
|
|
65
|
+
case "session.reclaim": {
|
|
62
66
|
const sessionId = requireString("sessionId");
|
|
63
67
|
if (!sessionId)
|
|
64
68
|
return { ok: false, error: { code: "BAD_FRAME", name: type, message: `${type} requires sessionId` } };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { updateSessionRegistry } from "./registry.js";
|
|
4
|
+
import { ENGINE_SESSION_BRANCH_PREFIX, LEGACY_ENGINE_BRANCH_PREFIX, gitOutcome, legacyEngineHome, sessionsHome, } from "./worktree.js";
|
|
5
|
+
const NOT_ISOLATED = "the session ran in the checkout itself — there is nothing of its own to reclaim";
|
|
6
|
+
const FOREIGN_BRANCH = "its branch is not an engine session branch — this row is not the engine's to reclaim";
|
|
7
|
+
const FOREIGN_WORKTREE = "its worktree is outside the engine's worktrees directory — this row is not the engine's to reclaim";
|
|
8
|
+
function insideAnEngineHome(worktreePath, env) {
|
|
9
|
+
const live = path.join(sessionsHome(env), "worktrees") + path.sep;
|
|
10
|
+
const legacy = path.join(legacyEngineHome(env), "worktrees") + path.sep;
|
|
11
|
+
return worktreePath.startsWith(live) || worktreePath.startsWith(legacy);
|
|
12
|
+
}
|
|
13
|
+
function isEngineBranch(branch) {
|
|
14
|
+
return branch !== null && (branch.startsWith(ENGINE_SESSION_BRANCH_PREFIX) || branch.startsWith(LEGACY_ENGINE_BRANCH_PREFIX));
|
|
15
|
+
}
|
|
16
|
+
async function isDirectory(target) {
|
|
17
|
+
try {
|
|
18
|
+
return (await stat(target)).isDirectory();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function refuse(record, facts, reason) {
|
|
25
|
+
return {
|
|
26
|
+
sessionId: record.sessionId,
|
|
27
|
+
isolated: false,
|
|
28
|
+
checkoutPresent: false,
|
|
29
|
+
worktreePresent: false,
|
|
30
|
+
branchExists: false,
|
|
31
|
+
reading: "unknown",
|
|
32
|
+
dirty: false,
|
|
33
|
+
stale: false,
|
|
34
|
+
orphaned: false,
|
|
35
|
+
reclaimable: false,
|
|
36
|
+
...facts,
|
|
37
|
+
reason,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function processIsGone(pid) {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 0);
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return error.code === "ESRCH";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function inspectSession(record, env = process.env) {
|
|
50
|
+
const orphaned = record.worktreePath.startsWith(path.join(legacyEngineHome(env), "worktrees") + path.sep);
|
|
51
|
+
const checkoutPresent = await isDirectory(record.projectPath);
|
|
52
|
+
const worktreePresent = await isDirectory(record.worktreePath);
|
|
53
|
+
const isolated = record.branch !== null && record.worktreePath !== record.projectPath;
|
|
54
|
+
if (!isolated)
|
|
55
|
+
return refuse(record, { checkoutPresent, orphaned }, NOT_ISOLATED);
|
|
56
|
+
if (!isEngineBranch(record.branch))
|
|
57
|
+
return refuse(record, { isolated, checkoutPresent, orphaned }, FOREIGN_BRANCH);
|
|
58
|
+
if (!insideAnEngineHome(record.worktreePath, env)) {
|
|
59
|
+
return refuse(record, { isolated, checkoutPresent, orphaned }, FOREIGN_WORKTREE);
|
|
60
|
+
}
|
|
61
|
+
if (!checkoutPresent) {
|
|
62
|
+
return refuse(record, { isolated, worktreePresent, orphaned }, `its checkout is gone — remove ${record.worktreePath} and the branch by hand`);
|
|
63
|
+
}
|
|
64
|
+
const branch = record.branch;
|
|
65
|
+
const branchExists = (await gitOutcome(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], record.projectPath)).ok;
|
|
66
|
+
const dirty = worktreePresent && (await gitOutcome(["status", "--porcelain"], record.worktreePath)).stdout !== "";
|
|
67
|
+
const stale = record.status === "open" && typeof record.enginePid === "number" && processIsGone(record.enginePid);
|
|
68
|
+
const reading = await readMerge(record, branch, branchExists, dirty);
|
|
69
|
+
const reclaimable = checkoutPresent &&
|
|
70
|
+
!dirty &&
|
|
71
|
+
(record.status === "closed" || stale) &&
|
|
72
|
+
(reading === "untouched" || reading === "merged" || reading === "merged-into-head" || !branchExists);
|
|
73
|
+
return {
|
|
74
|
+
sessionId: record.sessionId,
|
|
75
|
+
isolated,
|
|
76
|
+
checkoutPresent,
|
|
77
|
+
worktreePresent,
|
|
78
|
+
branchExists,
|
|
79
|
+
reading,
|
|
80
|
+
dirty,
|
|
81
|
+
stale,
|
|
82
|
+
orphaned,
|
|
83
|
+
reclaimable,
|
|
84
|
+
reason: explain({ reading, dirty, stale, branchExists, reclaimable, status: record.status, baseBranch: record.baseBranch ?? null }),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
async function readMerge(record, branch, branchExists, dirty) {
|
|
88
|
+
if (!branchExists)
|
|
89
|
+
return "unknown";
|
|
90
|
+
const cwd = record.projectPath;
|
|
91
|
+
if (record.baseCommit) {
|
|
92
|
+
const tip = await gitOutcome(["rev-parse", branch], cwd);
|
|
93
|
+
if (tip.ok && tip.stdout === record.baseCommit && !dirty)
|
|
94
|
+
return "untouched";
|
|
95
|
+
}
|
|
96
|
+
if (record.baseBranch) {
|
|
97
|
+
const againstBase = await gitOutcome(["merge-base", "--is-ancestor", branch, record.baseBranch], cwd);
|
|
98
|
+
if (againstBase.code === 0)
|
|
99
|
+
return "merged";
|
|
100
|
+
if (againstBase.code === 1)
|
|
101
|
+
return "unmerged";
|
|
102
|
+
}
|
|
103
|
+
const againstHead = await gitOutcome(["merge-base", "--is-ancestor", branch, "HEAD"], cwd);
|
|
104
|
+
if (againstHead.code === 0)
|
|
105
|
+
return "merged-into-head";
|
|
106
|
+
if (againstHead.code === 1)
|
|
107
|
+
return "unmerged";
|
|
108
|
+
return "unknown";
|
|
109
|
+
}
|
|
110
|
+
function explain(facts) {
|
|
111
|
+
if (facts.dirty)
|
|
112
|
+
return "its worktree has uncommitted changes — commit or discard them first";
|
|
113
|
+
if (facts.status === "open" && !facts.stale)
|
|
114
|
+
return "it is still open — close the session first";
|
|
115
|
+
if (facts.reading === "unmerged")
|
|
116
|
+
return "unmerged — its branch has work nothing has taken";
|
|
117
|
+
if (facts.reading === "unknown" && facts.branchExists)
|
|
118
|
+
return "its branch could not be read against any base";
|
|
119
|
+
if (!facts.reclaimable)
|
|
120
|
+
return "it cannot be reclaimed yet";
|
|
121
|
+
if (!facts.branchExists)
|
|
122
|
+
return "its branch is already gone — only the worktree is left";
|
|
123
|
+
if (facts.reading === "untouched")
|
|
124
|
+
return "nothing was ever done in this session, and its worktree is clean";
|
|
125
|
+
if (facts.reading === "merged")
|
|
126
|
+
return `merged into ${facts.baseBranch ?? "its base"} and its worktree is clean`;
|
|
127
|
+
return "merged into the checkout's current HEAD and its worktree is clean";
|
|
128
|
+
}
|
|
129
|
+
export async function readOrphanedWorktrees(env = process.env) {
|
|
130
|
+
const root = path.join(legacyEngineHome(env), "worktrees");
|
|
131
|
+
let entries;
|
|
132
|
+
try {
|
|
133
|
+
entries = (await readdir(root, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return { records: [], undecipherable: [] };
|
|
137
|
+
}
|
|
138
|
+
const records = [];
|
|
139
|
+
const undecipherable = [];
|
|
140
|
+
for (const id of entries) {
|
|
141
|
+
const worktreePath = path.join(root, id);
|
|
142
|
+
const projectPath = await checkoutOfLinkedWorktree(worktreePath, id);
|
|
143
|
+
if (!projectPath) {
|
|
144
|
+
undecipherable.push(worktreePath);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
records.push({
|
|
148
|
+
sessionId: id,
|
|
149
|
+
projectPath,
|
|
150
|
+
worktreePath,
|
|
151
|
+
branch: `${LEGACY_ENGINE_BRANCH_PREFIX}${id}`,
|
|
152
|
+
provider: "claude-code",
|
|
153
|
+
providerSessionId: null,
|
|
154
|
+
createdAt: await directoryCreatedAt(worktreePath),
|
|
155
|
+
status: "closed",
|
|
156
|
+
nustack: 1,
|
|
157
|
+
projectId: null,
|
|
158
|
+
permissionMode: null,
|
|
159
|
+
baseBranch: null,
|
|
160
|
+
baseCommit: null,
|
|
161
|
+
enginePid: null,
|
|
162
|
+
closedAt: null,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return { records, undecipherable };
|
|
166
|
+
}
|
|
167
|
+
async function checkoutOfLinkedWorktree(worktreePath, id) {
|
|
168
|
+
let body;
|
|
169
|
+
try {
|
|
170
|
+
body = await readFile(path.join(worktreePath, ".git"), "utf8");
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const match = /^gitdir:\s*(.+)$/m.exec(body.trim());
|
|
176
|
+
if (!match?.[1])
|
|
177
|
+
return null;
|
|
178
|
+
const tail = path.join(".git", "worktrees", id);
|
|
179
|
+
const gitdir = match[1].trim();
|
|
180
|
+
if (!gitdir.endsWith(tail))
|
|
181
|
+
return null;
|
|
182
|
+
const checkout = gitdir.slice(0, gitdir.length - tail.length).replace(new RegExp(`${path.sep}+$`), "");
|
|
183
|
+
return checkout === "" ? null : checkout;
|
|
184
|
+
}
|
|
185
|
+
async function directoryCreatedAt(target) {
|
|
186
|
+
try {
|
|
187
|
+
return (await stat(target)).mtime.toISOString();
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return new Date(0).toISOString();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export async function reclaimSession(record, options, env = process.env) {
|
|
194
|
+
const refused = (reason) => ({
|
|
195
|
+
sessionId: record.sessionId,
|
|
196
|
+
removedWorktree: false,
|
|
197
|
+
deletedBranch: false,
|
|
198
|
+
droppedRow: false,
|
|
199
|
+
reason,
|
|
200
|
+
});
|
|
201
|
+
if (!isEngineBranch(record.branch))
|
|
202
|
+
return refused(record.branch === null ? NOT_ISOLATED : FOREIGN_BRANCH);
|
|
203
|
+
if (record.worktreePath === record.projectPath)
|
|
204
|
+
return refused(NOT_ISOLATED);
|
|
205
|
+
if (!insideAnEngineHome(record.worktreePath, env))
|
|
206
|
+
return refused(FOREIGN_WORKTREE);
|
|
207
|
+
const facts = await inspectSession(record, env);
|
|
208
|
+
if (!facts.checkoutPresent)
|
|
209
|
+
return refused(facts.reason);
|
|
210
|
+
if (record.status === "open" && !facts.stale) {
|
|
211
|
+
return refused(`its engine is still running (pid ${String(record.enginePid ?? "unknown")}) — close the session first`);
|
|
212
|
+
}
|
|
213
|
+
const force = options.force === true;
|
|
214
|
+
if (!force && !facts.reclaimable)
|
|
215
|
+
return refused(facts.reason);
|
|
216
|
+
const branch = record.branch;
|
|
217
|
+
const removal = await gitOutcome(force ? ["worktree", "remove", "--force", record.worktreePath] : ["worktree", "remove", record.worktreePath], record.projectPath);
|
|
218
|
+
await gitOutcome(["worktree", "prune"], record.projectPath);
|
|
219
|
+
const deletion = await gitOutcome([...(force ? ["branch", "-D"] : ["branch", "-d"]), branch], record.projectPath);
|
|
220
|
+
const worktreeLeft = await isDirectory(record.worktreePath);
|
|
221
|
+
const branchLeft = (await gitOutcome(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], record.projectPath)).ok;
|
|
222
|
+
const cleared = !worktreeLeft && !branchLeft;
|
|
223
|
+
const droppedRow = cleared && !facts.orphaned
|
|
224
|
+
? await updateSessionRegistry(env, (sessions) => sessions.filter((row) => row.sessionId !== record.sessionId)).then(() => true)
|
|
225
|
+
: false;
|
|
226
|
+
return {
|
|
227
|
+
sessionId: record.sessionId,
|
|
228
|
+
removedWorktree: !worktreeLeft,
|
|
229
|
+
deletedBranch: !branchLeft,
|
|
230
|
+
droppedRow,
|
|
231
|
+
reason: cleared ? null : (deletion.stderr || removal.stderr || "the worktree or the branch could not be removed"),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { sessionsHome } from "./worktree.js";
|
|
4
|
+
export const SESSION_REGISTRY_VERSION = 2;
|
|
5
|
+
export function sessionRegistryPath(env = process.env) {
|
|
6
|
+
return path.join(sessionsHome(env), "registry.json");
|
|
7
|
+
}
|
|
8
|
+
function asText(value) {
|
|
9
|
+
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
10
|
+
}
|
|
11
|
+
function asNullableText(value) {
|
|
12
|
+
return typeof value === "string" ? value : null;
|
|
13
|
+
}
|
|
14
|
+
function narrowRecord(value) {
|
|
15
|
+
if (typeof value !== "object" || value === null)
|
|
16
|
+
return null;
|
|
17
|
+
const row = value;
|
|
18
|
+
const sessionId = asText(row.sessionId);
|
|
19
|
+
const projectPath = asText(row.projectPath);
|
|
20
|
+
const worktreePath = asText(row.worktreePath);
|
|
21
|
+
const createdAt = asText(row.createdAt);
|
|
22
|
+
if (!sessionId || !projectPath || !worktreePath || !createdAt)
|
|
23
|
+
return null;
|
|
24
|
+
if (row.status !== "open" && row.status !== "closed")
|
|
25
|
+
return null;
|
|
26
|
+
if (row.provider !== "claude-code")
|
|
27
|
+
return null;
|
|
28
|
+
const enginePid = typeof row.enginePid === "number" && Number.isSafeInteger(row.enginePid) && row.enginePid > 0 ? row.enginePid : null;
|
|
29
|
+
return {
|
|
30
|
+
sessionId,
|
|
31
|
+
projectPath,
|
|
32
|
+
worktreePath,
|
|
33
|
+
branch: asNullableText(row.branch),
|
|
34
|
+
provider: "claude-code",
|
|
35
|
+
providerSessionId: asNullableText(row.providerSessionId),
|
|
36
|
+
createdAt,
|
|
37
|
+
status: row.status,
|
|
38
|
+
nustack: row.nustack === 0 ? 0 : 1,
|
|
39
|
+
projectId: asNullableText(row.projectId),
|
|
40
|
+
permissionMode: asNullableText(row.permissionMode),
|
|
41
|
+
baseBranch: asNullableText(row.baseBranch),
|
|
42
|
+
baseCommit: asNullableText(row.baseCommit),
|
|
43
|
+
enginePid,
|
|
44
|
+
closedAt: asNullableText(row.closedAt),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export async function readSessionRegistry(env = process.env) {
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(await readFile(sessionRegistryPath(env), "utf8"));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return { records: [], dropped: 0 };
|
|
54
|
+
}
|
|
55
|
+
const rows = parsed?.sessions;
|
|
56
|
+
if (!Array.isArray(rows))
|
|
57
|
+
return { records: [], dropped: 0 };
|
|
58
|
+
const records = [];
|
|
59
|
+
let dropped = 0;
|
|
60
|
+
for (const row of rows) {
|
|
61
|
+
const record = narrowRecord(row);
|
|
62
|
+
if (record)
|
|
63
|
+
records.push(record);
|
|
64
|
+
else
|
|
65
|
+
dropped += 1;
|
|
66
|
+
}
|
|
67
|
+
return { records, dropped };
|
|
68
|
+
}
|
|
69
|
+
let registryWrites = Promise.resolve();
|
|
70
|
+
export function updateSessionRegistry(env, mutate) {
|
|
71
|
+
const run = async () => {
|
|
72
|
+
const file = sessionRegistryPath(env);
|
|
73
|
+
const { records } = await readSessionRegistry(env);
|
|
74
|
+
const body = JSON.stringify({ v: SESSION_REGISTRY_VERSION, sessions: mutate(records) }, null, 2) + "\n";
|
|
75
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
76
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
77
|
+
await writeFile(temp, body, "utf8");
|
|
78
|
+
await rename(temp, file);
|
|
79
|
+
};
|
|
80
|
+
const landed = registryWrites.then(run, run);
|
|
81
|
+
registryWrites = landed.then(() => undefined, () => undefined);
|
|
82
|
+
return landed;
|
|
83
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
5
|
import { ClaudeSession } from "./claudeAdapter.js";
|
|
6
6
|
import { FRAME_VERSION, parseInboundFrame, serializeFrame, } from "./frames.js";
|
|
7
|
-
import {
|
|
7
|
+
import { inspectSession, reclaimSession } from "./prune.js";
|
|
8
|
+
import { sessionRegistryPath, readSessionRegistry, updateSessionRegistry } from "./registry.js";
|
|
9
|
+
import { createSessionWorktree } from "./worktree.js";
|
|
8
10
|
function defaultProbeClaude(env) {
|
|
9
11
|
return new Promise((resolve) => {
|
|
10
12
|
execFile("claude", ["--version"], { env, timeout: 5_000 }, (error, stdout) => {
|
|
@@ -37,9 +39,6 @@ export class SessionSupervisor {
|
|
|
37
39
|
error(sessionId, code, message) {
|
|
38
40
|
this.emit({ v: FRAME_VERSION, type: "session.error", sessionId, code, message });
|
|
39
41
|
}
|
|
40
|
-
registryPath() {
|
|
41
|
-
return path.join(sessionsHome(this.env), "registry.json");
|
|
42
|
-
}
|
|
43
42
|
async boundProjectOf(projectPath) {
|
|
44
43
|
try {
|
|
45
44
|
const parsed = JSON.parse(await readFile(path.join(projectPath, ".nustack", "workspace.json"), "utf8"));
|
|
@@ -61,20 +60,7 @@ export class SessionSupervisor {
|
|
|
61
60
|
}
|
|
62
61
|
}
|
|
63
62
|
async updateRegistry(mutate) {
|
|
64
|
-
const run =
|
|
65
|
-
const file = this.registryPath();
|
|
66
|
-
let sessions = [];
|
|
67
|
-
try {
|
|
68
|
-
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
69
|
-
if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed.sessions)) {
|
|
70
|
-
sessions = parsed.sessions;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
catch {
|
|
74
|
-
}
|
|
75
|
-
await mkdir(path.dirname(file), { recursive: true });
|
|
76
|
-
await writeFile(file, JSON.stringify({ v: 2, sessions: mutate(sessions) }, null, 2) + "\n", "utf8");
|
|
77
|
-
};
|
|
63
|
+
const run = () => updateSessionRegistry(this.env, mutate);
|
|
78
64
|
this.registryWrites = this.registryWrites.then(run, run);
|
|
79
65
|
return this.registryWrites;
|
|
80
66
|
}
|
|
@@ -137,6 +123,12 @@ export class SessionSupervisor {
|
|
|
137
123
|
session.adapter.stop();
|
|
138
124
|
return;
|
|
139
125
|
}
|
|
126
|
+
case "session.list":
|
|
127
|
+
await this.sendRoster();
|
|
128
|
+
return;
|
|
129
|
+
case "session.reclaim":
|
|
130
|
+
await this.reclaimRow(frame.sessionId);
|
|
131
|
+
return;
|
|
140
132
|
}
|
|
141
133
|
}
|
|
142
134
|
async openSession(sessionId, projectPath, permissionMode, isolated, resumeSessionId) {
|
|
@@ -155,6 +147,11 @@ export class SessionSupervisor {
|
|
|
155
147
|
status: "open",
|
|
156
148
|
nustack: 1,
|
|
157
149
|
projectId: await this.boundProjectOf(projectPath),
|
|
150
|
+
permissionMode: permissionMode ?? null,
|
|
151
|
+
baseBranch: worktree.baseBranch,
|
|
152
|
+
baseCommit: worktree.baseCommit,
|
|
153
|
+
enginePid: process.pid,
|
|
154
|
+
closedAt: null,
|
|
158
155
|
};
|
|
159
156
|
const adapterOptions = {
|
|
160
157
|
cwd: worktree.worktreePath,
|
|
@@ -194,7 +191,7 @@ export class SessionSupervisor {
|
|
|
194
191
|
if (!wasExpected)
|
|
195
192
|
this.error(sessionId, "ADAPTER_FAILED", `claude exited unexpectedly (code ${String(code)})`);
|
|
196
193
|
this.emit({ v: FRAME_VERSION, type: "session.closed", sessionId });
|
|
197
|
-
void this.updateRegistry((sessions) => sessions.map((row) =>
|
|
194
|
+
void this.updateRegistry((sessions) => sessions.map((row) => row.sessionId === sessionId ? { ...row, status: "closed", closedAt: new Date().toISOString() } : row));
|
|
198
195
|
void this.moveSessionRow(record, wasExpected ? "done" : "failed");
|
|
199
196
|
},
|
|
200
197
|
};
|
|
@@ -235,13 +232,83 @@ export class SessionSupervisor {
|
|
|
235
232
|
});
|
|
236
233
|
this.status(sessionId, "ready");
|
|
237
234
|
}
|
|
235
|
+
async sendRoster() {
|
|
236
|
+
const { records, dropped } = await readSessionRegistry(this.env);
|
|
237
|
+
const rows = [];
|
|
238
|
+
for (const record of records) {
|
|
239
|
+
const attached = this.sessions.has(record.sessionId);
|
|
240
|
+
const facts = await inspectSession(record, this.env);
|
|
241
|
+
const reclaimable = !attached && facts.reclaimable;
|
|
242
|
+
const reason = attached ? "this engine is running it — close the session first" : facts.reason;
|
|
243
|
+
rows.push({
|
|
244
|
+
sessionId: record.sessionId,
|
|
245
|
+
projectPath: record.projectPath,
|
|
246
|
+
worktreePath: record.worktreePath,
|
|
247
|
+
branch: record.branch,
|
|
248
|
+
createdAt: record.createdAt,
|
|
249
|
+
status: record.status,
|
|
250
|
+
closedAt: record.closedAt ?? null,
|
|
251
|
+
permissionMode: record.permissionMode ?? null,
|
|
252
|
+
baseBranch: record.baseBranch ?? null,
|
|
253
|
+
attached,
|
|
254
|
+
liveElsewhere: !attached && record.status === "open" && typeof record.enginePid === "number" && !facts.stale,
|
|
255
|
+
isolated: facts.isolated,
|
|
256
|
+
checkoutPresent: facts.checkoutPresent,
|
|
257
|
+
worktreePresent: facts.worktreePresent,
|
|
258
|
+
branchExists: facts.branchExists,
|
|
259
|
+
reading: facts.reading,
|
|
260
|
+
dirty: facts.dirty,
|
|
261
|
+
stale: facts.stale,
|
|
262
|
+
reclaimable,
|
|
263
|
+
reason,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
this.emit({
|
|
267
|
+
v: FRAME_VERSION,
|
|
268
|
+
type: "session.roster",
|
|
269
|
+
registryPath: sessionRegistryPath(this.env),
|
|
270
|
+
unreadable: dropped,
|
|
271
|
+
sessions: rows,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
async reclaimRow(sessionId) {
|
|
275
|
+
if (this.sessions.has(sessionId)) {
|
|
276
|
+
return this.error(sessionId, "SESSION_ATTACHED", `session ${sessionId} is open in this engine — close it first`);
|
|
277
|
+
}
|
|
278
|
+
const { records } = await readSessionRegistry(this.env);
|
|
279
|
+
const record = records.find((row) => row.sessionId === sessionId);
|
|
280
|
+
if (!record)
|
|
281
|
+
return this.error(sessionId, "SESSION_UNKNOWN", `no session ${sessionId} in this machine's registry`);
|
|
282
|
+
const reclaim = this.registryWrites.then(() => reclaimSession(record, {}, this.env), () => reclaimSession(record, {}, this.env));
|
|
283
|
+
this.registryWrites = reclaim.then(() => undefined, () => undefined);
|
|
284
|
+
try {
|
|
285
|
+
const outcome = await reclaim;
|
|
286
|
+
this.emit({
|
|
287
|
+
v: FRAME_VERSION,
|
|
288
|
+
type: "session.reclaimed",
|
|
289
|
+
sessionId,
|
|
290
|
+
removedWorktree: outcome.removedWorktree,
|
|
291
|
+
deletedBranch: outcome.deletedBranch,
|
|
292
|
+
droppedRow: outcome.droppedRow,
|
|
293
|
+
reason: outcome.reason,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
this.error(sessionId, "RECLAIM_FAILED", error instanceof Error ? error.message : String(error));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
238
300
|
async shutdown() {
|
|
239
301
|
if (this.shuttingDown)
|
|
240
302
|
return;
|
|
241
303
|
this.shuttingDown = true;
|
|
304
|
+
const held = [...this.sessions.keys()];
|
|
242
305
|
for (const [, session] of this.sessions)
|
|
243
306
|
session.adapter.stop();
|
|
244
307
|
this.sessions.clear();
|
|
308
|
+
if (held.length > 0) {
|
|
309
|
+
const closedAt = new Date().toISOString();
|
|
310
|
+
await this.updateRegistry((sessions) => sessions.map((row) => (held.includes(row.sessionId) && row.status === "open" ? { ...row, status: "closed", closedAt } : row)));
|
|
311
|
+
}
|
|
245
312
|
this.lines?.close();
|
|
246
313
|
this.resolveDone?.();
|
|
247
314
|
}
|
|
@@ -9,25 +9,48 @@ export function sessionsHome(env = process.env) {
|
|
|
9
9
|
return override;
|
|
10
10
|
return path.join(os.homedir(), ".nustack", "sessions");
|
|
11
11
|
}
|
|
12
|
+
export const LEGACY_ENGINE_BRANCH_PREFIX = "nustack/lane-";
|
|
13
|
+
export function legacyEngineHome(env = process.env) {
|
|
14
|
+
const override = env.NUSTACK_LEGACY_ENGINE_HOME;
|
|
15
|
+
if (override && override !== "")
|
|
16
|
+
return override;
|
|
17
|
+
return path.join(os.homedir(), ".nustack", "lanes");
|
|
18
|
+
}
|
|
12
19
|
function git(args, cwd) {
|
|
20
|
+
return gitOutcome(args, cwd).then((outcome) => (outcome.ok ? outcome.stdout : null));
|
|
21
|
+
}
|
|
22
|
+
export function gitOutcome(args, cwd) {
|
|
13
23
|
return new Promise((resolve) => {
|
|
14
|
-
|
|
15
|
-
resolve(error ?
|
|
16
|
-
}
|
|
24
|
+
const failed = (error) => {
|
|
25
|
+
resolve({ ok: false, code: null, stdout: "", stderr: error instanceof Error ? error.message : String(error) });
|
|
26
|
+
};
|
|
27
|
+
try {
|
|
28
|
+
execFile("git", args, { cwd, timeout: 15_000 }, (error, stdout, stderr) => {
|
|
29
|
+
const raw = error?.code;
|
|
30
|
+
const code = error ? (typeof raw === "number" ? raw : null) : 0;
|
|
31
|
+
resolve({ ok: !error, code, stdout: stdout.trim(), stderr: stderr.trim() });
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
failed(error);
|
|
36
|
+
}
|
|
17
37
|
});
|
|
18
38
|
}
|
|
19
39
|
export async function createSessionWorktree(projectPath, sessionId, env = process.env, isolate = false) {
|
|
20
|
-
const inPlace = { worktreePath: projectPath, branch: null, isolated: false };
|
|
40
|
+
const inPlace = { worktreePath: projectPath, branch: null, isolated: false, baseBranch: null, baseCommit: null };
|
|
21
41
|
if (!isolate)
|
|
22
42
|
return inPlace;
|
|
23
43
|
const isRepo = await git(["rev-parse", "--is-inside-work-tree"], projectPath);
|
|
24
44
|
if (isRepo !== "true")
|
|
25
45
|
return inPlace;
|
|
46
|
+
const head = await git(["rev-parse", "--abbrev-ref", "HEAD"], projectPath);
|
|
47
|
+
const baseBranch = head === null || head === "HEAD" ? null : head;
|
|
48
|
+
const baseCommit = await git(["rev-parse", "HEAD"], projectPath);
|
|
26
49
|
const branch = `${ENGINE_SESSION_BRANCH_PREFIX}${sessionId}`;
|
|
27
50
|
const worktreePath = path.join(sessionsHome(env), "worktrees", sessionId);
|
|
28
51
|
await mkdir(path.dirname(worktreePath), { recursive: true });
|
|
29
52
|
const added = await git(["worktree", "add", worktreePath, "-b", branch], projectPath);
|
|
30
53
|
if (added === null)
|
|
31
54
|
return inPlace;
|
|
32
|
-
return { worktreePath, branch, isolated: true };
|
|
55
|
+
return { worktreePath, branch, isolated: true, baseBranch, baseCommit };
|
|
33
56
|
}
|
package/package.json
CHANGED