Parent directory

session-file.ts

10671 bytes
  1import { randomUUID } from "node:crypto";
  2import { chmod, link, lstat, open, readdir, readFile, rename, unlink } from "node:fs/promises";
  3import { basename, dirname, join, resolve } from "node:path";
  4import {
  5  toEntryId,
  6  treeEntryFromUnknown,
  7  type BranchCandidate,
  8  type EntryId,
  9  type TreeEntry,
 10} from "./tree-model.ts";
 11
 12declare const sessionFilePathBrand: unique symbol;
 13
 14export type SessionFilePath = string & { readonly [sessionFilePathBrand]: "SessionFilePath" };
 15
 16export interface FileIdentity {
 17  readonly dev: number;
 18  readonly ino: number;
 19  readonly mode: number;
 20  readonly mtimeMs: number;
 21  readonly size: number;
 22}
 23
 24export interface SessionFileSnapshot {
 25  readonly path: SessionFilePath;
 26  readonly content: string;
 27  readonly identity: FileIdentity;
 28}
 29
 30interface RawLine {
 31  readonly content: string;
 32  readonly record?: Record<string, unknown>;
 33}
 34
 35export function toSessionFilePath(path: string): SessionFilePath {
 36  return resolve(path) as SessionFilePath;
 37}
 38
 39export async function captureSessionFile(sessionFile: string): Promise<SessionFileSnapshot> {
 40  const path = toSessionFilePath(sessionFile);
 41
 42  for (let attempt = 0; attempt < 2; attempt += 1) {
 43    const before = await getFileIdentity(path);
 44    const content = await readFile(path, "utf8");
 45    const after = await getFileIdentity(path);
 46
 47    if (sameIdentity(before, after) && Buffer.byteLength(content) === after.size) {
 48      return { path, content, identity: after };
 49    }
 50  }
 51
 52  throw new Error("Session file changed while it was read");
 53}
 54
 55export async function assertSessionFileUnchanged(snapshot: SessionFileSnapshot): Promise<void> {
 56  const current = await captureSessionFile(snapshot.path);
 57  if (!sameIdentity(snapshot.identity, current.identity) || snapshot.content !== current.content) {
 58    throw new Error("Session file changed while the dialog was open");
 59  }
 60}
 61
 62export function getSessionTreeEntries(snapshot: SessionFileSnapshot): TreeEntry[] {
 63  const entries: TreeEntry[] = [];
 64  for (const rawLine of splitRawLines(snapshot.content)) {
 65    const entry = treeEntryFromUnknown(rawLine.record);
 66    if (entry) entries.push(entry);
 67  }
 68  return entries;
 69}
 70
 71export function buildPrunedSessionContent(
 72  snapshot: SessionFileSnapshot,
 73  candidates: readonly BranchCandidate[],
 74): string {
 75  const expectedEntries = collectPruneEntries(candidates);
 76  const rawLines = splitRawLines(snapshot.content);
 77  const linesByEntryId = new Map<EntryId, number[]>();
 78
 79  for (let index = 0; index < rawLines.length; index += 1) {
 80    const rawLine = rawLines[index];
 81    if (!rawLine?.record) continue;
 82
 83    const id = rawLine.record.id;
 84    if (typeof id !== "string" || id.length === 0) continue;
 85
 86    const entryId = toEntryId(id);
 87    if (!expectedEntries.has(entryId)) continue;
 88
 89    const indexes = linesByEntryId.get(entryId) ?? [];
 90    indexes.push(index);
 91    linesByEntryId.set(entryId, indexes);
 92  }
 93
 94  const lineIndexesToRemove = new Set<number>();
 95  for (const [id, entry] of expectedEntries) {
 96    const indexes = linesByEntryId.get(id) ?? [];
 97    if (indexes.length !== 1) {
 98      throw new Error(`Could not safely locate exactly one JSONL line for entry ${id}`);
 99    }
100
101    const lineIndex = indexes[0];
102    if (lineIndex === undefined) {
103      throw new Error(`Could not safely locate JSONL line for entry ${id}`);
104    }
105
106    const rawLine = rawLines[lineIndex];
107    if (!rawLine?.record || !hasSameEntryIdentity(entryIdentityFromRecord(rawLine.record), entry)) {
108      throw new Error(`JSONL line for entry ${id} does not match the loaded session`);
109    }
110
111    lineIndexesToRemove.add(lineIndex);
112  }
113
114  return rawLines
115    .filter((_line, index) => !lineIndexesToRemove.has(index))
116    .map((line) => line.content)
117    .join("");
118}
119
120export function createBackupPath(sessionFile: SessionFilePath): SessionFilePath {
121  const path = toSessionFilePath(sessionFile);
122  const timestamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
123  return toSessionFilePath(
124    join(dirname(path), `${basename(path)}.tree-prune-backup-${timestamp}-${randomUUID()}`),
125  );
126}
127
128export async function createSessionBackup(
129  snapshot: SessionFileSnapshot,
130  backupPath = createBackupPath(snapshot.path),
131): Promise<SessionFilePath> {
132  const path = toSessionFilePath(backupPath);
133  if (dirname(path) !== dirname(snapshot.path)) {
134    throw new Error("Backup must be in the session file directory");
135  }
136
137  await writeNewFileAtomically(path, snapshot.content, snapshot.identity.mode);
138  return path;
139}
140
141export function countPruneEntries(candidates: readonly BranchCandidate[]): number {
142  return collectPruneEntries(candidates).size;
143}
144
145export async function pruneSessionFile(
146  snapshot: SessionFileSnapshot,
147  candidates: readonly BranchCandidate[],
148  backupPath = createBackupPath(snapshot.path),
149): Promise<{ backupPath: SessionFilePath; removedEntryCount: number }> {
150  const removedEntryCount = countPruneEntries(candidates);
151  const prunedContent = buildPrunedSessionContent(snapshot, candidates);
152  await assertSessionFileUnchanged(snapshot);
153  const createdBackupPath = await createSessionBackup(snapshot, backupPath);
154  await replaceSessionFile(snapshot, prunedContent);
155
156  return { backupPath: createdBackupPath, removedEntryCount };
157}
158
159export async function restoreSessionBackup(
160  currentSnapshot: SessionFileSnapshot,
161  selectedBackup: SessionFileSnapshot,
162  backupPath = createBackupPath(currentSnapshot.path),
163): Promise<SessionFilePath> {
164  if (dirname(selectedBackup.path) !== dirname(currentSnapshot.path)) {
165    throw new Error("Backup must be in the session file directory");
166  }
167
168  await assertSessionFileUnchanged(currentSnapshot);
169  await assertSessionFileUnchanged(selectedBackup);
170  const currentBackupPath = await createSessionBackup(currentSnapshot, backupPath);
171  await replaceSessionFile(currentSnapshot, selectedBackup.content);
172
173  return currentBackupPath;
174}
175
176export async function listSessionBackups(sessionFile: SessionFilePath): Promise<SessionFilePath[]> {
177  const path = toSessionFilePath(sessionFile);
178  const directory = dirname(path);
179  const prefix = `${basename(path)}.tree-prune-backup-`;
180  const directoryEntries = await readdir(directory, { withFileTypes: true });
181
182  return directoryEntries
183    .filter((entry) => entry.isFile() && entry.name.startsWith(prefix))
184    .map((entry) => toSessionFilePath(join(directory, entry.name)))
185    .sort((left, right) => right.localeCompare(left));
186}
187
188async function replaceSessionFile(snapshot: SessionFileSnapshot, content: string): Promise<void> {
189  const temporaryPath = temporaryPathFor(snapshot.path);
190
191  try {
192    await writeFileAndSync(temporaryPath, content, snapshot.identity.mode);
193    await assertSessionFileUnchanged(snapshot);
194    await rename(temporaryPath, snapshot.path);
195  } catch (error) {
196    await removeIfPresent(temporaryPath);
197    throw error;
198  }
199}
200
201async function writeNewFileAtomically(path: string, content: string, mode: number): Promise<void> {
202  const temporaryPath = temporaryPathFor(path);
203
204  try {
205    await writeFileAndSync(temporaryPath, content, mode);
206    await link(temporaryPath, path);
207  } finally {
208    await removeIfPresent(temporaryPath);
209  }
210}
211
212async function writeFileAndSync(path: string, content: string, mode: number): Promise<void> {
213  const file = await open(path, "wx", mode);
214  try {
215    await file.writeFile(content, "utf8");
216    await file.sync();
217  } finally {
218    await file.close();
219  }
220
221  await chmod(path, mode);
222}
223
224async function removeIfPresent(path: string): Promise<void> {
225  try {
226    await unlink(path);
227  } catch (error) {
228    if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
229  }
230}
231
232async function getFileIdentity(path: string): Promise<FileIdentity> {
233  const stats = await lstat(path);
234  if (!stats.isFile() || stats.isSymbolicLink()) {
235    throw new Error("Session file must be a regular file");
236  }
237
238  return {
239    dev: stats.dev,
240    ino: stats.ino,
241    mode: stats.mode,
242    mtimeMs: stats.mtimeMs,
243    size: stats.size,
244  };
245}
246
247function sameIdentity(left: FileIdentity, right: FileIdentity): boolean {
248  return (
249    left.dev === right.dev &&
250    left.ino === right.ino &&
251    left.mode === right.mode &&
252    left.mtimeMs === right.mtimeMs &&
253    left.size === right.size
254  );
255}
256
257function splitRawLines(content: string): RawLine[] {
258  const lines: RawLine[] = [];
259  let start = 0;
260
261  while (start < content.length) {
262    const lineEnd = content.indexOf("\n", start);
263    const end = lineEnd === -1 ? content.length : lineEnd + 1;
264    const line = content.slice(start, end);
265    const record = parseRecord(line);
266    lines.push(record ? { content: line, record } : { content: line });
267    start = end;
268  }
269
270  return lines;
271}
272
273function parseRecord(line: string): Record<string, unknown> | undefined {
274  const source = line.endsWith("\n") ? line.slice(0, -1) : line;
275  if (source.trim().length === 0) return undefined;
276
277  try {
278    const parsed: unknown = JSON.parse(source);
279    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
280    return parsed as Record<string, unknown>;
281  } catch {
282    return undefined;
283  }
284}
285
286function collectPruneEntries(candidates: readonly BranchCandidate[]): Map<EntryId, TreeEntry> {
287  const entriesById = new Map<EntryId, TreeEntry>();
288
289  for (const candidate of candidates) {
290    for (const entry of candidate.entries) {
291      const existingEntry = entriesById.get(entry.id);
292      if (existingEntry && !hasSameEntryIdentity(existingEntry, entry)) {
293        throw new Error(`Selected branches disagree about entry ${entry.id}`);
294      }
295      entriesById.set(entry.id, entry);
296    }
297  }
298
299  if (entriesById.size === 0) throw new Error("Select at least one branch");
300  return entriesById;
301}
302
303interface EntryIdentity {
304  readonly id: EntryId;
305  readonly parentId: EntryId | null;
306  readonly type: string;
307}
308
309function entryIdentityFromRecord(record: Record<string, unknown>): EntryIdentity | undefined {
310  const { id, parentId, type } = record;
311  if (
312    typeof id !== "string" ||
313    id.length === 0 ||
314    (typeof parentId !== "string" && parentId !== null) ||
315    typeof type !== "string" ||
316    type.length === 0
317  ) {
318    return undefined;
319  }
320
321  return {
322    id: id as EntryId,
323    parentId: parentId as EntryId | null,
324    type,
325  };
326}
327
328function hasSameEntryIdentity(
329  left: EntryIdentity | undefined,
330  right: EntryIdentity,
331): boolean {
332  if (!left) return false;
333  return left.id === right.id && left.parentId === right.parentId && left.type === right.type;
334}
335
336function temporaryPathFor(path: string): string {
337  return join(dirname(path), `.${basename(path)}.tree-prune-tmp-${process.pid}-${randomUUID()}`);
338}