@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.
@@ -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 { isRepoPath, isSha, isUnitId } from "./diff.js";
5
- import { currentBranch, defaultBaseRef, loadDiff, repoRoot, resolveCommit } from "./git.js";
6
- import { isArray, isCommentId, isFields, isPlanVersion, parsePlanFile, planHash, serializePlanFile, } from "./plan.js";
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 writeJson(path, value) {
137
+ function writeText(path, text) {
18
138
  mkdirSync(dirname(path), { recursive: true });
19
139
  const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
20
- writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`);
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
- return { schema, version, branch, head };
74
- }
75
- function isCommitIndex(v) {
76
- return typeof v === "number" && Number.isInteger(v) && v >= 0;
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 reviewsDir = join(dir, "reviews");
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 reviews = () => versions(reviewsDir).map(({ version, path }) => {
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
- lastReview: () => reviews().findLast((r) => r.review.mode === "split")?.review ?? null,
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 SessionFileError(`${draftPath} does not exist yet`);
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: (version, result) => writeJson(appliedPath, {
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 repoRoot(repo);
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 readAnchor(dir) {
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.dir)?.baseRef ?? (await defaultBaseRef(where.root));
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.dir);
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
+ }