@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.
- package/LICENSE +202 -0
- package/README.md +35 -2
- package/package.json +41 -3
- package/src/domain/commit-message.ts +80 -0
- package/src/domain/locks.ts +5 -0
- package/src/domain/paths.ts +98 -0
- package/src/index.ts +11 -0
- package/src/node/authoring-clients.ts +206 -0
- package/src/node/build.ts +42 -0
- package/src/node/compile-session.ts +187 -0
- package/src/node/config.ts +141 -0
- 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 +68 -0
- package/src/node/jobs.ts +361 -0
- package/src/node/locks.ts +46 -0
- 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 +104 -0
- package/src/node/review/git-review.ts +428 -0
- package/src/node/review/record.ts +77 -0
- package/src/node/review/routes.ts +73 -0
- package/src/node/review/task-reviews.ts +50 -0
- package/src/node/revisions.ts +599 -0
- package/src/node/routes.ts +329 -0
- package/src/node/serve.ts +503 -0
- package/src/node/site.ts +132 -0
- 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
|
@@ -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
|
+
}
|