@lincyaw/workspace 0.0.0-stage → 1.0.0-rc.1

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.
Files changed (44) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +35 -2
  3. package/package.json +41 -3
  4. package/src/domain/commit-message.ts +80 -0
  5. package/src/domain/locks.ts +5 -0
  6. package/src/domain/paths.ts +98 -0
  7. package/src/index.ts +11 -0
  8. package/src/node/authoring-clients.ts +206 -0
  9. package/src/node/build.ts +42 -0
  10. package/src/node/compile-session.ts +187 -0
  11. package/src/node/config.ts +141 -0
  12. package/src/node/document-assets.ts +30 -0
  13. package/src/node/document-audio.ts +473 -0
  14. package/src/node/document-authoring.ts +436 -0
  15. package/src/node/document-editor.ts +157 -0
  16. package/src/node/document-history.ts +190 -0
  17. package/src/node/document-images.ts +241 -0
  18. package/src/node/document-operations.ts +191 -0
  19. package/src/node/document-transcription-adoption.ts +96 -0
  20. package/src/node/document-transcription.ts +471 -0
  21. package/src/node/document-voice.ts +115 -0
  22. package/src/node/errors.ts +23 -0
  23. package/src/node/git.ts +68 -0
  24. package/src/node/jobs.ts +361 -0
  25. package/src/node/locks.ts +46 -0
  26. package/src/node/page-delete.ts +106 -0
  27. package/src/node/project-files.ts +163 -0
  28. package/src/node/projects.ts +192 -0
  29. package/src/node/review/agent-session-reviews.ts +374 -0
  30. package/src/node/review/git-guard.ts +104 -0
  31. package/src/node/review/git-review.ts +428 -0
  32. package/src/node/review/record.ts +77 -0
  33. package/src/node/review/routes.ts +73 -0
  34. package/src/node/review/task-reviews.ts +50 -0
  35. package/src/node/revisions.ts +599 -0
  36. package/src/node/routes.ts +329 -0
  37. package/src/node/serve.ts +503 -0
  38. package/src/node/site.ts +132 -0
  39. package/src/node/source-editor.ts +332 -0
  40. package/src/node/speech-settings.ts +21 -0
  41. package/src/node/templates.ts +27 -0
  42. package/src/node/workbench-api.ts +176 -0
  43. package/src/node/writer.ts +389 -0
  44. package/templates/document/default/lecture.json +75 -0
@@ -0,0 +1,599 @@
1
+ import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, rmdirSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join, relative, resolve, sep } from 'node:path'
4
+ import type { DocumentFileChange, HistoryRevision, Member, RevertResult, Revision, RevisionsAnswer, RevisionService, UncommittedFile } from '@lincyaw/kernel'
5
+ import { leavesRoot } from '../domain/paths.ts'
6
+ import { commitMessage, memberAddress, originOf } from '../domain/commit-message.ts'
7
+ import type { CommitOrigin } from '../domain/commit-message.ts'
8
+ import type { WorkspaceConfig } from './config.ts'
9
+ import { isDocumentDir } from './config.ts'
10
+ import { RequestError } from './errors.ts'
11
+ import type { Git } from './git.ts'
12
+ import { documentDirOf, isGeneratedPath, makeDocumentDir, writeChanges } from './writer.ts'
13
+ import type { DocumentWriter } from './writer.ts'
14
+
15
+ const FIELD = '\x00'
16
+ const RECORD = '\x1e'
17
+ const FORMAT = '--format=%x1e%H%x00%an <%ae>%x00%aI%x00%B%x00'
18
+
19
+ interface LogEntry {
20
+ id: string
21
+ author: string
22
+ date: string
23
+ message: string
24
+ files: string[]
25
+ root?: true
26
+ }
27
+
28
+ function parseLog(output: string): LogEntry[] {
29
+ return output.split(RECORD).filter(Boolean).map(record => {
30
+ const [id = '', author = '', date = '', message = '', ...paths] = record.split(FIELD)
31
+ const first = paths.findIndex(path => path !== '')
32
+ // Git inserts one LF between the formatted header and its NUL-delimited paths.
33
+ if (first >= 0 && paths[first]!.startsWith('\n')) paths[first] = paths[first]!.slice(1)
34
+ return { id, author, date, message: message.trim(), files: paths.filter(Boolean) }
35
+ })
36
+ }
37
+
38
+ function revertedOf(message: string): string | undefined {
39
+ return originOf(message)?.revert ?? /This reverts commit ([0-9a-f]{7,64})/.exec(message)?.[1]
40
+ }
41
+
42
+ function finder(entries: LogEntry[]): (id: string) => LogEntry | undefined {
43
+ return id => entries.find(entry => entry.id === id || (id.length >= 7 && entry.id.startsWith(id)))
44
+ }
45
+
46
+ function redoPredicate(entries: LogEntry[]): (entry: LogEntry) => boolean {
47
+ const find = finder(entries)
48
+ const known = new Map<string, boolean>()
49
+ const redo = (entry: LogEntry): boolean => {
50
+ let found = known.get(entry.id)
51
+ if (found !== undefined) return found
52
+ known.set(entry.id, false)
53
+ const reverted = revertedOf(entry.message)
54
+ const target = reverted === undefined ? undefined : find(reverted)
55
+ found = target !== undefined && revertedOf(target.message) !== undefined && !redo(target)
56
+ known.set(entry.id, found)
57
+ return found
58
+ }
59
+ return redo
60
+ }
61
+
62
+ export type UndoTargets = Pick<RevisionsAnswer, 'undo' | 'redo'>
63
+
64
+ /**
65
+ * The change the next undo takes back and the undo the next redo takes back. Over the Presmium revisions, oldest
66
+ * first: a change goes on the done stack and empties the undone one; an undo takes its change off done and goes on
67
+ * undone; a redo takes its undo off undone and puts the change back on done. A document operation empties both.
68
+ */
69
+ function undoTargets(entries: LogEntry[]): UndoTargets {
70
+ const find = finder(entries)
71
+ const isRedo = redoPredicate(entries)
72
+ const changeOf = (entry: LogEntry): string => {
73
+ let current = entry
74
+ for (let hops = 0; hops < entries.length; hops++) {
75
+ const reverted = revertedOf(current.message)
76
+ const next = reverted === undefined ? undefined : find(reverted)
77
+ if (!next) break
78
+ current = next
79
+ }
80
+ return current.id
81
+ }
82
+ const done: string[] = []
83
+ const undone: string[] = []
84
+ const drop = (stack: string[], id: string) => {
85
+ const at = stack.lastIndexOf(id)
86
+ if (at >= 0) stack.splice(at, 1)
87
+ }
88
+ for (const entry of [...entries].reverse()) {
89
+ const origin = originOf(entry.message)
90
+ if (!origin) continue
91
+ if (entry.root) { done.length = 0; undone.length = 0; continue }
92
+ if (origin.operation) {
93
+ done.length = 0
94
+ undone.length = 0
95
+ continue
96
+ }
97
+ const reverted = revertedOf(entry.message)
98
+ const target = reverted === undefined ? undefined : find(reverted)
99
+ if (!target) {
100
+ done.push(entry.id)
101
+ undone.length = 0
102
+ } else if (isRedo(entry)) {
103
+ drop(undone, target.id)
104
+ done.push(changeOf(target))
105
+ } else {
106
+ drop(done, changeOf(target))
107
+ undone.push(entry.id)
108
+ }
109
+ }
110
+ return { ...done.length ? { undo: done.at(-1)! } : {}, ...undone.length ? { redo: undone.at(-1)! } : {} }
111
+ }
112
+
113
+ function listed(entries: LogEntry[], filter: { task?: string, limit?: number }): HistoryRevision[] {
114
+ const revertedBy = new Map<string, string>()
115
+ for (const entry of entries) {
116
+ const reverted = revertedOf(entry.message)
117
+ if (reverted && !revertedBy.has(reverted)) revertedBy.set(reverted, entry.id)
118
+ }
119
+ const isRedo = redoPredicate(entries)
120
+ const revisions = entries.flatMap((entry) => {
121
+ const found = revision(entry, revertedBy.get(entry.id), isRedo(entry))
122
+ return found && (!filter.task || found.origin.task === filter.task) ? [found] : []
123
+ })
124
+ const roots = new Map<string, string>(), parity = new Map<string, boolean>()
125
+ const states = new Map<string, { undone: boolean, target: string }>()
126
+ for (const entry of [...revisions].reverse()) {
127
+ const root = entry.reverts ? roots.get(entry.reverts) : entry.id
128
+ if (!root) continue
129
+ const undone = entry.reverts ? !parity.get(entry.reverts) : false
130
+ roots.set(entry.id, root); parity.set(entry.id, undone)
131
+ states.set(root, { undone, target: undone ? entry.id : root })
132
+ }
133
+ const complete = revisions.map(entry => {
134
+ const state = !entry.reverts && states.get(entry.id)
135
+ return state && entry.revertible !== false ? { ...entry, undone: state.undone, revertTarget: state.target } : entry
136
+ })
137
+ return filter.limit ? complete.slice(0, filter.limit) : complete
138
+ }
139
+
140
+ function revision(entry: LogEntry, revertedBy?: string, redo = false): HistoryRevision | undefined {
141
+ const origin = originOf(entry.message)
142
+ if (!origin) return undefined
143
+ const { revert, coAuthor: _coAuthor, operation, ...plain } = origin
144
+ return {
145
+ id: entry.id,
146
+ message: entry.message,
147
+ author: entry.author,
148
+ date: entry.date,
149
+ origin: plain,
150
+ files: entry.files,
151
+ ...revertedBy ? { revertedBy } : {},
152
+ ...operation ? { operation } : {},
153
+ ...revert ? { reverts: revert } : {},
154
+ ...revert && redo ? { redo: true as const } : {},
155
+ ...entry.root ? { revertible: false as const } : {},
156
+ }
157
+ }
158
+
159
+ interface TreeFile {
160
+ path: string
161
+ mode: string
162
+ oid: string
163
+ }
164
+
165
+ export interface RevisionFiles {
166
+ config: WorkspaceConfig
167
+ writer: DocumentWriter
168
+ }
169
+
170
+ export interface LockedCommit {
171
+ paths: string[]
172
+ summary: string
173
+ body: string
174
+ origin: CommitOrigin
175
+ /** The commit this one undoes. */
176
+ revert?: string
177
+ trailers?: [string, string][]
178
+ }
179
+
180
+ export class Revisions implements RevisionService {
181
+ readonly #git: Git
182
+ readonly #files: RevisionFiles
183
+
184
+ constructor(git: Git, files: RevisionFiles) {
185
+ this.#git = git
186
+ this.#files = files
187
+ }
188
+
189
+ /** One authoring transaction holds the git queue and restores its index if file saving or committing fails. */
190
+ transaction<T>(work: () => Promise<T>): Promise<T> {
191
+ return this.#git.exclusive(async () => {
192
+ const index = resolve(this.#git.root, (await this.#git.output(['rev-parse', '--git-path', 'index'])).trim())
193
+ const before = existsSync(index) ? readFileSync(index) : undefined
194
+ try {
195
+ return await work()
196
+ } catch (error) {
197
+ if (before) writeFileSync(index, before)
198
+ else rmSync(index, { force: true })
199
+ throw error
200
+ }
201
+ })
202
+ }
203
+
204
+ commit(change: { files: string[], message: string, origin: CommitOrigin, trailers?: [string, string][] }): Promise<Revision | undefined> {
205
+ const [summary = '', ...body] = change.message.trim().split('\n')
206
+ return this.transaction(() => this.commitWhileLocked({ paths: change.files, summary, body: body.join('\n'), origin: change.origin, ...change.trailers ? { trailers: change.trailers } : {} }))
207
+ }
208
+
209
+ /** Commits the paths; the caller holds the git lock (`Git.exclusive`). */
210
+ async commitWhileLocked(commit: LockedCommit): Promise<Revision | undefined> {
211
+ const { summary, body, origin } = commit
212
+ const unique = [...new Set(commit.paths.map(path => path.replace(/^\.?\/+|\/+$/g, '')).filter(path => path && !leavesRoot(path)))]
213
+ if (unique.length === 0) return undefined
214
+ await this.#git.output(['add', '-A', '--', ...unique], { GIT_LITERAL_PATHSPECS: '1' })
215
+ const staged = (await this.#git.output(['diff', '--cached', '--name-only', '--', ...unique], { GIT_LITERAL_PATHSPECS: '1' })).trim()
216
+ if (!staged) return undefined
217
+ const message = withTrailers(commitMessage(summary, body, origin, commit.revert), commit.trailers ?? [])
218
+ await this.#git.output([...await this.#identity(), 'commit', '-q', '--no-verify', '-m', message, ...this.#author(origin.member), '--', ...unique], { GIT_LITERAL_PATHSPECS: '1' })
219
+ return this.#revision('HEAD')
220
+ }
221
+
222
+ async status(paths: string[] = ['']): Promise<{ branch?: string, uncommitted: number }> {
223
+ const branch = await this.#git.tryOutput(['symbolic-ref', '--quiet', '--short', 'HEAD'])
224
+ return { ...branch.ok ? { branch: branch.output.trim() } : {}, uncommitted: (await this.#statusOf(paths)).length }
225
+ }
226
+
227
+ async uncommittedFiles(paths: string[]): Promise<UncommittedFile[]> {
228
+ const specs = paths.map(path => path || '.')
229
+ const status = await this.#statusOf(paths)
230
+ const counts = new Map<string, [number, number]>()
231
+ const numstat = await this.#git.tryOutput(['diff', '--numstat', '-z', '--no-renames', 'HEAD', '--', ...specs], { GIT_LITERAL_PATHSPECS: '1' })
232
+ if (numstat.ok) {
233
+ for (const entry of numstat.output.split('\0').filter(Boolean)) {
234
+ const [added = '-', removed = '-', path = ''] = entry.split('\t')
235
+ if (added !== '-') counts.set(this.#fromTop(path), [Number(added), Number(removed)])
236
+ }
237
+ }
238
+ return status.map(({ code, path }): UncommittedFile => {
239
+ const kind = code.includes('D') ? 'deleted' : code === '??' || code[0] === 'A' ? 'added' : 'modified'
240
+ const counted = counts.get(path) ?? (kind === 'added' ? lineCount(join(this.#files.config.root, path)) : undefined)
241
+ return { path, kind, ...counted ? { added: counted[0], removed: counted[1] } : {} }
242
+ }).sort((a, b) => a.path.localeCompare(b.path))
243
+ }
244
+
245
+ discard(paths: string[]): Promise<void> {
246
+ return this.#git.exclusive(async () => {
247
+ const files: TreeFile[] = []
248
+ for (const path of paths) {
249
+ const listed = await this.#git.tryOutput(['ls-tree', '-z', 'HEAD', '--', path], { GIT_LITERAL_PATHSPECS: '1' })
250
+ const [meta = '', named] = (listed.ok ? listed.output.split('\0')[0] ?? '' : '').split('\t')
251
+ const [mode = '', , oid = ''] = meta.split(' ')
252
+ files.push(named && mode ? { path, mode, oid } : { path, mode: '000000', oid: '' })
253
+ }
254
+ await this.#write(files)
255
+ })
256
+ }
257
+
258
+ async history(filter: { document?: string, task?: string, limit?: number } = {}): Promise<HistoryRevision[]> {
259
+ return listed(await this.#entries(filter.document), filter)
260
+ }
261
+
262
+ /** The history and the revisions its next undo and redo take back, which count the document's whole history whatever the limit. */
263
+ async historyWithUndo(filter: { document?: string, task?: string, limit?: number } = {}): Promise<RevisionsAnswer> {
264
+ const entries = await this.#entries(filter.document)
265
+ return { revisions: listed(entries, filter), ...undoTargets(entries) }
266
+ }
267
+
268
+ /** The revision the next undo or redo of the document's history takes back, read under the git lock. */
269
+ next(which: keyof UndoTargets, document?: string): Promise<string | undefined> {
270
+ return this.#git.exclusive(async () => undoTargets(await this.#entries(document))[which])
271
+ }
272
+
273
+ async head(): Promise<string | undefined> {
274
+ const result = await this.#git.tryOutput(['rev-parse', '--verify', 'HEAD'])
275
+ return result.ok ? result.output.trim() : undefined
276
+ }
277
+
278
+ /** Reads one regular historical text blob. The caller enforces document/path policy. */
279
+ async readFile(id: string, path: string, maxBytes: number, document?: string): Promise<Uint8Array> {
280
+ if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(id) || leavesRoot(path)) throw new RequestError(400, 'history-file-invalid')
281
+ if (!(await this.#git.tryOutput(['merge-base', '--is-ancestor', id, 'HEAD'])).ok) throw new RequestError(404, 'history-revision-not-found')
282
+ const topPath = relative(this.#git.top, resolve(this.#files.config.root, path)).split(sep).join('/')
283
+ const listed = await this.#git.output(['-C', this.#git.top, 'ls-tree', '-z', id, '--', topPath], { GIT_LITERAL_PATHSPECS: '1' })
284
+ const [meta = '', named] = (listed.split('\0')[0] ?? '').split('\t')
285
+ const [mode, type, oid] = meta.split(' ')
286
+ if (named !== topPath || !oid) throw new RequestError(404, 'history-file-not-found')
287
+ if (type !== 'blob' || !['100644', '100755'].includes(mode!)) throw new RequestError(422, 'history-file-not-regular-text')
288
+ if (document !== undefined && await this.ownerAt(id, path) !== document) throw new RequestError(403, 'history-file-outside-document')
289
+ const size = Number((await this.#git.output(['cat-file', '-s', oid])).trim())
290
+ if (!Number.isSafeInteger(size) || size > maxBytes) throw new RequestError(413, 'history-file-too-large')
291
+ const result = await this.#git.run(['cat-file', 'blob', oid])
292
+ if (result.code !== 0 || result.stdout.length !== size) throw new RequestError(409, 'history-file-read-failed')
293
+ return result.stdout
294
+ }
295
+
296
+ /** The historical canonical ancestor, shared by reads and history selection. */
297
+ async ownerAt(id: string, path: string): Promise<string | undefined> {
298
+ if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})(?:\^1)?$/.test(id) || leavesRoot(path)) throw new RequestError(400, 'history-file-invalid')
299
+ return (await this.#owners([{ id, path }]))[0]
300
+ }
301
+
302
+ async #owners(files: { id: string, path: string }[]): Promise<(string | undefined)[]> {
303
+ const maxTreeBytes = 4 << 20, queries = new Set<string>()
304
+ const candidates = files.map(({ id, path }) => {
305
+ const parts = path.split('/'), found: { directory: string, query: string }[] = []
306
+ for (let i = parts.length - 1; i >= 0; i--) {
307
+ const directory = parts.slice(0, i).join('/')
308
+ if (!isDocumentDir(this.#files.config, directory)) continue
309
+ const topPath = relative(this.#git.top, resolve(this.#files.config.root, directory)).split(sep).join('/')
310
+ const query = topPath ? `${id}:${topPath}` : `${id}^{tree}`
311
+ found.push({ directory, query }); queries.add(query)
312
+ }
313
+ return found
314
+ })
315
+ const trees = new Map<string, { oid: string, bytes: number }>(), objects = new Map<string, number>(), all = [...queries]
316
+ for (let offset = 0; offset < all.length; offset += 512) {
317
+ const batch = all.slice(offset, offset + 512)
318
+ const result = await this.#git.run(['cat-file', '--batch-check=%(objectname) %(objecttype) %(objectsize)', '-Z'], {}, Buffer.from(batch.join('\0') + '\0'))
319
+ if (result.code !== 0) throw new RequestError(500, 'history-tree-read-failed')
320
+ const lines = result.stdout.toString('utf8').split('\0'); lines.pop()
321
+ if (lines.length !== batch.length) throw new RequestError(500, 'history-tree-read-failed')
322
+ batch.forEach((query, index) => {
323
+ const match = /^([a-f0-9]{40}|[a-f0-9]{64}) tree (\d+)$/.exec(lines[index]!)
324
+ if (!match) return
325
+ const bytes = Number(match[2])
326
+ if (!Number.isSafeInteger(bytes) || bytes > maxTreeBytes) throw new RequestError(413, 'history-tree-too-large')
327
+ trees.set(query, { oid: match[1]!, bytes }); objects.set(match[1]!, bytes)
328
+ })
329
+ }
330
+ const regular = new Map<string, boolean>(), pending = [...objects]
331
+ for (let offset = 0; offset < pending.length;) {
332
+ const batch: [string, number][] = []; let bytes = 0
333
+ while (offset < pending.length && batch.length < 512 && (!batch.length || bytes + pending[offset]![1] <= maxTreeBytes)) { const item = pending[offset++]!; batch.push(item); bytes += item[1] }
334
+ const result = await this.#git.run(['cat-file', '--batch', '-Z'], {}, Buffer.from(batch.map(([oid]) => oid).join('\0') + '\0'))
335
+ if (result.code !== 0) throw new RequestError(500, 'history-tree-read-failed')
336
+ let cursor = 0
337
+ for (const [oid, expectedBytes] of batch) {
338
+ const endHeader = result.stdout.indexOf(0, cursor)
339
+ if (endHeader < 0 || result.stdout.subarray(cursor, endHeader).toString('ascii') !== `${oid} tree ${expectedBytes}`) throw new RequestError(500, 'history-tree-read-failed')
340
+ const start = endHeader + 1, end = start + expectedBytes
341
+ if (result.stdout[end] !== 0) throw new RequestError(500, 'history-tree-read-failed')
342
+ const tree = result.stdout.subarray(start, end), oidBytes = oid.length / 2
343
+ let at = 0, found = false
344
+ while (at < tree.length) {
345
+ const space = tree.indexOf(32, at), zero = tree.indexOf(0, space + 1)
346
+ if (space < at || zero < space || zero + 1 + oidBytes > tree.length) throw new RequestError(500, 'history-tree-read-failed')
347
+ if (tree.subarray(space + 1, zero).toString('utf8') === 'lecture.json') found = ['100644', '100755'].includes(tree.subarray(at, space).toString('ascii'))
348
+ at = zero + 1 + oidBytes
349
+ }
350
+ regular.set(oid, found); cursor = end + 1
351
+ }
352
+ if (cursor !== result.stdout.length) throw new RequestError(500, 'history-tree-read-failed')
353
+ }
354
+ return candidates.map(found => found.find(item => { const tree = trees.get(item.query); return tree && regular.get(tree.oid) })?.directory)
355
+ }
356
+
357
+ /** The revert's checks under the git queue without writing: next target, ancestry, conflicts, scope, guard and uncommitted files. */
358
+ checkRevert(id: string, member: Member, still?: { which: keyof UndoTargets, document?: string }, guard?: (paths: string[]) => Promise<void> | void): Promise<{ ok: true } | { ok: false, conflicts: string[] }> {
359
+ return this.#git.exclusive(async () => {
360
+ const planned = await this.#planRevert(id, member, still, guard)
361
+ return planned.ok ? { ok: true } : planned
362
+ })
363
+ }
364
+
365
+ async #planRevert(id: string, member: Member, still?: { which: keyof UndoTargets, document?: string }, guard?: (paths: string[]) => Promise<void> | void) {
366
+ if (still && undoTargets(await this.#entries(still.document))[still.which] !== id) throw new RequestError(409, 'history-target-changed', { revision: id, which: still.which })
367
+ const verified = await this.#git.tryOutput(['rev-parse', '--verify', '--quiet', `${id}^{commit}`])
368
+ if (!verified.ok) throw new RequestError(404, 'history-revision-not-found')
369
+ const target = (await this.#log(verified.output.trim()))!
370
+ if (!(await this.#git.tryOutput(['merge-base', '--is-ancestor', target.id, 'HEAD'])).ok) throw new RequestError(404, 'history-revision-not-found')
371
+ const parent = await this.#git.tryOutput(['rev-parse', '--verify', '--quiet', `${target.id}^1`])
372
+ if (!parent.ok) throw new RequestError(409, 'history-baseline-has-no-parent')
373
+ const merged = await this.#git.run(['merge-tree', '--write-tree', '--no-messages', '--name-only', `--merge-base=${target.id}`, 'HEAD', parent.output.trim()])
374
+ const lines = merged.stdout.toString('utf8').split('\n').map(line => line.trim()).filter(Boolean)
375
+ if (merged.code === 1) return { ok: false as const, conflicts: [...new Set(lines.slice(1).map(this.#fromTop))] }
376
+ if (merged.code !== 0 || !lines[0]) throw new Error(`git merge-tree failed: ${merged.stderr.trim()}`)
377
+ const changes = await this.#changes('HEAD', lines[0])
378
+ if (changes.length === 0) throw new RequestError(409, 'history-change-already-undone')
379
+ const outside = changes.filter(change => documentDirOf(this.#files.config, change.path) === undefined).map(change => change.path)
380
+ if (outside.length > 0 && !member.roles.includes('owner')) throw new RequestError(403, 'history-revert-outside-documents', { paths: outside.slice(0, 6) })
381
+ await guard?.(changes.map(change => change.path))
382
+ const blocked = await this.#uncommitted(changes.map(change => change.path))
383
+ if (blocked.length > 0) return { ok: false as const, conflicts: blocked }
384
+ return { ok: true as const, target, tree: lines[0], changes }
385
+ }
386
+
387
+ /** Reverts the commit; guards and next-target checks run under the existing git queue. */
388
+ revert(id: string, member: Member, still?: { which: keyof UndoTargets, document?: string }, guard?: (paths: string[]) => Promise<void> | void, kind?: 'editor' | 'agent'): Promise<RevertResult> {
389
+ return this.#git.exclusive(async () => {
390
+ const planned = await this.#planRevert(id, member, still, guard)
391
+ if (!planned.ok) return planned
392
+ const { target, tree, changes } = planned
393
+ const originalHead = await this.head()
394
+ const restore = await this.#changes(tree, 'HEAD')
395
+ const indexPath = resolve(this.#git.root, (await this.#git.output(['rev-parse', '--git-path', 'index'])).trim())
396
+ const oldIndex = existsSync(indexPath) ? readFileSync(indexPath) : undefined
397
+ try {
398
+ await this.#write(changes)
399
+ const subject = target.message.split('\n')[0] ?? target.id
400
+ const undone = originOf(target.message)
401
+ const origin: CommitOrigin = { kind: kind ?? undone?.kind ?? 'editor', member, ...undone?.operation ? { operation: undone.operation } : {} }
402
+ const done = await this.commitWhileLocked({ paths: changes.map(change => change.path), summary: `Revert "${subject}"`, body: `This reverts commit ${target.id}.`, origin, revert: target.id })
403
+ if (!done) throw new Error(`the revert of ${target.id} committed nothing`)
404
+ return { ok: true, revision: done }
405
+ } catch (error) {
406
+ const afterHead = await this.head()
407
+ if (afterHead !== originalHead) throw new RequestError(409, 'history-readback-changed', { committed: true, snapshot: afterHead })
408
+ await this.#write(restore)
409
+ if (oldIndex) writeFileSync(indexPath, oldIndex)
410
+ else rmSync(indexPath, { force: true })
411
+ throw error
412
+ }
413
+ })
414
+ }
415
+
416
+ /** Whether git ignores the path, which a commit then leaves out. */
417
+ async ignores(path: string): Promise<boolean> {
418
+ return (await this.#git.run(['check-ignore', '-q', '--', path])).code === 0
419
+ }
420
+
421
+ async messageOf(id: string): Promise<string | undefined> {
422
+ const shown = await this.#git.tryOutput(['show', '-s', '--format=%B', `${id}^{commit}`])
423
+ return shown.ok ? shown.output : undefined
424
+ }
425
+
426
+ async filesOf(id: string): Promise<string[]> {
427
+ const shown = await this.#git.tryOutput(['show', '--no-renames', '--name-only', '-z', '--format=', `${id}^{commit}`])
428
+ return shown.ok ? shown.output.split('\0').filter(Boolean).map(this.#fromTop) : []
429
+ }
430
+
431
+ async documentsOf(id: string): Promise<string[]> {
432
+ const verified = await this.#git.tryOutput(['rev-parse', '--verify', '--quiet', `${id}^{commit}`])
433
+ if (!verified.ok) return []
434
+ const files = (await this.#git.output(['show', '--no-renames', '--name-only', '-z', '--format=', verified.output.trim()])).split('\0').filter(Boolean).map(this.#fromTop)
435
+ return [...new Set(files.flatMap(file => documentDirOf(this.#files.config, file) ?? []))]
436
+ }
437
+
438
+ async #entries(document?: string): Promise<LogEntry[]> {
439
+ const log = await this.#git.tryOutput(['log', FORMAT, '--name-only', '--no-renames', '-z'], { GIT_LITERAL_PATHSPECS: '1' })
440
+ const entries = log.ok ? parseLog(log.output).map(entry => ({ ...entry, files: entry.files.map(this.#fromTop) })) : []
441
+ if (entries.length) {
442
+ const rootIds = new Set((await this.#git.output(['rev-list', '--max-parents=0', 'HEAD'])).trim().split('\n'))
443
+ for (const entry of entries) if (rootIds.has(entry.id)) entry.root = true
444
+ }
445
+ if (document === undefined) return entries
446
+ const candidates = entries.flatMap((entry, index) => entry.files.filter(path => !leavesRoot(path) && (document === '' || path.startsWith(document + '/'))).map(path => ({ index, path, id: entry.id })))
447
+ const probes = candidates.flatMap(({ id, path }) => [{ id, path }, { id: `${id}^1`, path }])
448
+ const owners = await this.#owners(probes), accepted = new Set<number>()
449
+ candidates.forEach((item, index) => {
450
+ const deepest = [owners[index * 2], owners[index * 2 + 1]].filter((owner): owner is string => owner !== undefined).sort((a, b) => b.length - a.length)[0]
451
+ if (deepest === document) accepted.add(item.index)
452
+ })
453
+ return entries.filter((_, index) => accepted.has(index))
454
+ }
455
+
456
+ async #statusOf(paths: string[]): Promise<{ code: string, path: string }[]> {
457
+ const status = await this.#git.tryOutput(['status', '--porcelain=v1', '-z', '--no-renames', '--untracked-files=all', '--', ...paths.map(path => path || '.')], { GIT_LITERAL_PATHSPECS: '1' })
458
+ if (!status.ok) return []
459
+ return status.output.split('\0').filter(Boolean)
460
+ .map(entry => ({ code: entry.slice(0, 2), path: this.#fromTop(entry.slice(3)) }))
461
+ .filter(({ path }) => !isGeneratedPath(path))
462
+ }
463
+
464
+ #fromTop = (path: string): string => relative(this.#files.config.root, join(this.#git.top, path)).split(sep).join('/')
465
+
466
+ async #changes(from: string, to: string): Promise<TreeFile[]> {
467
+ const fields = (await this.#git.output(['diff-tree', '-r', '-z', '--no-renames', from, to])).split('\0').filter(Boolean)
468
+ const out: TreeFile[] = []
469
+ for (let i = 0; i + 1 < fields.length; i += 2) {
470
+ const [, mode = '', , oid = ''] = fields[i]!.slice(1).split(' ')
471
+ const path = this.#fromTop(fields[i + 1]!)
472
+ if (leavesRoot(path)) throw new Error(`the revert changes ${fields[i + 1]}, outside the workspace`)
473
+ out.push({ path, mode, oid })
474
+ }
475
+ return out
476
+ }
477
+
478
+ async #uncommitted(paths: string[]): Promise<string[]> {
479
+ const work = mkdtempSync(join(tmpdir(), 'presmium-revert-'))
480
+ const env = { GIT_INDEX_FILE: join(work, 'index'), GIT_LITERAL_PATHSPECS: '1' }
481
+ try {
482
+ await this.#git.output(['read-tree', 'HEAD'], env)
483
+ const staged = await this.#git.output(['diff', '--cached', '--name-only', '-z', 'HEAD', '--', ...paths], { GIT_LITERAL_PATHSPECS: '1' })
484
+ const blocked = new Set<string>(staged.split('\0').filter(Boolean).map(this.#fromTop))
485
+ for (const path of paths) {
486
+ const tracked = (await this.#git.tryOutput(['ls-files', '--', path], env)).output.trim() !== ''
487
+ let present = true
488
+ try {
489
+ lstatSync(join(this.#files.config.root, path))
490
+ } catch {
491
+ present = false
492
+ }
493
+ if (!tracked && !present) continue
494
+ if (!(await this.#git.tryOutput(['add', '-A', '--', path], env)).ok) blocked.add(path)
495
+ }
496
+ const differ = (await this.#git.output(['diff-index', '--cached', '--name-only', '-z', 'HEAD', '--', ...paths], env)).split('\0').filter(Boolean).map(this.#fromTop)
497
+ for (const path of differ) blocked.add(path)
498
+ return [...blocked].sort()
499
+ } finally {
500
+ rmSync(work, { recursive: true, force: true })
501
+ }
502
+ }
503
+
504
+ async #write(files: TreeFile[]): Promise<void> {
505
+ const { config, writer } = this.#files
506
+ const changes: { change: DocumentFileChange, dir?: string }[] = []
507
+ for (const file of files) {
508
+ if (file.mode === '160000') throw new Error(`${file.path} is a submodule: not reverted`)
509
+ const dir = documentDirOf(config, file.path)
510
+ if (/^0+$/.test(file.mode)) {
511
+ changes.push({ change: { path: file.path, remove: true }, dir })
512
+ continue
513
+ }
514
+ const blob = (await this.#git.run(['cat-file', 'blob', file.oid])).stdout
515
+ const change: DocumentFileChange = file.mode === '120000' ? { path: file.path, link: blob.toString('utf8') } : { path: file.path, content: blob, ...file.mode === '100755' ? { executable: true } : {} }
516
+ changes.push({ change, dir })
517
+ }
518
+ const inDocuments = changes.filter(({ dir }) => dir !== undefined)
519
+ for (const dir of new Set(inDocuments.filter(({ change }) => !('remove' in change)).map(({ dir }) => dir!))) {
520
+ if (!exists(join(config.root, dir))) makeDocumentDir(config.root, dir)
521
+ }
522
+ if (inDocuments.length > 0) await writer.write(inDocuments.map(({ change }) => change))
523
+ const outside = changes.filter(({ dir }) => dir === undefined).map(({ change }) => change)
524
+ for (const { path } of outside) {
525
+ const open = writableByOthers(config.root, path)
526
+ if (open) throw new Error(`the revert would write ${path}, in ${open}, which more than serve may write`)
527
+ }
528
+ if (outside.length > 0) writeChanges(config, outside, true)
529
+ for (const dir of new Set(inDocuments.map(({ dir }) => dir!))) {
530
+ if (!dir) continue
531
+ try {
532
+ if (readdirSync(join(config.root, dir)).length === 0) rmdirSync(join(config.root, dir))
533
+ } catch {
534
+ }
535
+ }
536
+ }
537
+
538
+ async #log(id: string): Promise<LogEntry | undefined> {
539
+ const entry = parseLog(await this.#git.output(['show', '--no-renames', '--name-only', '-z', FORMAT, id]))[0]
540
+ return entry && { ...entry, files: entry.files.map(this.#fromTop) }
541
+ }
542
+
543
+ async #revision(id: string): Promise<Revision | undefined> {
544
+ const entry = await this.#log(id)
545
+ return entry && revision(entry)
546
+ }
547
+
548
+ #author(member: Member | undefined): string[] {
549
+ return member ? [`--author=${memberAddress(member)}`] : []
550
+ }
551
+
552
+ async #identity(): Promise<string[]> {
553
+ const email = await this.#git.tryOutput(['config', 'user.email'])
554
+ return email.ok && email.output.trim() ? [] : ['-c', 'user.name=Presmium', '-c', 'user.email=presmium@presmium.invalid']
555
+ }
556
+ }
557
+
558
+ function withTrailers(message: string, more: [string, string][]): string {
559
+ if (more.length === 0) return message
560
+ const lines = message.trimEnd().split('\n')
561
+ const at = lines.at(-1)?.startsWith('Co-authored-by: ') ? lines.length - 1 : lines.length
562
+ lines.splice(at, 0, ...more.map(([key, value]) => `${key}: ${value.replace(/\s+/g, ' ').trim()}`))
563
+ return `${lines.join('\n')}\n`
564
+ }
565
+
566
+ function writableByOthers(root: string, path: string): string | undefined {
567
+ const parts = path.split('/').slice(0, -1)
568
+ for (let i = 0; i <= parts.length; i++) {
569
+ const dir = parts.slice(0, i).join('/')
570
+ let mode: number
571
+ try {
572
+ ({ mode } = lstatSync(join(root, dir)))
573
+ } catch {
574
+ return undefined
575
+ }
576
+ if ((mode & 0o002) !== 0) return dir || '.'
577
+ }
578
+ return undefined
579
+ }
580
+
581
+ function lineCount(path: string): [number, number] | undefined {
582
+ try {
583
+ const bytes = readFileSync(path)
584
+ if (bytes.includes(0)) return undefined
585
+ const text = bytes.toString('utf8')
586
+ return [text === '' ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0), 0]
587
+ } catch {
588
+ return undefined
589
+ }
590
+ }
591
+
592
+ function exists(path: string): boolean {
593
+ try {
594
+ lstatSync(path)
595
+ return true
596
+ } catch {
597
+ return false
598
+ }
599
+ }