@shiyuphan/follow-me 0.1.0 → 0.2.0
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 +128 -56
- package/dist/src/anthropic.js +41 -9
- package/dist/src/cli.js +836 -82
- package/dist/src/comments.js +300 -0
- package/dist/src/git.js +103 -24
- package/dist/src/harness.js +74 -0
- package/dist/src/host.js +379 -0
- package/dist/src/install.js +131 -0
- package/dist/src/naive.js +26 -0
- package/dist/src/plan.js +281 -25
- package/dist/src/prompt.js +391 -82
- package/dist/src/server.js +710 -160
- package/dist/src/session.js +311 -104
- package/dist/src/syntax.js +199 -0
- package/dist/src/types.js +5 -2
- package/dist/ui/app.js +1703 -272
- package/dist/ui/concerns.js +165 -0
- package/dist/ui/highlight.js +221 -0
- package/dist/ui/index.html +62 -7
- package/dist/ui/live.js +184 -0
- package/dist/ui/playback.js +480 -76
- package/dist/ui/questions.js +189 -0
- package/dist/ui/style.css +975 -75
- package/dist/ui/summary.js +45 -0
- package/package.json +4 -2
- package/skills/follow-me/SKILL.md +34 -16
package/dist/src/session.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
2
|
+
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { parseDraft, parseReview } from "./comments.js";
|
|
5
|
+
import { isSha } from "./diff.js";
|
|
6
|
+
import { branchExists, currentBranch, defaultBaseRef, GitError, loadDiff, objectsDir, QUARANTINE_PREFIX, repoRoot, resolveCommit, } from "./git.js";
|
|
7
|
+
import { isFields, isPlanVersion, parsePlanFile, planHash, serializePlanFile, } from "./plan.js";
|
|
7
8
|
export class BranchMovedError extends Error {
|
|
8
9
|
name = "BranchMovedError";
|
|
9
10
|
}
|
|
@@ -11,15 +12,137 @@ export class BranchMovedError extends Error {
|
|
|
11
12
|
export class SessionFileError extends Error {
|
|
12
13
|
name = "SessionFileError";
|
|
13
14
|
}
|
|
15
|
+
/** draft.json has not been written yet. */
|
|
16
|
+
export class MissingDraftError extends SessionFileError {
|
|
17
|
+
path;
|
|
18
|
+
constructor(path) {
|
|
19
|
+
super(`${path} does not exist yet`);
|
|
20
|
+
this.path = path;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** The command ran outside any git work tree. */
|
|
24
|
+
export class NotARepositoryError extends Error {
|
|
25
|
+
name = "NotARepositoryError";
|
|
26
|
+
constructor() {
|
|
27
|
+
super("not a git repository (run it inside the repo you want to split)");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Another live follow-me process holds the session's lock. */
|
|
31
|
+
export class SessionLockedError extends Error {
|
|
32
|
+
name = "SessionLockedError";
|
|
33
|
+
}
|
|
34
|
+
export function isAlive(pid) {
|
|
35
|
+
try {
|
|
36
|
+
process.kill(pid, 0);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
// EPERM: the process exists but belongs to another user.
|
|
41
|
+
return error instanceof Error && "code" in error && error.code === "EPERM";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function readLock(path) {
|
|
45
|
+
const raw = readJson(path);
|
|
46
|
+
if (raw === undefined)
|
|
47
|
+
return { kind: "free" };
|
|
48
|
+
if (!isFields(raw))
|
|
49
|
+
throw new SessionFileError(`${path} is not a lock`);
|
|
50
|
+
const { pid, command } = raw;
|
|
51
|
+
if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
|
|
52
|
+
throw new SessionFileError(`${path} is not a lock`);
|
|
53
|
+
}
|
|
54
|
+
if (typeof command !== "string")
|
|
55
|
+
throw new SessionFileError(`${path} is not a lock`);
|
|
56
|
+
return { kind: isAlive(pid) ? "held" : "stale", pid, command };
|
|
57
|
+
}
|
|
58
|
+
const LOCK_TOKEN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
59
|
+
export function isLockToken(value) {
|
|
60
|
+
return typeof value === "string" && LOCK_TOKEN.test(value);
|
|
61
|
+
}
|
|
62
|
+
function newToken() {
|
|
63
|
+
const token = randomUUID();
|
|
64
|
+
if (!isLockToken(token))
|
|
65
|
+
throw new Error(`randomUUID returned ${token}`);
|
|
66
|
+
return token;
|
|
67
|
+
}
|
|
68
|
+
function lockContent(command, token) {
|
|
69
|
+
return `${JSON.stringify({ pid: process.pid, command, token })}\n`;
|
|
70
|
+
}
|
|
71
|
+
function holding(path, token) {
|
|
72
|
+
return {
|
|
73
|
+
token,
|
|
74
|
+
release: () => {
|
|
75
|
+
const raw = readJson(path);
|
|
76
|
+
if (isFields(raw) && raw.token === token)
|
|
77
|
+
rmSync(path, { force: true });
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function isCode(error, code) {
|
|
82
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Takes <dir>/lock for this process. The lock is written whole to a temp file and linked into
|
|
86
|
+
* place, which fails like open(wx) when the lock exists, so a reader never sees a half-written
|
|
87
|
+
* lock. A lock whose pid is dead is taken over.
|
|
88
|
+
*/
|
|
89
|
+
function acquireLock(dir, branch, command) {
|
|
90
|
+
const path = join(dir, "lock");
|
|
91
|
+
const token = newToken();
|
|
92
|
+
mkdirSync(dir, { recursive: true });
|
|
93
|
+
for (let attempt = 0;; attempt++) {
|
|
94
|
+
const temp = `${path}.${process.pid}.${token}.tmp`;
|
|
95
|
+
writeFileSync(temp, lockContent(command, token));
|
|
96
|
+
try {
|
|
97
|
+
linkSync(temp, path);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (!isCode(error, "EEXIST"))
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
rmSync(temp, { force: true });
|
|
106
|
+
}
|
|
107
|
+
const state = readLock(path);
|
|
108
|
+
if (state.kind === "held" || attempt > 0) {
|
|
109
|
+
const who = state.kind === "free" ? "another process" : `pid ${state.pid}`;
|
|
110
|
+
const what = state.kind === "free" ? "follow-me" : `follow-me ${state.command}`;
|
|
111
|
+
throw new SessionLockedError(`the session for ${branch} is busy: ${who} is running ${what}; wait for it to finish or stop it`);
|
|
112
|
+
}
|
|
113
|
+
rmSync(path, { force: true });
|
|
114
|
+
}
|
|
115
|
+
return holding(path, token);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Takes over a lock another live process holds and is handing over, identified by its token.
|
|
119
|
+
* The holder waits for the handoff to finish, so nobody else can take the lock between the read
|
|
120
|
+
* and the rename; the old holder's release then finds a foreign token and leaves the lock alone.
|
|
121
|
+
*/
|
|
122
|
+
function adoptLock(dir, from, command) {
|
|
123
|
+
const path = join(dir, "lock");
|
|
124
|
+
const raw = readJson(path);
|
|
125
|
+
if (!isFields(raw) || raw.token !== from) {
|
|
126
|
+
throw new SessionLockedError(`${path} is not held by the process handing it over`);
|
|
127
|
+
}
|
|
128
|
+
const token = newToken();
|
|
129
|
+
const temp = `${path}.${process.pid}.${token}.tmp`;
|
|
130
|
+
writeFileSync(temp, lockContent(command, token));
|
|
131
|
+
renameSync(temp, path);
|
|
132
|
+
return holding(path, token);
|
|
133
|
+
}
|
|
14
134
|
export function slugOf(branch) {
|
|
15
135
|
return branch.replaceAll("/", "-");
|
|
16
136
|
}
|
|
17
|
-
function
|
|
137
|
+
function writeText(path, text) {
|
|
18
138
|
mkdirSync(dirname(path), { recursive: true });
|
|
19
139
|
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
20
|
-
writeFileSync(temp,
|
|
140
|
+
writeFileSync(temp, text);
|
|
21
141
|
renameSync(temp, path);
|
|
22
142
|
}
|
|
143
|
+
function writeJson(path, value) {
|
|
144
|
+
writeText(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
145
|
+
}
|
|
23
146
|
function readJson(path) {
|
|
24
147
|
let text;
|
|
25
148
|
try {
|
|
@@ -65,101 +188,24 @@ function parseAnchor(v) {
|
|
|
65
188
|
function parseApplied(v) {
|
|
66
189
|
if (!isFields(v))
|
|
67
190
|
return null;
|
|
68
|
-
const { schema, version, branch, head } = v;
|
|
191
|
+
const { schema, version, branch, head, hash = null, signed = null } = v;
|
|
69
192
|
if (schema !== "follow-me/applied@1" || !isPlanVersion(version))
|
|
70
193
|
return null;
|
|
71
194
|
if (typeof branch !== "string" || typeof head !== "string" || !isSha(head))
|
|
72
195
|
return null;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
function parseCommentAnchor(v) {
|
|
79
|
-
if (!isFields(v))
|
|
80
|
-
return "anchor is not an object";
|
|
81
|
-
if (v.kind === "plan")
|
|
82
|
-
return { kind: "plan" };
|
|
83
|
-
if (!isCommitIndex(v.commit))
|
|
84
|
-
return "anchor.commit is not a commit index";
|
|
85
|
-
const commit = v.commit;
|
|
86
|
-
if (v.kind === "message" || v.kind === "position")
|
|
87
|
-
return { kind: v.kind, commit };
|
|
88
|
-
if (v.kind !== "hunk")
|
|
89
|
-
return "anchor.kind is unknown";
|
|
90
|
-
if (typeof v.unit !== "string" || !isUnitId(v.unit))
|
|
91
|
-
return "anchor.unit is not a unit id";
|
|
92
|
-
if (v.changes === null)
|
|
93
|
-
return { kind: "hunk", unit: v.unit, commit, changes: null };
|
|
94
|
-
if (!isArray(v.changes) || !v.changes.every(isCommitIndex)) {
|
|
95
|
-
return "anchor.changes is not null or a list of change indices";
|
|
96
|
-
}
|
|
97
|
-
return { kind: "hunk", unit: v.unit, commit, changes: v.changes.filter(isCommitIndex) };
|
|
98
|
-
}
|
|
99
|
-
function parseComment(v) {
|
|
100
|
-
if (!isFields(v))
|
|
101
|
-
return "not an object";
|
|
102
|
-
if (typeof v.id !== "string" || !isCommentId(v.id))
|
|
103
|
-
return "id is not a comment id";
|
|
104
|
-
const anchor = parseCommentAnchor(v.anchor);
|
|
105
|
-
if (typeof anchor === "string")
|
|
106
|
-
return anchor;
|
|
107
|
-
const ctx = v.context;
|
|
108
|
-
if (!isFields(ctx) || typeof ctx.commitMessage !== "string") {
|
|
109
|
-
return "context.commitMessage is not a string";
|
|
110
|
-
}
|
|
111
|
-
const path = ctx.path;
|
|
112
|
-
if (path !== null && (typeof path !== "string" || !isRepoPath(path))) {
|
|
113
|
-
return "context.path is not null or a path";
|
|
114
|
-
}
|
|
115
|
-
if (typeof v.body !== "string" || typeof v.createdAt !== "string") {
|
|
116
|
-
return "body and createdAt must be strings";
|
|
117
|
-
}
|
|
118
|
-
return {
|
|
119
|
-
id: v.id,
|
|
120
|
-
anchor,
|
|
121
|
-
context: { commitMessage: ctx.commitMessage, path },
|
|
122
|
-
body: v.body,
|
|
123
|
-
createdAt: v.createdAt,
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
/** Boundary parse of reviews/vN.json. Files written before walk mode existed have no mode. */
|
|
127
|
-
export function parseReview(v) {
|
|
128
|
-
if (!isFields(v)) {
|
|
129
|
-
return "not an object";
|
|
130
|
-
}
|
|
131
|
-
if (v.schema !== "follow-me/review@1")
|
|
132
|
-
return 'schema is not "follow-me/review@1"';
|
|
133
|
-
const mode = v.mode ?? "split";
|
|
134
|
-
if (mode !== "split" && mode !== "walk")
|
|
135
|
-
return 'mode is not "split" or "walk"';
|
|
136
|
-
if (!isPlanVersion(v.planVersion))
|
|
137
|
-
return "planVersion is not a positive integer";
|
|
138
|
-
if (typeof v.planHash !== "string" || typeof v.endedAt !== "string") {
|
|
139
|
-
return "planHash and endedAt must be strings";
|
|
140
|
-
}
|
|
141
|
-
if (!isArray(v.comments))
|
|
142
|
-
return "comments is not an array";
|
|
143
|
-
const comments = [];
|
|
144
|
-
for (const [i, raw] of v.comments.entries()) {
|
|
145
|
-
const c = parseComment(raw);
|
|
146
|
-
if (typeof c === "string")
|
|
147
|
-
return `comments[${i}]: ${c}`;
|
|
148
|
-
comments.push(c);
|
|
149
|
-
}
|
|
150
|
-
return {
|
|
151
|
-
schema: v.schema,
|
|
152
|
-
mode,
|
|
153
|
-
planVersion: v.planVersion,
|
|
154
|
-
planHash: v.planHash,
|
|
155
|
-
endedAt: v.endedAt,
|
|
156
|
-
comments,
|
|
157
|
-
};
|
|
196
|
+
if (hash !== null && typeof hash !== "string")
|
|
197
|
+
return null;
|
|
198
|
+
if (signed !== null && typeof signed !== "boolean")
|
|
199
|
+
return null;
|
|
200
|
+
return { schema, version, branch, head, hash, signed };
|
|
158
201
|
}
|
|
159
202
|
function makeSession(root, dir, anchor, diff) {
|
|
160
203
|
const plansDir = join(dir, "plans");
|
|
161
|
-
const
|
|
204
|
+
const splitReviews = join(dir, "reviews");
|
|
205
|
+
const walkReviewsDir = join(splitReviews, "walk");
|
|
206
|
+
const reviewsDir = (mode) => mode === "walk" ? walkReviewsDir : splitReviews;
|
|
162
207
|
const draftPath = join(dir, "draft.json");
|
|
208
|
+
const promptPath = join(dir, "prompt.md");
|
|
163
209
|
const appliedPath = join(dir, "applied.json");
|
|
164
210
|
const plans = () => versions(plansDir).map(({ version, path }) => {
|
|
165
211
|
const file = parsePlanFile(readJson(path));
|
|
@@ -170,30 +216,74 @@ function makeSession(root, dir, anchor, diff) {
|
|
|
170
216
|
}
|
|
171
217
|
return { version, file, hash: planHash(file) };
|
|
172
218
|
});
|
|
173
|
-
const
|
|
219
|
+
const reviewsIn = (mode) => versions(reviewsDir(mode)).map(({ version, path }) => {
|
|
174
220
|
const review = parseReview(readJson(path));
|
|
175
221
|
if (typeof review === "string")
|
|
176
222
|
throw new SessionFileError(`${path}: ${review}`);
|
|
177
223
|
return { version, review };
|
|
178
224
|
});
|
|
225
|
+
const reviews = () => reviewsIn("split");
|
|
226
|
+
const walkReviews = () => [...reviews().filter((r) => r.review.mode === "walk"), ...reviewsIn("walk")].sort((a, b) => a.version - b.version);
|
|
227
|
+
const draftVersions = (mode) => {
|
|
228
|
+
const dir = reviewsDir(mode);
|
|
229
|
+
if (!existsSync(dir))
|
|
230
|
+
return [];
|
|
231
|
+
return readdirSync(dir).flatMap((name) => {
|
|
232
|
+
const n = Number(/^v([1-9][0-9]*)\.draft\.json$/.exec(name)?.[1]);
|
|
233
|
+
return isPlanVersion(n) ? [n] : [];
|
|
234
|
+
});
|
|
235
|
+
};
|
|
236
|
+
const draftReview = (mode, version) => {
|
|
237
|
+
const path = join(reviewsDir(mode), `v${version}.draft.json`);
|
|
238
|
+
const draft = parseDraft(readJson(path));
|
|
239
|
+
if (typeof draft === "string")
|
|
240
|
+
throw new SessionFileError(`${path}: ${draft}`);
|
|
241
|
+
const { comments, concerns, resolved } = draft;
|
|
242
|
+
return {
|
|
243
|
+
schema: "follow-me/review@1",
|
|
244
|
+
mode,
|
|
245
|
+
planVersion: version,
|
|
246
|
+
planHash: plans().find((p) => p.version === version)?.hash ?? "",
|
|
247
|
+
endedAt: statSync(path).mtime.toISOString(),
|
|
248
|
+
comments,
|
|
249
|
+
...(concerns.length === 0 ? {} : { concerns }),
|
|
250
|
+
...(resolved.length === 0 ? {} : { resolved }),
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
const priorReview = (mode, before) => {
|
|
254
|
+
const below = (v) => before === null || v < before;
|
|
255
|
+
const done = (mode === "walk" ? walkReviews() : reviews().filter((r) => r.review.mode === mode))
|
|
256
|
+
.filter((r) => below(r.version))
|
|
257
|
+
.at(-1);
|
|
258
|
+
const unfinished = draftVersions(mode)
|
|
259
|
+
.filter((v) => below(v) && v > (done?.version ?? 0))
|
|
260
|
+
.sort((a, b) => a - b)
|
|
261
|
+
.at(-1);
|
|
262
|
+
return unfinished === undefined ? (done?.review ?? null) : draftReview(mode, unfinished);
|
|
263
|
+
};
|
|
179
264
|
return {
|
|
180
265
|
root,
|
|
181
266
|
dir,
|
|
182
267
|
anchor,
|
|
183
268
|
diff,
|
|
184
269
|
draftPath,
|
|
270
|
+
promptPath,
|
|
185
271
|
plans,
|
|
272
|
+
reviewsDir,
|
|
186
273
|
reviews,
|
|
187
|
-
|
|
274
|
+
walkReviews,
|
|
275
|
+
lastReview: () => priorReview("split", null),
|
|
276
|
+
priorReview,
|
|
188
277
|
readDraft: () => {
|
|
189
278
|
const draft = readJson(draftPath);
|
|
190
279
|
if (draft === undefined)
|
|
191
|
-
throw new
|
|
280
|
+
throw new MissingDraftError(draftPath);
|
|
192
281
|
return draft;
|
|
193
282
|
},
|
|
194
283
|
writeDraft: (draft) => writeJson(draftPath, draft),
|
|
284
|
+
writePrompt: (text) => writeText(promptPath, text),
|
|
195
285
|
writeReview: (review) => {
|
|
196
|
-
const path = join(reviewsDir, `v${review.planVersion}.json`);
|
|
286
|
+
const path = join(reviewsDir(review.mode), `v${review.planVersion}.json`);
|
|
197
287
|
if (existsSync(path))
|
|
198
288
|
throw new SessionFileError(`${path} is already written`);
|
|
199
289
|
writeJson(path, review);
|
|
@@ -208,6 +298,9 @@ function makeSession(root, dir, anchor, diff) {
|
|
|
208
298
|
writeJson(join(plansDir, `v${version}.json`), serializePlanFile(plan.file));
|
|
209
299
|
return version;
|
|
210
300
|
},
|
|
301
|
+
lock: (command) => acquireLock(dir, anchor.branch, command),
|
|
302
|
+
adoptLock: (from, command) => adoptLock(dir, from, command),
|
|
303
|
+
lockState: () => readLock(join(dir, "lock")),
|
|
211
304
|
applied: () => {
|
|
212
305
|
const raw = readJson(appliedPath);
|
|
213
306
|
if (raw === undefined)
|
|
@@ -217,16 +310,30 @@ function makeSession(root, dir, anchor, diff) {
|
|
|
217
310
|
throw new SessionFileError(`${appliedPath} is not an applied record`);
|
|
218
311
|
return applied;
|
|
219
312
|
},
|
|
220
|
-
recordApplied: (
|
|
313
|
+
recordApplied: (plan, result) => writeJson(appliedPath, {
|
|
221
314
|
schema: "follow-me/applied@1",
|
|
222
|
-
version,
|
|
315
|
+
version: plan.version,
|
|
223
316
|
branch: result.branch,
|
|
224
317
|
head: result.head,
|
|
318
|
+
hash: plan.hash,
|
|
319
|
+
signed: result.signed,
|
|
225
320
|
}),
|
|
226
321
|
};
|
|
227
322
|
}
|
|
323
|
+
/** The top level of the work tree holding cwd. */
|
|
324
|
+
async function rootOf(cwd) {
|
|
325
|
+
try {
|
|
326
|
+
return await repoRoot(cwd);
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
if (error instanceof GitError && error.message.includes("not a git repository")) {
|
|
330
|
+
throw new NotARepositoryError();
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
228
335
|
async function locate(repo, branch) {
|
|
229
|
-
const root = await
|
|
336
|
+
const root = await rootOf(repo);
|
|
230
337
|
const name = branch ?? (await currentBranch(root));
|
|
231
338
|
if (name === null)
|
|
232
339
|
throw new Error("HEAD is detached; pass --branch <name>");
|
|
@@ -237,7 +344,7 @@ async function locate(repo, branch) {
|
|
|
237
344
|
writeFileSync(ignore, "*\n");
|
|
238
345
|
return { root, branch: name, dir: join(top, slugOf(name)) };
|
|
239
346
|
}
|
|
240
|
-
function
|
|
347
|
+
function readAnchorIn(dir) {
|
|
241
348
|
const path = join(dir, "session.json");
|
|
242
349
|
const raw = readJson(path);
|
|
243
350
|
if (raw === undefined)
|
|
@@ -247,10 +354,19 @@ function readAnchor(dir) {
|
|
|
247
354
|
throw new SessionFileError(`${path} is not a session anchor`);
|
|
248
355
|
return anchor;
|
|
249
356
|
}
|
|
357
|
+
function readAnchor(where) {
|
|
358
|
+
const anchor = readAnchorIn(where.dir);
|
|
359
|
+
if (anchor === null)
|
|
360
|
+
return null;
|
|
361
|
+
if (anchor.branch !== where.branch) {
|
|
362
|
+
throw new SessionFileError(`${where.dir} holds the session of branch ${anchor.branch}, and ${where.branch} maps to the same directory; rename one of the two branches`);
|
|
363
|
+
}
|
|
364
|
+
return anchor;
|
|
365
|
+
}
|
|
250
366
|
/** Anchors the session to the branch's current tip, creating it if needed. Plans and reviews stay. */
|
|
251
367
|
export async function extractSession(repo, opts = {}) {
|
|
252
368
|
const where = await locate(repo, opts.branch);
|
|
253
|
-
const baseRef = opts.base ?? readAnchor(where
|
|
369
|
+
const baseRef = opts.base ?? readAnchor(where)?.baseRef ?? (await defaultBaseRef(where.root));
|
|
254
370
|
const diff = await loadDiff(where.root, baseRef, where.branch);
|
|
255
371
|
const anchor = {
|
|
256
372
|
schema: "follow-me/session@1",
|
|
@@ -266,7 +382,7 @@ export async function extractSession(repo, opts = {}) {
|
|
|
266
382
|
/** Opens the session for a branch, creating it on first use. Throws if the branch moved since. */
|
|
267
383
|
export async function openSession(repo, opts = {}) {
|
|
268
384
|
const where = await locate(repo, opts.branch);
|
|
269
|
-
const anchor = readAnchor(where
|
|
385
|
+
const anchor = readAnchor(where);
|
|
270
386
|
if (anchor === null)
|
|
271
387
|
return extractSession(where.root, { ...opts, branch: where.branch });
|
|
272
388
|
const tip = await resolveCommit(where.root, where.branch);
|
|
@@ -276,3 +392,94 @@ export async function openSession(repo, opts = {}) {
|
|
|
276
392
|
const diff = await loadDiff(where.root, anchor.base, anchor.tip);
|
|
277
393
|
return makeSession(where.root, where.dir, anchor, diff);
|
|
278
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* Opens the session stored in a `<root>/.follow-me/<slug>` directory, as the review server does
|
|
397
|
+
* when a CLI registers it. Throws like openSession when the branch moved.
|
|
398
|
+
*/
|
|
399
|
+
export async function openSessionAt(dir) {
|
|
400
|
+
const top = dirname(dir);
|
|
401
|
+
if (basename(top) !== ".follow-me")
|
|
402
|
+
throw new SessionFileError(`${dir} is not a session directory`);
|
|
403
|
+
const anchor = readAnchorIn(dir);
|
|
404
|
+
if (anchor === null)
|
|
405
|
+
throw new SessionFileError(`${dir} has no session.json`);
|
|
406
|
+
if (slugOf(anchor.branch) !== basename(dir)) {
|
|
407
|
+
throw new SessionFileError(`${dir} holds the session of branch ${anchor.branch}`);
|
|
408
|
+
}
|
|
409
|
+
return openSession(dirname(top), { branch: anchor.branch });
|
|
410
|
+
}
|
|
411
|
+
/** "<name>.<pid>.<random>.tmp" as written by writeJson, the lock and the review server. */
|
|
412
|
+
function abandonedTemps(dir) {
|
|
413
|
+
if (!existsSync(dir))
|
|
414
|
+
return [];
|
|
415
|
+
return readdirSync(dir, { recursive: true, encoding: "utf8" })
|
|
416
|
+
.filter((name) => {
|
|
417
|
+
const pid = Number(/\.([0-9]+)\.[0-9a-f-]+\.tmp$/.exec(name)?.[1]);
|
|
418
|
+
return Number.isInteger(pid) && pid > 0 && !isAlive(pid);
|
|
419
|
+
})
|
|
420
|
+
.sort()
|
|
421
|
+
.map((name) => join(dir, name));
|
|
422
|
+
}
|
|
423
|
+
/** Review drafts for a version at or below the latest finished review; none can be resumed. */
|
|
424
|
+
function deadReviewDrafts(reviewsDir) {
|
|
425
|
+
const latest = versions(reviewsDir).at(-1)?.version ?? 0;
|
|
426
|
+
if (!existsSync(reviewsDir))
|
|
427
|
+
return [];
|
|
428
|
+
return readdirSync(reviewsDir)
|
|
429
|
+
.filter((name) => {
|
|
430
|
+
const n = Number(/^v([1-9][0-9]*)\.draft\.json$/.exec(name)?.[1]);
|
|
431
|
+
return Number.isInteger(n) && n <= latest;
|
|
432
|
+
})
|
|
433
|
+
.sort()
|
|
434
|
+
.map((name) => join(reviewsDir, name));
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Finds what follow-me left behind in this worktree: sessions whose branch is gone, review drafts
|
|
438
|
+
* no review can resume, locks and temp files of dead processes, and object quarantines of dead
|
|
439
|
+
* applies. With all, the whole .follow-me/ directory. Removes them unless dryRun. Sessions held by
|
|
440
|
+
* a live process are left alone, and all refuses while one exists.
|
|
441
|
+
*/
|
|
442
|
+
export async function cleanSessions(repo, opts) {
|
|
443
|
+
const root = await rootOf(repo);
|
|
444
|
+
const top = join(root, ".follow-me");
|
|
445
|
+
const sessions = existsSync(top)
|
|
446
|
+
? readdirSync(top, { withFileTypes: true })
|
|
447
|
+
.filter((e) => e.isDirectory())
|
|
448
|
+
.map((e) => join(top, e.name))
|
|
449
|
+
.sort()
|
|
450
|
+
: [];
|
|
451
|
+
const found = [];
|
|
452
|
+
for (const dir of sessions) {
|
|
453
|
+
const lock = readLock(join(dir, "lock"));
|
|
454
|
+
if (lock.kind === "held") {
|
|
455
|
+
if (!opts.all)
|
|
456
|
+
continue;
|
|
457
|
+
throw new SessionLockedError(`${dir} is busy: pid ${lock.pid} is running follow-me ${lock.command}; wait for it to finish or stop it`);
|
|
458
|
+
}
|
|
459
|
+
if (opts.all)
|
|
460
|
+
continue;
|
|
461
|
+
const anchor = readAnchorIn(dir);
|
|
462
|
+
if (anchor !== null && !(await branchExists(root, anchor.branch))) {
|
|
463
|
+
found.push(dir);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (lock.kind === "stale")
|
|
467
|
+
found.push(join(dir, "lock"));
|
|
468
|
+
const reviews = join(dir, "reviews");
|
|
469
|
+
found.push(...abandonedTemps(dir), ...deadReviewDrafts(reviews), ...deadReviewDrafts(join(reviews, "walk")));
|
|
470
|
+
}
|
|
471
|
+
if (opts.all && existsSync(top))
|
|
472
|
+
found.push(top);
|
|
473
|
+
const objects = await objectsDir(root);
|
|
474
|
+
for (const name of readdirSync(objects).sort()) {
|
|
475
|
+
if (!name.startsWith(QUARANTINE_PREFIX))
|
|
476
|
+
continue;
|
|
477
|
+
const pid = Number(name.slice(QUARANTINE_PREFIX.length).split("-")[0]);
|
|
478
|
+
if (Number.isInteger(pid) && !isAlive(pid))
|
|
479
|
+
found.push(join(objects, name));
|
|
480
|
+
}
|
|
481
|
+
if (!opts.dryRun)
|
|
482
|
+
for (const path of found)
|
|
483
|
+
rmSync(path, { recursive: true, force: true });
|
|
484
|
+
return found;
|
|
485
|
+
}
|