@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.
- package/README.md +5 -5
- package/package.json +10 -5
- package/src/domain/{revisions.ts → commit-message.ts} +7 -7
- package/src/domain/locks.ts +5 -0
- package/src/domain/paths.ts +2 -2
- package/src/index.ts +7 -2
- package/src/node/authoring-clients.ts +206 -0
- package/src/node/build.ts +4 -4
- package/src/node/compile-session.ts +187 -0
- package/src/node/config.ts +36 -21
- package/src/node/document-assets.ts +30 -0
- package/src/node/document-audio.ts +473 -0
- package/src/node/document-authoring.ts +436 -0
- package/src/node/document-editor.ts +157 -0
- package/src/node/document-history.ts +190 -0
- package/src/node/document-images.ts +241 -0
- package/src/node/document-operations.ts +191 -0
- package/src/node/document-transcription-adoption.ts +96 -0
- package/src/node/document-transcription.ts +471 -0
- package/src/node/document-voice.ts +115 -0
- package/src/node/errors.ts +23 -0
- package/src/node/git.ts +15 -15
- package/src/node/jobs.ts +361 -0
- package/src/node/locks.ts +10 -5
- package/src/node/page-delete.ts +106 -0
- package/src/node/project-files.ts +163 -0
- package/src/node/projects.ts +192 -0
- package/src/node/review/agent-session-reviews.ts +374 -0
- package/src/node/review/git-guard.ts +9 -8
- package/src/node/review/git-review.ts +102 -90
- package/src/node/review/record.ts +77 -0
- package/src/node/review/routes.ts +36 -35
- package/src/node/review/task-reviews.ts +50 -0
- package/src/node/revisions.ts +321 -68
- package/src/node/routes.ts +236 -146
- package/src/node/serve.ts +319 -118
- package/src/node/site.ts +34 -23
- package/src/node/source-editor.ts +332 -0
- package/src/node/speech-settings.ts +21 -0
- package/src/node/templates.ts +27 -0
- package/src/node/workbench-api.ts +176 -0
- package/src/node/writer.ts +389 -0
- package/templates/document/default/lecture.json +75 -0
- package/src/domain/api.ts +0 -102
- package/src/domain/index.ts +0 -6
- package/src/node/documents.ts +0 -185
- package/src/node/files.ts +0 -252
- package/src/node/review/review-record.ts +0 -73
- package/src/node/review/reviews.ts +0 -422
- package/src/node/service.ts +0 -237
- package/src/node/session.ts +0 -123
- package/src/node/source.ts +0 -148
- package/templates/document/default/lecture.md +0 -18
package/src/node/revisions.ts
CHANGED
|
@@ -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/
|
|
8
|
-
import type { CommitOrigin } from '../domain/
|
|
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 {
|
|
11
|
-
import {
|
|
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(
|
|
29
|
-
const [id = '', author = '', date = '', message = '',
|
|
30
|
-
|
|
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
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
const staged = (await this.#git.
|
|
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),
|
|
104
|
-
await this.#git.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
|
164
|
-
if (!
|
|
165
|
-
const target =
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 }) => !
|
|
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.
|
|
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.
|
|
232
|
-
const
|
|
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.
|
|
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.
|
|
494
|
+
if (!(await this.#git.tryOutput(['add', '-A', '--', path], env)).ok) blocked.add(path)
|
|
243
495
|
}
|
|
244
|
-
const differ = (await this.#git.
|
|
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
|
-
|
|
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.
|
|
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
|
}
|