@lincyaw/workspace 0.0.0-stage → 1.0.0-rc.0
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 +36 -3
- package/src/domain/api.ts +102 -0
- package/src/domain/index.ts +6 -0
- package/src/domain/paths.ts +98 -0
- package/src/domain/revisions.ts +80 -0
- package/src/index.ts +6 -0
- package/src/node/build.ts +42 -0
- package/src/node/config.ts +126 -0
- package/src/node/documents.ts +185 -0
- package/src/node/files.ts +252 -0
- package/src/node/git.ts +68 -0
- package/src/node/locks.ts +41 -0
- package/src/node/review/git-guard.ts +103 -0
- package/src/node/review/git-review.ts +416 -0
- package/src/node/review/review-record.ts +73 -0
- package/src/node/review/reviews.ts +422 -0
- package/src/node/review/routes.ts +72 -0
- package/src/node/revisions.ts +346 -0
- package/src/node/routes.ts +239 -0
- package/src/node/serve.ts +302 -0
- package/src/node/service.ts +237 -0
- package/src/node/session.ts +123 -0
- package/src/node/site.ts +121 -0
- package/src/node/source.ts +148 -0
- package/templates/document/default/lecture.md +18 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join, sep } from 'node:path'
|
|
3
|
+
|
|
4
|
+
type Entry = { kind: 'file', data: Buffer, mode: number } | { kind: 'link', target: string } | { kind: 'dir' } | { kind: 'other' }
|
|
5
|
+
|
|
6
|
+
export interface SavedGuard {
|
|
7
|
+
identity: string
|
|
8
|
+
entries: [string, { kind: 'file', data: string, mode: number } | { kind: 'link', target: string } | { kind: 'dir' } | { kind: 'other' }][]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const UNGUARDED = new Set(['refs', 'logs', 'lfs'])
|
|
12
|
+
|
|
13
|
+
function unguarded(parts: string[]): boolean {
|
|
14
|
+
const name = parts.at(-1)!
|
|
15
|
+
if (name === 'index' || name.endsWith('.lock')) return true
|
|
16
|
+
return parts.some((part, i) => UNGUARDED.has(part) || (part === 'objects' && i + 1 < parts.length && parts[i + 1] !== 'info'))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function scan(gitDir: string): Map<string, Entry> {
|
|
20
|
+
const entries = new Map<string, Entry>()
|
|
21
|
+
const visit = (rel: string) => {
|
|
22
|
+
if (unguarded(rel.split(sep))) return
|
|
23
|
+
const path = join(gitDir, rel)
|
|
24
|
+
let stat
|
|
25
|
+
try {
|
|
26
|
+
stat = lstatSync(path)
|
|
27
|
+
} catch {
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
if (stat.isSymbolicLink()) {
|
|
31
|
+
entries.set(rel, { kind: 'link', target: readlinkSync(path) })
|
|
32
|
+
} else if (stat.isDirectory()) {
|
|
33
|
+
entries.set(rel, { kind: 'dir' })
|
|
34
|
+
for (const name of readdirSync(path)) visit(join(rel, name))
|
|
35
|
+
} else {
|
|
36
|
+
entries.set(rel, stat.isFile() ? { kind: 'file', data: readFileSync(path), mode: stat.mode & 0o7777 } : { kind: 'other' })
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const name of readdirSync(gitDir)) visit(name)
|
|
40
|
+
return entries
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function same(a: Entry, b: Entry): boolean {
|
|
44
|
+
if (a.kind !== b.kind) return false
|
|
45
|
+
if (a.kind === 'file' && b.kind === 'file') return a.mode === b.mode && a.data.equals(b.data)
|
|
46
|
+
if (a.kind === 'link' && b.kind === 'link') return a.target === b.target
|
|
47
|
+
return a.kind === 'dir'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface GitGuard {
|
|
51
|
+
restore(): string[]
|
|
52
|
+
saved(): SavedGuard
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function identityOf(gitDir: string): string {
|
|
56
|
+
const stat = lstatSync(gitDir)
|
|
57
|
+
return `${stat.dev}:${stat.ino}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function guardGitDir(gitDir: string, saved?: SavedGuard): GitGuard {
|
|
61
|
+
const identity = saved?.identity ?? identityOf(gitDir)
|
|
62
|
+
const before: Map<string, Entry> = saved
|
|
63
|
+
? new Map(saved.entries.map(([rel, entry]) => [rel, entry.kind === 'file' ? { ...entry, data: Buffer.from(entry.data, 'base64') } : entry]))
|
|
64
|
+
: scan(gitDir)
|
|
65
|
+
return {
|
|
66
|
+
restore() {
|
|
67
|
+
let stat
|
|
68
|
+
try {
|
|
69
|
+
stat = lstatSync(gitDir)
|
|
70
|
+
} catch {
|
|
71
|
+
throw new Error(`the git directory ${gitDir} is gone`)
|
|
72
|
+
}
|
|
73
|
+
if (!stat.isDirectory() || `${stat.dev}:${stat.ino}` !== identity) throw new Error(`the git directory ${gitDir} was replaced`)
|
|
74
|
+
const now = scan(gitDir)
|
|
75
|
+
const changed = new Set<string>()
|
|
76
|
+
for (const [rel, entry] of [...now].sort((a, b) => b[0].length - a[0].length)) {
|
|
77
|
+
const was = before.get(rel)
|
|
78
|
+
if (was && same(was, entry)) continue
|
|
79
|
+
rmSync(join(gitDir, rel), { recursive: true, force: true })
|
|
80
|
+
changed.add(rel)
|
|
81
|
+
}
|
|
82
|
+
for (const [rel, entry] of [...before].sort((a, b) => a[0].length - b[0].length)) {
|
|
83
|
+
const path = join(gitDir, rel)
|
|
84
|
+
try {
|
|
85
|
+
lstatSync(path)
|
|
86
|
+
continue
|
|
87
|
+
} catch {
|
|
88
|
+
}
|
|
89
|
+
if (entry.kind === 'dir') mkdirSync(path)
|
|
90
|
+
else if (entry.kind === 'link') symlinkSync(entry.target, path)
|
|
91
|
+
else if (entry.kind === 'file') {
|
|
92
|
+
writeFileSync(path, entry.data, { mode: entry.mode })
|
|
93
|
+
chmodSync(path, entry.mode)
|
|
94
|
+
} else continue
|
|
95
|
+
changed.add(rel)
|
|
96
|
+
}
|
|
97
|
+
return [...changed].sort()
|
|
98
|
+
},
|
|
99
|
+
saved() {
|
|
100
|
+
return { identity, entries: [...before].map(([rel, entry]) => [rel, entry.kind === 'file' ? { ...entry, data: entry.data.toString('base64') } : entry]) }
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
5
|
+
import { gitConfigArgs, gitEnvironment } from '@lincyaw/kernel'
|
|
6
|
+
import type { ChangeReview, DocumentFileChange, DocumentFiles, ReviewChange, ReviewChangeKind, ReviewStep } from '@lincyaw/kernel'
|
|
7
|
+
import { guardGitDir } from './git-guard.ts'
|
|
8
|
+
import type { GitGuard } from './git-guard.ts'
|
|
9
|
+
import { holderRuns, readRecord, removeRecord, SELF, writeRecord } from './review-record.ts'
|
|
10
|
+
import type { ReviewRecord, SessionRecord } from './review-record.ts'
|
|
11
|
+
|
|
12
|
+
export function within(path: string, dir: string): boolean {
|
|
13
|
+
const rel = relative(resolve(dir), resolve(path))
|
|
14
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface GitReview extends ChangeReview {
|
|
18
|
+
readonly task: string
|
|
19
|
+
checkpoints(): readonly string[]
|
|
20
|
+
restoreFiles(paths: string[]): Promise<void>
|
|
21
|
+
note(session: Omit<SessionRecord, 'checkpoints' | 'steps'>): void
|
|
22
|
+
suspend(): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const GIT_ENGINE = 'git'
|
|
26
|
+
|
|
27
|
+
const MAX_STEPS = 100
|
|
28
|
+
const MAX_DIFF = 256 * 1024
|
|
29
|
+
const MAX_DIFFS = 200
|
|
30
|
+
|
|
31
|
+
const DIFF = ['diff', '--no-renames', '--no-ext-diff', '--no-textconv', '--no-color']
|
|
32
|
+
|
|
33
|
+
const opened = new Set<string>()
|
|
34
|
+
|
|
35
|
+
function git(cwd: string, args: string[], env: Record<string, string> = {}): Promise<Buffer> {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
execFile('git', [...gitConfigArgs(), ...args], {
|
|
38
|
+
cwd,
|
|
39
|
+
encoding: 'buffer',
|
|
40
|
+
maxBuffer: 256 << 20,
|
|
41
|
+
env: { ...gitEnvironment(process.env), ...env, GIT_OPTIONAL_LOCKS: '0', GIT_LITERAL_PATHSPECS: '1' },
|
|
42
|
+
}, (error, stdout, stderr) => {
|
|
43
|
+
if (error) reject(new Error(`git ${args[0]}: ${stderr.toString('utf8').trim() || error.message}`))
|
|
44
|
+
else resolve(stdout)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const text = async (cwd: string, args: string[], env?: Record<string, string>) => (await git(cwd, args, env)).toString('utf8')
|
|
50
|
+
|
|
51
|
+
export interface GitDirs {
|
|
52
|
+
top: string
|
|
53
|
+
gitDir: string
|
|
54
|
+
commonDir: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function gitDirsOf(root: string): Promise<GitDirs> {
|
|
58
|
+
const [top = '', gitDir = '', commonDir = ''] = (await text(root, ['rev-parse', '--show-toplevel', '--absolute-git-dir', '--git-common-dir'])).split('\n')
|
|
59
|
+
return { top: realpathSync(top), gitDir: realpathSync(gitDir), commonDir: realpathSync(resolve(root, commonDir)) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const READING = '^(core\\.(filemode|symlinks|ignorecase|precomposeunicode|autocrlf|eol|safecrlf|excludesfile)|extensions\\.objectformat)$'
|
|
63
|
+
|
|
64
|
+
const quoted = (value: string) => `"${value.replace(/[\\"]/g, c => `\\${c}`).replace(/\n/g, '\\n')}"`
|
|
65
|
+
|
|
66
|
+
async function readingOf(dirs: GitDirs): Promise<{ config: string, exclude?: string }> {
|
|
67
|
+
const listed = await text(dirs.top, ['config', '-z', '--get-regexp', READING], { GIT_DIR: dirs.gitDir, GIT_COMMON_DIR: dirs.commonDir }).catch(() => '')
|
|
68
|
+
const sections = new Map<string, string[]>()
|
|
69
|
+
for (const entry of listed.split('\0').filter(Boolean)) {
|
|
70
|
+
const at = entry.indexOf('\n')
|
|
71
|
+
const [key, value] = at < 0 ? [entry, 'true'] : [entry.slice(0, at), entry.slice(at + 1)]
|
|
72
|
+
const [section = '', name = ''] = key.split('.')
|
|
73
|
+
sections.set(section, [...sections.get(section) ?? [], `\t${name} = ${quoted(value)}`])
|
|
74
|
+
}
|
|
75
|
+
const version = sections.has('extensions') ? 1 : 0
|
|
76
|
+
const others = [...sections].filter(([section]) => section !== 'core').map(([section, lines]) => `[${section}]\n${lines.join('\n')}`)
|
|
77
|
+
const config = [`[core]\n\trepositoryformatversion = ${version}\n\tbare = false`, ...sections.get('core') ?? [], ...others].join('\n')
|
|
78
|
+
let exclude: string | undefined
|
|
79
|
+
try {
|
|
80
|
+
exclude = readFileSync(join(dirs.commonDir, 'info', 'exclude'), 'utf8')
|
|
81
|
+
} catch {
|
|
82
|
+
}
|
|
83
|
+
return { config: `${config}\n`, ...exclude ? { exclude } : {} }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
class Trees {
|
|
87
|
+
readonly #work = mkdtempSync(join(tmpdir(), 'presmium-review-'))
|
|
88
|
+
#indexes = 0
|
|
89
|
+
readonly #pathspec: string
|
|
90
|
+
readonly #env: Record<string, string>
|
|
91
|
+
readonly #files?: DocumentFiles
|
|
92
|
+
readonly top: string
|
|
93
|
+
readonly deck: string
|
|
94
|
+
readonly root: string
|
|
95
|
+
|
|
96
|
+
constructor(dirs: GitDirs, deck: string, root: string, reading: { config: string, exclude?: string }, options: { files?: DocumentFiles } = {}) {
|
|
97
|
+
this.top = dirs.top
|
|
98
|
+
this.deck = deck
|
|
99
|
+
this.root = root
|
|
100
|
+
this.#pathspec = relative(dirs.top, deck).split(sep).join('/') || '.'
|
|
101
|
+
const own = join(this.#work, 'git')
|
|
102
|
+
for (const dir of ['refs', 'objects', 'info']) mkdirSync(join(own, dir), { recursive: true })
|
|
103
|
+
writeFileSync(join(own, 'HEAD'), 'ref: refs/heads/review\n')
|
|
104
|
+
writeFileSync(join(own, 'config'), reading.config)
|
|
105
|
+
if (reading.exclude !== undefined) writeFileSync(join(own, 'info', 'exclude'), reading.exclude)
|
|
106
|
+
this.#env = { GIT_DIR: own, GIT_OBJECT_DIRECTORY: join(dirs.commonDir, 'objects'), GIT_WORK_TREE: dirs.top }
|
|
107
|
+
this.#files = options.files
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#git(args: string[], index?: string): Promise<Buffer> {
|
|
111
|
+
return git(this.top, args, { ...this.#env, ...index ? { GIT_INDEX_FILE: index } : {} })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async #text(args: string[], index?: string): Promise<string> {
|
|
115
|
+
return (await this.#git(args, index)).toString('utf8')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fromGit = (path: string) => relative(this.root, join(this.top, path)).split(sep).join('/')
|
|
119
|
+
toGit = (path: string) => relative(this.top, join(this.root, path)).split(sep).join('/')
|
|
120
|
+
|
|
121
|
+
#index(): string {
|
|
122
|
+
return join(this.#work, `index-${++this.#indexes}`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async tree(): Promise<string> {
|
|
126
|
+
const index = this.#index()
|
|
127
|
+
try {
|
|
128
|
+
await this.#git(['add', '-A', '--', this.#pathspec], index)
|
|
129
|
+
return (await this.#text(['write-tree'], index)).trim()
|
|
130
|
+
} finally {
|
|
131
|
+
rmSync(index, { force: true })
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async compare(from: string, to: string): Promise<ReviewChange[]> {
|
|
136
|
+
if (from === to) return []
|
|
137
|
+
const status = (await this.#text([...DIFF, '--name-status', '-z', from, to])).split('\0').filter(Boolean)
|
|
138
|
+
const kinds = new Map<string, ReviewChangeKind>()
|
|
139
|
+
for (let i = 0; i + 1 < status.length; i += 2) kinds.set(status[i + 1]!, status[i]!.startsWith('A') ? 'added' : status[i]!.startsWith('D') ? 'deleted' : 'modified')
|
|
140
|
+
const counts = new Map<string, { added: number, removed: number, binary: boolean }>()
|
|
141
|
+
for (const entry of (await this.#text([...DIFF, '--numstat', '-z', from, to])).split('\0').filter(Boolean)) {
|
|
142
|
+
const [added = '0', removed = '0', ...path] = entry.split('\t')
|
|
143
|
+
const binary = added === '-'
|
|
144
|
+
counts.set(path.join('\t'), { added: binary ? 0 : Number(added), removed: binary ? 0 : Number(removed), binary })
|
|
145
|
+
}
|
|
146
|
+
const changes: ReviewChange[] = []
|
|
147
|
+
for (const [path, kind] of kinds) {
|
|
148
|
+
const count = counts.get(path) ?? { added: 0, removed: 0, binary: false }
|
|
149
|
+
const change: ReviewChange = { path: this.fromGit(path), kind, ...count }
|
|
150
|
+
if (!count.binary && changes.filter(c => c.diff).length < MAX_DIFFS) {
|
|
151
|
+
const diff = await this.#text([...DIFF, from, to, '--', path])
|
|
152
|
+
if (diff.length <= MAX_DIFF) change.diff = diff
|
|
153
|
+
}
|
|
154
|
+
changes.push(change)
|
|
155
|
+
}
|
|
156
|
+
return changes.sort((a, b) => a.path.localeCompare(b.path))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async restore(tree: string, paths: string[]): Promise<void> {
|
|
160
|
+
const ours = paths.map(this.toGit).filter(path => within(join(this.top, path), this.deck))
|
|
161
|
+
if (ours.length === 0) return
|
|
162
|
+
if (this.#files) return this.#write(tree, ours)
|
|
163
|
+
const known = new Set((await this.#text(['ls-tree', '-r', '-z', '--name-only', tree, '--', ...ours])).split('\0').filter(Boolean))
|
|
164
|
+
const back = ours.filter(path => known.has(path))
|
|
165
|
+
if (back.length > 0) {
|
|
166
|
+
const index = this.#index()
|
|
167
|
+
try {
|
|
168
|
+
await this.#git(['read-tree', tree], index)
|
|
169
|
+
await this.#git(['checkout-index', '-f', '--', ...back], index)
|
|
170
|
+
} finally {
|
|
171
|
+
rmSync(index, { force: true })
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const path of ours.filter(path => !known.has(path))) {
|
|
175
|
+
const file = join(this.top, path)
|
|
176
|
+
rmSync(file, { force: true })
|
|
177
|
+
for (let dir = dirname(file); within(dir, this.deck) && dir !== this.deck; dir = dirname(dir)) {
|
|
178
|
+
try {
|
|
179
|
+
if (readdirSync(dir).length > 0) break
|
|
180
|
+
rmdirSync(dir)
|
|
181
|
+
} catch {
|
|
182
|
+
break
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async #write(tree: string, paths: string[]): Promise<void> {
|
|
189
|
+
const entries = new Map<string, { mode: string, oid: string }>()
|
|
190
|
+
for (const entry of (await this.#text(['ls-tree', '-r', '-z', tree, '--', ...paths])).split('\0').filter(Boolean)) {
|
|
191
|
+
const [meta = '', path = ''] = entry.split('\t')
|
|
192
|
+
const [mode = '', , oid = ''] = meta.split(' ')
|
|
193
|
+
entries.set(path, { mode, oid })
|
|
194
|
+
}
|
|
195
|
+
const changes: DocumentFileChange[] = []
|
|
196
|
+
for (const path of paths) {
|
|
197
|
+
const entry = entries.get(path)
|
|
198
|
+
if (!entry) {
|
|
199
|
+
changes.push({ path: this.fromGit(path), remove: true })
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
if (entry.mode === '160000') continue
|
|
203
|
+
const blob = await this.#git(['cat-file', 'blob', entry.oid])
|
|
204
|
+
changes.push(entry.mode === '120000' ? { path: this.fromGit(path), link: blob.toString('utf8') } : { path: this.fromGit(path), content: blob, ...entry.mode === '100755' ? { executable: true } : {} })
|
|
205
|
+
}
|
|
206
|
+
await this.#files!.write(changes)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async reset(tree: string): Promise<void> {
|
|
210
|
+
await this.restore(tree, (await this.compare(tree, await this.tree())).map(change => change.path))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
dispose(): void {
|
|
214
|
+
rmSync(this.#work, { recursive: true, force: true })
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface GitReviewOptions {
|
|
219
|
+
workspaceRoot: string
|
|
220
|
+
deckDir: string
|
|
221
|
+
task: string
|
|
222
|
+
onRestore?: (paths: string[]) => void
|
|
223
|
+
warn?: (message: string) => void
|
|
224
|
+
files?: DocumentFiles
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const recovering = new Map<string, Promise<{ task: string, paths: string[] } | undefined>>()
|
|
228
|
+
|
|
229
|
+
function guarding(gitDir: string, deck: string, root: string, onRestore: GitReviewOptions['onRestore'], guard?: GitGuard) {
|
|
230
|
+
const found = guard ?? (within(gitDir, deck) ? guardGitDir(gitDir) : undefined)
|
|
231
|
+
return {
|
|
232
|
+
guard: found,
|
|
233
|
+
restore() {
|
|
234
|
+
const paths = found?.restore() ?? []
|
|
235
|
+
if (paths.length > 0) onRestore?.(paths.map(path => relative(root, join(gitDir, path)).split(sep).join('/')))
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function recoverGitReview(options: Omit<GitReviewOptions, 'task'>): Promise<{ task: string, paths: string[] } | undefined> {
|
|
241
|
+
const deck = realpathSync(options.deckDir)
|
|
242
|
+
let running = recovering.get(deck)
|
|
243
|
+
if (!running) {
|
|
244
|
+
running = recover(deck, realpathSync(options.workspaceRoot), options).finally(() => recovering.delete(deck))
|
|
245
|
+
recovering.set(deck, running)
|
|
246
|
+
}
|
|
247
|
+
return running
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function recover(deck: string, root: string, options: Omit<GitReviewOptions, 'task'>): Promise<{ task: string, paths: string[] } | undefined> {
|
|
251
|
+
const { onRestore } = options
|
|
252
|
+
const record = readRecord(deck)
|
|
253
|
+
if (!record || record.session || opened.has(deck) || holderRuns(record)) return undefined
|
|
254
|
+
let trees: Trees | undefined
|
|
255
|
+
try {
|
|
256
|
+
if (record.guard) guarding(record.dirs.gitDir, deck, root, onRestore, guardGitDir(record.dirs.gitDir, record.guard)).restore()
|
|
257
|
+
trees = new Trees(record.dirs, deck, root, await readingOf(record.dirs), options)
|
|
258
|
+
const paths = (await trees.compare(record.snapshot, await trees.tree())).map(change => change.path)
|
|
259
|
+
await trees.restore(record.snapshot, paths)
|
|
260
|
+
return { task: record.task, paths }
|
|
261
|
+
} finally {
|
|
262
|
+
removeRecord(deck)
|
|
263
|
+
trees?.dispose()
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function leftSessionReview(deckDir: string): ReviewRecord | undefined {
|
|
268
|
+
const deck = realpathSync(deckDir)
|
|
269
|
+
const record = readRecord(deck)
|
|
270
|
+
return record?.session && !opened.has(deck) && !holderRuns(record) ? record : undefined
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function openGitReview(options: GitReviewOptions & { session?: Omit<SessionRecord, 'checkpoints' | 'steps'> }): Promise<GitReview> {
|
|
274
|
+
const deck = realpathSync(options.deckDir)
|
|
275
|
+
const root = realpathSync(options.workspaceRoot)
|
|
276
|
+
if (opened.has(deck)) throw new Error(`a change of ${relative(root, deck) || '.'} is under way already`)
|
|
277
|
+
const left = readRecord(deck)
|
|
278
|
+
if (left && holderRuns(left)) throw new Error(`a change of ${relative(root, deck) || '.'} is under way in another process (${left.pid})`)
|
|
279
|
+
if (left?.session) throw new Error(`a review of ${relative(root, deck) || '.'} waits for its author (session ${left.session.session}): accept or reject it in the workbench first`)
|
|
280
|
+
if (left) await recoverGitReview(options)
|
|
281
|
+
const dirs = await gitDirsOf(root)
|
|
282
|
+
const { guard, restore: guarded } = guarding(dirs.gitDir, deck, root, options.onRestore)
|
|
283
|
+
const trees = new Trees(dirs, deck, root, await readingOf(dirs), options)
|
|
284
|
+
|
|
285
|
+
opened.add(deck)
|
|
286
|
+
let snapshot: string
|
|
287
|
+
try {
|
|
288
|
+
snapshot = await trees.tree()
|
|
289
|
+
} catch (error) {
|
|
290
|
+
opened.delete(deck)
|
|
291
|
+
trees.dispose()
|
|
292
|
+
throw error
|
|
293
|
+
}
|
|
294
|
+
const record: ReviewRecord = { deck, dirs, snapshot, task: options.task, ...SELF, ...guard ? { guard: guard.saved() } : {}, ...options.session ? { session: { ...options.session, checkpoints: [], steps: [] } } : {} }
|
|
295
|
+
return reviewOf({ deck, trees, guarded, record, warn: options.warn })
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export async function resumeGitReview(record: ReviewRecord, options: Omit<GitReviewOptions, 'task' | 'deckDir'>): Promise<GitReview> {
|
|
299
|
+
const deck = realpathSync(record.deck)
|
|
300
|
+
const root = realpathSync(options.workspaceRoot)
|
|
301
|
+
if (opened.has(deck) || holderRuns(record)) throw new Error(`a change of ${relative(root, deck) || '.'} is under way already`)
|
|
302
|
+
const { restore: guarded } = guarding(record.dirs.gitDir, deck, root, options.onRestore, record.guard ? guardGitDir(record.dirs.gitDir, record.guard) : undefined)
|
|
303
|
+
guarded()
|
|
304
|
+
const trees = new Trees(record.dirs, deck, root, await readingOf(record.dirs), options)
|
|
305
|
+
opened.add(deck)
|
|
306
|
+
return reviewOf({ deck, trees, guarded, record: { ...record, ...SELF }, warn: options.warn })
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function reviewOf(open: { deck: string, trees: Trees, guarded: () => void, record: ReviewRecord, warn?: (message: string) => void }): GitReview {
|
|
310
|
+
const { deck, trees, guarded, record } = open
|
|
311
|
+
const { snapshot } = record
|
|
312
|
+
const steps: ReviewStep[] = [...record.session?.steps ?? []]
|
|
313
|
+
const checkpoints = [snapshot, ...record.session?.checkpoints ?? []]
|
|
314
|
+
let made = Math.max(0, ...steps.map(step => Number(/^step-(\d+)$/.exec(step.id)?.[1] ?? 0)))
|
|
315
|
+
let settled = false
|
|
316
|
+
let suspended = false
|
|
317
|
+
|
|
318
|
+
const write = () => {
|
|
319
|
+
try {
|
|
320
|
+
writeRecord({ ...record, ...record.session ? { session: { ...record.session, checkpoints: checkpoints.slice(1), steps } } : {} })
|
|
321
|
+
} catch (error) {
|
|
322
|
+
open.warn?.(`the review's record was not written, so a crash would leave its changes: ${error instanceof Error ? error.message : String(error)}`)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
write()
|
|
326
|
+
|
|
327
|
+
const settle = () => {
|
|
328
|
+
if (settled) return
|
|
329
|
+
settled = true
|
|
330
|
+
opened.delete(deck)
|
|
331
|
+
if (!suspended) removeRecord(deck)
|
|
332
|
+
trees.dispose()
|
|
333
|
+
}
|
|
334
|
+
const live = () => {
|
|
335
|
+
if (settled) throw new Error('this review has ended')
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
root: deck,
|
|
340
|
+
visibility: 'immediate',
|
|
341
|
+
task: record.task,
|
|
342
|
+
checkpoints: () => checkpoints.slice(1),
|
|
343
|
+
async step(label, tool) {
|
|
344
|
+
live()
|
|
345
|
+
guarded()
|
|
346
|
+
const now = await trees.tree()
|
|
347
|
+
if (steps.length >= MAX_STEPS) {
|
|
348
|
+
checkpoints[checkpoints.length - 1] = now
|
|
349
|
+
const last: ReviewStep = { ...steps.at(-1)!, label, ...tool ? { tool } : {}, at: new Date().toISOString(), changes: await trees.compare(checkpoints.at(-2)!, now) }
|
|
350
|
+
steps[steps.length - 1] = last
|
|
351
|
+
if (record.session) write()
|
|
352
|
+
return last
|
|
353
|
+
}
|
|
354
|
+
const step: ReviewStep = { id: `step-${++made}`, label, ...tool ? { tool } : {}, at: new Date().toISOString(), changes: await trees.compare(checkpoints.at(-1)!, now) }
|
|
355
|
+
checkpoints.push(now)
|
|
356
|
+
steps.push(step)
|
|
357
|
+
if (record.session) write()
|
|
358
|
+
return step
|
|
359
|
+
},
|
|
360
|
+
steps: () => steps,
|
|
361
|
+
async diff() {
|
|
362
|
+
live()
|
|
363
|
+
guarded()
|
|
364
|
+
return trees.compare(snapshot, await trees.tree())
|
|
365
|
+
},
|
|
366
|
+
async restoreTo(stepId) {
|
|
367
|
+
live()
|
|
368
|
+
const at = steps.findIndex(step => step.id === stepId)
|
|
369
|
+
if (at < 0) throw new Error(`no step ${stepId} in this review`)
|
|
370
|
+
guarded()
|
|
371
|
+
await trees.reset(checkpoints[at]!)
|
|
372
|
+
steps.splice(at)
|
|
373
|
+
checkpoints.splice(at + 1)
|
|
374
|
+
if (record.session) write()
|
|
375
|
+
},
|
|
376
|
+
async restoreFiles(paths) {
|
|
377
|
+
live()
|
|
378
|
+
guarded()
|
|
379
|
+
const changed = new Set((await trees.compare(snapshot, await trees.tree())).map(change => change.path))
|
|
380
|
+
await trees.restore(snapshot, paths.filter(path => changed.has(path)))
|
|
381
|
+
},
|
|
382
|
+
note(session) {
|
|
383
|
+
if (!record.session) return
|
|
384
|
+
record.session = { ...record.session, ...session }
|
|
385
|
+
write()
|
|
386
|
+
},
|
|
387
|
+
async accept(reject = []) {
|
|
388
|
+
live()
|
|
389
|
+
guarded()
|
|
390
|
+
const changes = await trees.compare(snapshot, await trees.tree())
|
|
391
|
+
const rejected = new Set(reject.filter(path => changes.some(change => change.path === path)))
|
|
392
|
+
await trees.restore(snapshot, [...rejected])
|
|
393
|
+
settle()
|
|
394
|
+
return changes.filter(change => !rejected.has(change.path)).map(change => change.path)
|
|
395
|
+
},
|
|
396
|
+
async rollback() {
|
|
397
|
+
live()
|
|
398
|
+
guarded()
|
|
399
|
+
await trees.reset(snapshot)
|
|
400
|
+
settle()
|
|
401
|
+
},
|
|
402
|
+
async close() {
|
|
403
|
+
if (settled) return
|
|
404
|
+
try {
|
|
405
|
+
guarded()
|
|
406
|
+
await trees.reset(snapshot)
|
|
407
|
+
} finally {
|
|
408
|
+
settle()
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
suspend() {
|
|
412
|
+
suspended = true
|
|
413
|
+
settle()
|
|
414
|
+
},
|
|
415
|
+
}
|
|
416
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import type { Member, ReviewStep } from '@lincyaw/kernel'
|
|
6
|
+
import type { SavedGuard } from './git-guard.ts'
|
|
7
|
+
import type { GitDirs } from './git-review.ts'
|
|
8
|
+
|
|
9
|
+
export interface SessionRecord {
|
|
10
|
+
session: string
|
|
11
|
+
document: string
|
|
12
|
+
member: Member
|
|
13
|
+
turns: { from: number, to: number }
|
|
14
|
+
opened: string
|
|
15
|
+
checkpoints: string[]
|
|
16
|
+
steps: ReviewStep[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ReviewRecord {
|
|
20
|
+
deck: string
|
|
21
|
+
dirs: GitDirs
|
|
22
|
+
snapshot: string
|
|
23
|
+
task: string
|
|
24
|
+
pid: number
|
|
25
|
+
started?: string
|
|
26
|
+
guard?: SavedGuard
|
|
27
|
+
session?: SessionRecord
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function recordsDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
31
|
+
return join(env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state'), 'presmium', 'reviews')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const fileOf = (deck: string) => join(recordsDir(), `${createHash('sha256').update(deck).digest('hex').slice(0, 24)}.json`)
|
|
35
|
+
|
|
36
|
+
export function startTime(pid: number): string | undefined {
|
|
37
|
+
try {
|
|
38
|
+
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
|
|
39
|
+
return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19]
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const SELF = { pid: process.pid, started: startTime(process.pid) }
|
|
46
|
+
|
|
47
|
+
export function holderRuns(record: ReviewRecord): boolean {
|
|
48
|
+
try {
|
|
49
|
+
process.kill(record.pid, 0)
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM'
|
|
52
|
+
}
|
|
53
|
+
return record.started === undefined || startTime(record.pid) === record.started
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readRecord(deck: string): ReviewRecord | undefined {
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(readFileSync(fileOf(deck), 'utf8')) as ReviewRecord
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function writeRecord(record: ReviewRecord): void {
|
|
65
|
+
const file = fileOf(record.deck)
|
|
66
|
+
mkdirSync(recordsDir(), { recursive: true, mode: 0o700 })
|
|
67
|
+
writeFileSync(`${file}.${process.pid}`, JSON.stringify(record), { mode: 0o600 })
|
|
68
|
+
renameSync(`${file}.${process.pid}`, file)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function removeRecord(deck: string): void {
|
|
72
|
+
rmSync(fileOf(deck), { force: true })
|
|
73
|
+
}
|