session-file.ts
8038 bytes
1import { randomUUID } from "node:crypto";
2import { chmod, lstat, open, 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 countPruneEntries(candidates: readonly BranchCandidate[]): number {
121 return collectPruneEntries(candidates).size;
122}
123
124export async function pruneSessionFile(
125 snapshot: SessionFileSnapshot,
126 candidates: readonly BranchCandidate[],
127): Promise<void> {
128 const prunedContent = buildPrunedSessionContent(snapshot, candidates);
129 await replaceSessionFile(snapshot, prunedContent);
130}
131
132async function replaceSessionFile(snapshot: SessionFileSnapshot, content: string): Promise<void> {
133 const temporaryPath = temporaryPathFor(snapshot.path);
134
135 try {
136 await writeFileAndSync(temporaryPath, content, snapshot.identity.mode);
137 await assertSessionFileUnchanged(snapshot);
138 await rename(temporaryPath, snapshot.path);
139 } catch (error) {
140 await removeIfPresent(temporaryPath);
141 throw error;
142 }
143}
144
145async function writeFileAndSync(path: string, content: string, mode: number): Promise<void> {
146 const file = await open(path, "wx", mode);
147 try {
148 await file.writeFile(content, "utf8");
149 await file.sync();
150 } finally {
151 await file.close();
152 }
153
154 await chmod(path, mode);
155}
156
157async function removeIfPresent(path: string): Promise<void> {
158 try {
159 await unlink(path);
160 } catch (error) {
161 if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
162 }
163}
164
165async function getFileIdentity(path: string): Promise<FileIdentity> {
166 const stats = await lstat(path);
167 if (!stats.isFile() || stats.isSymbolicLink()) {
168 throw new Error("Session file must be a regular file");
169 }
170
171 return {
172 dev: stats.dev,
173 ino: stats.ino,
174 mode: stats.mode,
175 mtimeMs: stats.mtimeMs,
176 size: stats.size,
177 };
178}
179
180function sameIdentity(left: FileIdentity, right: FileIdentity): boolean {
181 return (
182 left.dev === right.dev &&
183 left.ino === right.ino &&
184 left.mode === right.mode &&
185 left.mtimeMs === right.mtimeMs &&
186 left.size === right.size
187 );
188}
189
190function splitRawLines(content: string): RawLine[] {
191 const lines: RawLine[] = [];
192 let start = 0;
193
194 while (start < content.length) {
195 const lineEnd = content.indexOf("\n", start);
196 const end = lineEnd === -1 ? content.length : lineEnd + 1;
197 const line = content.slice(start, end);
198 const record = parseRecord(line);
199 lines.push(record ? { content: line, record } : { content: line });
200 start = end;
201 }
202
203 return lines;
204}
205
206function parseRecord(line: string): Record<string, unknown> | undefined {
207 const source = line.endsWith("\n") ? line.slice(0, -1) : line;
208 if (source.trim().length === 0) return undefined;
209
210 try {
211 const parsed: unknown = JSON.parse(source);
212 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
213 return parsed as Record<string, unknown>;
214 } catch {
215 return undefined;
216 }
217}
218
219function collectPruneEntries(candidates: readonly BranchCandidate[]): Map<EntryId, TreeEntry> {
220 const entriesById = new Map<EntryId, TreeEntry>();
221
222 for (const candidate of candidates) {
223 for (const entry of candidate.entries) {
224 const existingEntry = entriesById.get(entry.id);
225 if (existingEntry && !hasSameEntryIdentity(existingEntry, entry)) {
226 throw new Error(`Selected branches disagree about entry ${entry.id}`);
227 }
228 entriesById.set(entry.id, entry);
229 }
230 }
231
232 if (entriesById.size === 0) throw new Error("Select at least one branch");
233 return entriesById;
234}
235
236interface EntryIdentity {
237 readonly id: EntryId;
238 readonly parentId: EntryId | null;
239 readonly type: string;
240}
241
242function entryIdentityFromRecord(record: Record<string, unknown>): EntryIdentity | undefined {
243 const { id, parentId, type } = record;
244 if (
245 typeof id !== "string" ||
246 id.length === 0 ||
247 (typeof parentId !== "string" && parentId !== null) ||
248 typeof type !== "string" ||
249 type.length === 0
250 ) {
251 return undefined;
252 }
253
254 return {
255 id: id as EntryId,
256 parentId: parentId as EntryId | null,
257 type,
258 };
259}
260
261function hasSameEntryIdentity(
262 left: EntryIdentity | undefined,
263 right: EntryIdentity,
264): boolean {
265 if (!left) return false;
266 return left.id === right.id && left.parentId === right.parentId && left.type === right.type;
267}
268
269function temporaryPathFor(path: string): string {
270 return join(dirname(path), `.${basename(path)}.tree-prune-tmp-${process.pid}-${randomUUID()}`);
271}