@lincyaw/workspace 1.0.0-rc.0 → 1.0.0-rc.2

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 (53) hide show
  1. package/README.md +5 -5
  2. package/package.json +10 -5
  3. package/src/domain/{revisions.ts → commit-message.ts} +7 -7
  4. package/src/domain/locks.ts +5 -0
  5. package/src/domain/paths.ts +2 -2
  6. package/src/index.ts +7 -2
  7. package/src/node/authoring-clients.ts +206 -0
  8. package/src/node/build.ts +4 -4
  9. package/src/node/compile-session.ts +187 -0
  10. package/src/node/config.ts +36 -21
  11. package/src/node/document-assets.ts +30 -0
  12. package/src/node/document-audio.ts +473 -0
  13. package/src/node/document-authoring.ts +436 -0
  14. package/src/node/document-editor.ts +157 -0
  15. package/src/node/document-history.ts +190 -0
  16. package/src/node/document-images.ts +241 -0
  17. package/src/node/document-operations.ts +191 -0
  18. package/src/node/document-transcription-adoption.ts +96 -0
  19. package/src/node/document-transcription.ts +471 -0
  20. package/src/node/document-voice.ts +115 -0
  21. package/src/node/errors.ts +23 -0
  22. package/src/node/git.ts +15 -15
  23. package/src/node/jobs.ts +361 -0
  24. package/src/node/locks.ts +10 -5
  25. package/src/node/page-delete.ts +106 -0
  26. package/src/node/project-files.ts +163 -0
  27. package/src/node/projects.ts +192 -0
  28. package/src/node/review/agent-session-reviews.ts +374 -0
  29. package/src/node/review/git-guard.ts +9 -8
  30. package/src/node/review/git-review.ts +102 -90
  31. package/src/node/review/record.ts +77 -0
  32. package/src/node/review/routes.ts +36 -35
  33. package/src/node/review/task-reviews.ts +50 -0
  34. package/src/node/revisions.ts +321 -68
  35. package/src/node/routes.ts +236 -146
  36. package/src/node/serve.ts +319 -118
  37. package/src/node/site.ts +34 -23
  38. package/src/node/source-editor.ts +332 -0
  39. package/src/node/speech-settings.ts +21 -0
  40. package/src/node/templates.ts +27 -0
  41. package/src/node/workbench-api.ts +176 -0
  42. package/src/node/writer.ts +389 -0
  43. package/templates/document/default/lecture.json +75 -0
  44. package/src/domain/api.ts +0 -102
  45. package/src/domain/index.ts +0 -6
  46. package/src/node/documents.ts +0 -185
  47. package/src/node/files.ts +0 -252
  48. package/src/node/review/review-record.ts +0 -73
  49. package/src/node/review/reviews.ts +0 -422
  50. package/src/node/service.ts +0 -237
  51. package/src/node/session.ts +0 -123
  52. package/src/node/source.ts +0 -148
  53. package/templates/document/default/lecture.md +0 -18
@@ -1,16 +1,16 @@
1
- import { lstatSync, mkdtempSync, readdirSync, readFileSync, rmdirSync, rmSync } from 'node:fs'
1
+ import { existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, rmdirSync, rmSync, writeFileSync } from 'node:fs'
2
2
  import { tmpdir } from 'node:os'
3
- import { join, relative, sep } from 'node:path'
4
- import type { DocumentFileChange, Member, RevertResult, Revision, RevisionService } from '@lincyaw/kernel'
5
- import type { HistoryRevision, UncommittedFile } from '../domain/api.ts'
3
+ import { join, relative, resolve, sep } from 'node:path'
4
+ import type { DocumentFileChange, HistoryRevision, Member, RevertResult, Revision, RevisionsAnswer, RevisionService, UncommittedFile } from '@lincyaw/kernel'
6
5
  import { leavesRoot } from '../domain/paths.ts'
7
- import { commitMessage, memberAddress, originOf } from '../domain/revisions.ts'
8
- import type { CommitOrigin } from '../domain/revisions.ts'
6
+ import { commitMessage, memberAddress, originOf } from '../domain/commit-message.ts'
7
+ import type { CommitOrigin } from '../domain/commit-message.ts'
9
8
  import type { WorkspaceConfig } from './config.ts'
10
- import { makeDocumentDir, RequestError } from './documents.ts'
11
- import { documentDirOf, generatedPath, writeChanges } from './files.ts'
12
- import type { DocumentWriter } from './files.ts'
9
+ import { isDocumentDir } from './config.ts'
10
+ import { RequestError } from './errors.ts'
13
11
  import type { Git } from './git.ts'
12
+ import { documentDirOf, isGeneratedPath, makeDocumentDir, writeChanges } from './writer.ts'
13
+ import type { DocumentWriter } from './writer.ts'
14
14
 
15
15
  const FIELD = '\x00'
16
16
  const RECORD = '\x1e'
@@ -22,12 +22,16 @@ interface LogEntry {
22
22
  date: string
23
23
  message: string
24
24
  files: string[]
25
+ root?: true
25
26
  }
26
27
 
27
28
  function parseLog(output: string): LogEntry[] {
28
- return output.split(RECORD).filter(Boolean).map((record) => {
29
- const [id = '', author = '', date = '', message = '', rest = ''] = record.split(FIELD)
30
- return { id, author, date, message: message.trim(), files: rest.split('\n').map(line => line.trim()).filter(Boolean) }
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) }
31
35
  })
32
36
  }
33
37
 
@@ -35,8 +39,12 @@ function revertedOf(message: string): string | undefined {
35
39
  return originOf(message)?.revert ?? /This reverts commit ([0-9a-f]{7,64})/.exec(message)?.[1]
36
40
  }
37
41
 
38
- function redoes(entries: LogEntry[]): (entry: LogEntry) => boolean {
39
- const find = (id: string) => entries.find(entry => entry.id === id || (id.length >= 7 && entry.id.startsWith(id)))
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)
40
48
  const known = new Map<string, boolean>()
41
49
  const redo = (entry: LogEntry): boolean => {
42
50
  let found = known.get(entry.id)
@@ -51,6 +59,84 @@ function redoes(entries: LogEntry[]): (entry: LogEntry) => boolean {
51
59
  return redo
52
60
  }
53
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
+
54
140
  function revision(entry: LogEntry, revertedBy?: string, redo = false): HistoryRevision | undefined {
55
141
  const origin = originOf(entry.message)
56
142
  if (!origin) return undefined
@@ -66,6 +152,7 @@ function revision(entry: LogEntry, revertedBy?: string, redo = false): HistoryRe
66
152
  ...operation ? { operation } : {},
67
153
  ...revert ? { reverts: revert } : {},
68
154
  ...revert && redo ? { redo: true as const } : {},
155
+ ...entry.root ? { revertible: false as const } : {},
69
156
  }
70
157
  }
71
158
 
@@ -80,6 +167,16 @@ export interface RevisionFiles {
80
167
  writer: DocumentWriter
81
168
  }
82
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
+
83
180
  export class Revisions implements RevisionService {
84
181
  readonly #git: Git
85
182
  readonly #files: RevisionFiles
@@ -89,24 +186,41 @@ export class Revisions implements RevisionService {
89
186
  this.#files = files
90
187
  }
91
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
+
92
204
  commit(change: { files: string[], message: string, origin: CommitOrigin, trailers?: [string, string][] }): Promise<Revision | undefined> {
93
205
  const [summary = '', ...body] = change.message.trim().split('\n')
94
- return this.#git.exclusive(() => this.commitNow(change.files, summary, body.join('\n'), change.origin, undefined, change.trailers))
206
+ return this.transaction(() => this.commitWhileLocked({ paths: change.files, summary, body: body.join('\n'), origin: change.origin, ...change.trailers ? { trailers: change.trailers } : {} }))
95
207
  }
96
208
 
97
- async commitNow(paths: string[], summary: string, body: string, origin: CommitOrigin, revert?: string, more: [string, string][] = []): Promise<Revision | undefined> {
98
- const unique = [...new Set(paths.map(path => path.replace(/^\.?\/+|\/+$/g, '')).filter(path => path && !leavesRoot(path)))]
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)))]
99
213
  if (unique.length === 0) return undefined
100
- for (const path of unique) await this.#git.attempt(['add', '-A', '--', path], { GIT_LITERAL_PATHSPECS: '1' })
101
- const staged = (await this.#git.raw(['diff', '--cached', '--name-only', '--', ...unique], { GIT_LITERAL_PATHSPECS: '1' })).trim()
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()
102
216
  if (!staged) return undefined
103
- const message = withTrailers(commitMessage(summary, body, origin, revert), more)
104
- await this.#git.raw([...await this.#identity(), 'commit', '-q', '--no-verify', '-m', message, ...this.#author(origin.member), '--', ...unique], { GIT_LITERAL_PATHSPECS: '1' })
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' })
105
219
  return this.#revision('HEAD')
106
220
  }
107
221
 
108
222
  async status(paths: string[] = ['']): Promise<{ branch?: string, uncommitted: number }> {
109
- const branch = await this.#git.attempt(['symbolic-ref', '--quiet', '--short', 'HEAD'])
223
+ const branch = await this.#git.tryOutput(['symbolic-ref', '--quiet', '--short', 'HEAD'])
110
224
  return { ...branch.ok ? { branch: branch.output.trim() } : {}, uncommitted: (await this.#statusOf(paths)).length }
111
225
  }
112
226
 
@@ -114,7 +228,7 @@ export class Revisions implements RevisionService {
114
228
  const specs = paths.map(path => path || '.')
115
229
  const status = await this.#statusOf(paths)
116
230
  const counts = new Map<string, [number, number]>()
117
- const numstat = await this.#git.attempt(['diff', '--numstat', '-z', '--no-renames', 'HEAD', '--', ...specs], { GIT_LITERAL_PATHSPECS: '1' })
231
+ const numstat = await this.#git.tryOutput(['diff', '--numstat', '-z', '--no-renames', 'HEAD', '--', ...specs], { GIT_LITERAL_PATHSPECS: '1' })
118
232
  if (numstat.ok) {
119
233
  for (const entry of numstat.output.split('\0').filter(Boolean)) {
120
234
  const [added = '-', removed = '-', path = ''] = entry.split('\t')
@@ -132,7 +246,7 @@ export class Revisions implements RevisionService {
132
246
  return this.#git.exclusive(async () => {
133
247
  const files: TreeFile[] = []
134
248
  for (const path of paths) {
135
- const listed = await this.#git.attempt(['ls-tree', '-z', 'HEAD', '--', path], { GIT_LITERAL_PATHSPECS: '1' })
249
+ const listed = await this.#git.tryOutput(['ls-tree', '-z', 'HEAD', '--', path], { GIT_LITERAL_PATHSPECS: '1' })
136
250
  const [meta = '', named] = (listed.ok ? listed.output.split('\0')[0] ?? '' : '').split('\t')
137
251
  const [mode = '', , oid = ''] = meta.split(' ')
138
252
  files.push(named && mode ? { path, mode, oid } : { path, mode: '000000', oid: '' })
@@ -142,78 +256,215 @@ export class Revisions implements RevisionService {
142
256
  }
143
257
 
144
258
  async history(filter: { document?: string, task?: string, limit?: number } = {}): Promise<HistoryRevision[]> {
145
- const log = await this.#git.attempt(['log', FORMAT, '--name-only', '--no-renames', ...filter.document ? ['--', filter.document] : []], { GIT_LITERAL_PATHSPECS: '1' })
146
- if (!log.ok) return []
147
- const entries = parseLog(log.output)
148
- const revertedBy = new Map<string, string>()
149
- for (const entry of entries) {
150
- const reverted = revertedOf(entry.message)
151
- if (reverted && !revertedBy.has(reverted)) revertedBy.set(reverted, entry.id)
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')
152
353
  }
153
- const redo = redoes(entries)
154
- const revisions = entries.flatMap((entry) => {
155
- const found = revision(entry, revertedBy.get(entry.id), redo(entry))
156
- return found && (!filter.task || found.origin.task === filter.task) ? [found] : []
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
157
362
  })
158
- return filter.limit ? revisions.slice(0, filter.limit) : revisions
159
363
  }
160
364
 
161
- revert(id: string, member: Member): Promise<RevertResult> {
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> {
162
389
  return this.#git.exclusive(async () => {
163
- const verified = await this.#git.attempt(['rev-parse', '--verify', '--quiet', `${id}^{commit}`])
164
- if (!verified.ok) throw new Error(`no commit ${id}`)
165
- const target = (await this.#log(verified.output.trim()))!
166
- const parent = await this.#git.attempt(['rev-parse', '--verify', '--quiet', `${target.id}^1`])
167
- if (!parent.ok) throw new Error(`commit ${target.id} has no parent to go back to`)
168
- const merged = await this.#git.run(['merge-tree', '--write-tree', '--no-messages', '--name-only', `--merge-base=${target.id}`, 'HEAD', parent.output.trim()])
169
- const lines = merged.stdout.toString('utf8').split('\n').map(line => line.trim()).filter(Boolean)
170
- if (merged.code === 1) return { ok: false, conflicts: [...new Set(lines.slice(1).map(this.#fromTop))] }
171
- if (merged.code !== 0 || !lines[0]) throw new Error(`git merge-tree failed: ${merged.stderr.trim()}`)
172
- const changes = await this.#changes('HEAD', lines[0])
173
- if (changes.length === 0) throw new Error(`commit ${target.id} is undone already`)
174
- const outside = changes.filter(change => documentDirOf(this.#files.config, change.path) === undefined).map(change => change.path)
175
- if (outside.length > 0 && !member.roles.includes('owner')) throw new RequestError(403, `only an owner reverts a change of ${outside.join(', ')}, outside the documents`)
176
- const blocked = await this.#uncommitted(changes.map(change => change.path))
177
- if (blocked.length > 0) return { ok: false, conflicts: blocked }
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 {
178
398
  await this.#write(changes)
179
399
  const subject = target.message.split('\n')[0] ?? target.id
180
400
  const undone = originOf(target.message)
181
- const origin: CommitOrigin = { kind: undone?.kind ?? 'editor', member, ...undone?.operation ? { operation: undone.operation } : {} }
182
- const done = await this.commitNow(changes.map(change => change.path), `Revert "${subject}"`, `This reverts commit ${target.id}.`, origin, target.id)
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 })
183
403
  if (!done) throw new Error(`the revert of ${target.id} committed nothing`)
184
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
+ }
185
413
  })
186
414
  }
187
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
+
188
421
  async messageOf(id: string): Promise<string | undefined> {
189
- const shown = await this.#git.attempt(['show', '-s', '--format=%B', `${id}^{commit}`])
422
+ const shown = await this.#git.tryOutput(['show', '-s', '--format=%B', `${id}^{commit}`])
190
423
  return shown.ok ? shown.output : undefined
191
424
  }
192
425
 
193
426
  async filesOf(id: string): Promise<string[]> {
194
- const shown = await this.#git.attempt(['show', '--no-renames', '--name-only', '-z', '--format=', `${id}^{commit}`])
427
+ const shown = await this.#git.tryOutput(['show', '--no-renames', '--name-only', '-z', '--format=', `${id}^{commit}`])
195
428
  return shown.ok ? shown.output.split('\0').filter(Boolean).map(this.#fromTop) : []
196
429
  }
197
430
 
198
431
  async documentsOf(id: string): Promise<string[]> {
199
- const verified = await this.#git.attempt(['rev-parse', '--verify', '--quiet', `${id}^{commit}`])
432
+ const verified = await this.#git.tryOutput(['rev-parse', '--verify', '--quiet', `${id}^{commit}`])
200
433
  if (!verified.ok) return []
201
- const files = (await this.#git.raw(['show', '--no-renames', '--name-only', '-z', '--format=', verified.output.trim()])).split('\0').filter(Boolean).map(this.#fromTop)
434
+ const files = (await this.#git.output(['show', '--no-renames', '--name-only', '-z', '--format=', verified.output.trim()])).split('\0').filter(Boolean).map(this.#fromTop)
202
435
  return [...new Set(files.flatMap(file => documentDirOf(this.#files.config, file) ?? []))]
203
436
  }
204
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
+
205
456
  async #statusOf(paths: string[]): Promise<{ code: string, path: string }[]> {
206
- const status = await this.#git.attempt(['status', '--porcelain=v1', '-z', '--no-renames', '--untracked-files=all', '--', ...paths.map(path => path || '.')], { GIT_LITERAL_PATHSPECS: '1' })
457
+ const status = await this.#git.tryOutput(['status', '--porcelain=v1', '-z', '--no-renames', '--untracked-files=all', '--', ...paths.map(path => path || '.')], { GIT_LITERAL_PATHSPECS: '1' })
207
458
  if (!status.ok) return []
208
459
  return status.output.split('\0').filter(Boolean)
209
460
  .map(entry => ({ code: entry.slice(0, 2), path: this.#fromTop(entry.slice(3)) }))
210
- .filter(({ path }) => !generatedPath(path))
461
+ .filter(({ path }) => !isGeneratedPath(path))
211
462
  }
212
463
 
213
464
  #fromTop = (path: string): string => relative(this.#files.config.root, join(this.#git.top, path)).split(sep).join('/')
214
465
 
215
466
  async #changes(from: string, to: string): Promise<TreeFile[]> {
216
- const fields = (await this.#git.raw(['diff-tree', '-r', '-z', '--no-renames', from, to])).split('\0').filter(Boolean)
467
+ const fields = (await this.#git.output(['diff-tree', '-r', '-z', '--no-renames', from, to])).split('\0').filter(Boolean)
217
468
  const out: TreeFile[] = []
218
469
  for (let i = 0; i + 1 < fields.length; i += 2) {
219
470
  const [, mode = '', , oid = ''] = fields[i]!.slice(1).split(' ')
@@ -228,10 +479,11 @@ export class Revisions implements RevisionService {
228
479
  const work = mkdtempSync(join(tmpdir(), 'presmium-revert-'))
229
480
  const env = { GIT_INDEX_FILE: join(work, 'index'), GIT_LITERAL_PATHSPECS: '1' }
230
481
  try {
231
- await this.#git.raw(['read-tree', 'HEAD'], env)
232
- const blocked = new Set<string>()
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))
233
485
  for (const path of paths) {
234
- const tracked = (await this.#git.attempt(['ls-files', '--', path], env)).output.trim() !== ''
486
+ const tracked = (await this.#git.tryOutput(['ls-files', '--', path], env)).output.trim() !== ''
235
487
  let present = true
236
488
  try {
237
489
  lstatSync(join(this.#files.config.root, path))
@@ -239,9 +491,9 @@ export class Revisions implements RevisionService {
239
491
  present = false
240
492
  }
241
493
  if (!tracked && !present) continue
242
- if (!(await this.#git.attempt(['add', '-A', '--', path], env)).ok) blocked.add(path)
494
+ if (!(await this.#git.tryOutput(['add', '-A', '--', path], env)).ok) blocked.add(path)
243
495
  }
244
- const differ = (await this.#git.raw(['diff-index', '--cached', '--name-only', '-z', 'HEAD', '--', ...paths], env)).split('\0').filter(Boolean).map(this.#fromTop)
496
+ const differ = (await this.#git.output(['diff-index', '--cached', '--name-only', '-z', 'HEAD', '--', ...paths], env)).split('\0').filter(Boolean).map(this.#fromTop)
245
497
  for (const path of differ) blocked.add(path)
246
498
  return [...blocked].sort()
247
499
  } finally {
@@ -284,7 +536,8 @@ export class Revisions implements RevisionService {
284
536
  }
285
537
 
286
538
  async #log(id: string): Promise<LogEntry | undefined> {
287
- return parseLog(await this.#git.raw(['show', '--no-renames', '--name-only', FORMAT, id]))[0]
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) }
288
541
  }
289
542
 
290
543
  async #revision(id: string): Promise<Revision | undefined> {
@@ -297,7 +550,7 @@ export class Revisions implements RevisionService {
297
550
  }
298
551
 
299
552
  async #identity(): Promise<string[]> {
300
- const email = await this.#git.attempt(['config', 'user.email'])
553
+ const email = await this.#git.tryOutput(['config', 'user.email'])
301
554
  return email.ok && email.output.trim() ? [] : ['-c', 'user.name=Presmium', '-c', 'user.email=presmium@presmium.invalid']
302
555
  }
303
556
  }