@modesto_ma/dsh-guanxiang 0.2.0-beta.3 → 0.2.0-beta.4

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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * dsh-repo `dshRepos` 服务的查找。观象经它维护应用仓库的本地镜像和只读快照、
3
+ * 管理主机与令牌,并把 SKILL 修改提议提交为合并请求(GitLab MR 或 GitHub PR)。
4
+ *
5
+ * 只从 `dsh-repo/contract` 引入类型;服务在运行时经 `ctx.get('dshRepos')`
6
+ * 取得,版本不符或缺少本插件用到的方法(早于 0.1.0-beta.3 的 dsh-repo)时
7
+ * 不调用。
8
+ *
9
+ * @module dsh-guanxiang/repos/lookup
10
+ */
11
+
12
+ import type { DshRepoService, DshReposVersion, RepoErrorCode } from 'dsh-repo/contract'
13
+
14
+ /** Name dsh-repo provides the service under. */
15
+ export const DSH_REPOS_SERVICE = 'dshRepos'
16
+
17
+ /** The `dshRepos` contract version this plugin speaks. */
18
+ export const DSH_REPOS_VERSION: DshReposVersion = 1
19
+
20
+ /** Oldest dsh-repo release providing every method {@link REQUIRED_METHODS} lists. */
21
+ export const DSH_REPO_MIN_RELEASE = '0.1.0-beta.3'
22
+
23
+ /** Methods this plugin calls; `getBranch` also marks a provider that honors `baseSha` and `newBranch` on commits. */
24
+ const REQUIRED_METHODS = [
25
+ 'getBranch', 'commitFiles', 'createChange',
26
+ 'listHosts', 'addHost', 'removeHost', 'setHostToken',
27
+ 'mirrorSettings', 'syncMirror', 'readMirrorFile', 'listMirrorTree', 'acquireSnapshot',
28
+ ] as const satisfies readonly (keyof DshRepoService)[]
29
+
30
+ /** Result of looking the service up. */
31
+ export type ReposLookup =
32
+ | { readonly availability: 'ready'; readonly service: DshRepoService }
33
+ | { readonly availability: 'missing' | 'incompatible' }
34
+
35
+ /**
36
+ * Classify the value `ctx.get('dshRepos')` returned. The provider is another
37
+ * package that may be older or newer than this one, so its version and
38
+ * methods are checked at run time.
39
+ * @param value - the looked-up service, or undefined.
40
+ * @returns the usable service, or why there is none.
41
+ */
42
+ export function lookupRepos(value: unknown): ReposLookup {
43
+ if (value === undefined || value === null) return { availability: 'missing' }
44
+ const service = value as Partial<Record<keyof DshRepoService, unknown>>
45
+ if (service.version !== DSH_REPOS_VERSION || REQUIRED_METHODS.some(method => typeof service[method] !== 'function')) {
46
+ return { availability: 'incompatible' }
47
+ }
48
+ return { availability: 'ready', service: value as DshRepoService }
49
+ }
50
+
51
+ /**
52
+ * The failure class of a `dshRepos` rejection.
53
+ * @param error - the rejection.
54
+ * @returns its `code` when it is a `RepoError`.
55
+ */
56
+ export function repoErrorCode(error: unknown): RepoErrorCode | undefined {
57
+ if (!(error instanceof Error) || error.name !== 'RepoError') return undefined
58
+ const code = (error as Error & { code?: unknown }).code
59
+ return typeof code === 'string' ? code as RepoErrorCode : undefined
60
+ }
61
+
62
+ /**
63
+ * Why the service cannot be used, as one line for the panel and tool errors.
64
+ * @param availability - lookup outcome other than `ready`.
65
+ * @returns the reason and the fix.
66
+ */
67
+ export function unavailableMessage(availability: 'missing' | 'incompatible'): string {
68
+ return availability === 'missing'
69
+ ? `观象需要 dsh-repo 插件提供的 ${DSH_REPOS_SERVICE} 服务来拉取仓库和提交 SKILL 修改;请安装 dsh-repo`
70
+ : `dsh-repo 的 ${DSH_REPOS_SERVICE} 服务与观象不兼容(需要服务版本 ${DSH_REPOS_VERSION},即 dsh-repo ${DSH_REPO_MIN_RELEASE} 及以上);请升级 dsh-repo`
71
+ }
72
+
73
+ /**
74
+ * The usable service, or a thrown error saying why there is none.
75
+ * @param value - the looked-up service, or undefined.
76
+ * @returns the service.
77
+ * @throws Error with {@link unavailableMessage} when it is missing or incompatible.
78
+ */
79
+ export function requireRepos(value: unknown): DshRepoService {
80
+ const lookup = lookupRepos(value)
81
+ if (lookup.availability !== 'ready') throw new Error(unavailableMessage(lookup.availability))
82
+ return lookup.service
83
+ }
84
+
85
+ /**
86
+ * Readable message of a `dshRepos` rejection or any other thrown value.
87
+ * @param error - the rejection.
88
+ * @returns its message.
89
+ */
90
+ export function messageOf(error: unknown): string {
91
+ return error instanceof Error ? error.message : String(error)
92
+ }
@@ -1,42 +1,27 @@
1
1
  /**
2
- * 应用注册表:把设置中的 git 地址拉取到 `<reposDir>/<仓库名>`,从远端分支最新
3
- * 提交读取各仓库的应用描述,对外提供「已就绪的应用」,并为每次派发提供该
4
- * 提交的只读代码快照。
2
+ * 应用注册表:经 dsh-repo 的本地镜像拉取设置中的 git 地址,从远端分支最新提交
3
+ * 读取各仓库的应用描述,对外提供「已就绪的应用」,并为每次派发持有该提交的
4
+ * 只读代码快照。
5
5
  *
6
- * - 拉取目录只做 fetch,不检出:新地址 `git clone --no-checkout`(先克隆到临时
7
- * 目录再改名,半成品不会被当成仓库),已有目录 `git fetch`。应用描述与 SKILL
8
- * 直接从提交的 git 对象读取,所以随时可以更新,不受正在进行的分析影响。
9
- * - 子 Agent 在 `<reposDir>/.snapshots/<仓库名>/` 下按提交创建的 `git worktree`
10
- * 快照里工作。快照创建后不再改动;同一提交的派发共用一份,没有使用者且已不是
11
- * 最新提交时删除。进程内首次同步某仓库时清掉上次运行遗留的快照。
12
- * - 更新失败时继续使用已有提交,并在状态里给出原因;应用描述或 SKILL 不合法时
13
- * 该应用不可用。
6
+ * - 镜像、快照和令牌都由 dsh-repo 管理:镜像只做 fetch,不检出;应用描述与
7
+ * SKILL 直接从提交读取,所以随时可以更新,不受正在进行的分析影响。快照在
8
+ * 所有持有者释放后由 dsh-repo 清理。
9
+ * - dsh-repo 不可用或更新失败时继续使用已有提交,并在状态里给出原因;应用描述
10
+ * 或 SKILL 不合法时该应用不可用。
14
11
  * - 多个仓库声明同一个应用 id 时,这些仓库全部不可用。
15
12
  *
16
13
  * @module dsh-guanxiang/repos/registry
17
14
  */
18
15
 
19
- import { createHash, randomBytes } from 'node:crypto'
20
- import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises'
21
- import { basename, dirname, join } from 'node:path'
22
- import { expandHome } from '../config.ts'
16
+ import type { DshRepoService, SnapshotLease } from 'dsh-repo/contract'
23
17
  import type { AppManifest, RepoSpec, RepoStatusWire } from '../settings/contract.ts'
24
- import { GitError } from './git.ts'
25
- import type { GitRunner } from './git.ts'
18
+ import { messageOf, requireRepos } from './lookup.ts'
26
19
  import { checkAppManifest } from './manifest.ts'
27
- import type { RepoFileReader } from './manifest.ts'
28
20
 
29
21
  /** Collaborators of the registry. */
30
22
  export interface RepoRegistryDeps {
31
- readonly git: GitRunner
32
- /** Timeout of one git command. */
33
- readonly gitTimeoutMs: number
34
- /**
35
- * Environment for the network commands (clone, fetch, remote HEAD lookup) of
36
- * one address, such as an access token; resolved again on every sync. A
37
- * rejection fails that sync with its message.
38
- */
39
- readonly auth?: (url: string) => Promise<Readonly<Record<string, string>>>
23
+ /** The current `ctx.get('dshRepos')` value, looked up on every use. */
24
+ readonly repos: () => unknown
40
25
  /** Called after any status change (sync start/end, configuration, leases). */
41
26
  readonly onChange: () => void
42
27
  readonly now?: () => number
@@ -55,162 +40,58 @@ export interface LeasedApp extends ReadyApp {
55
40
  readonly dir: string
56
41
  }
57
42
 
58
- /** A clone opened for writing objects; see {@link RepoRegistry.withClone}. */
59
- export interface CloneAccess {
60
- /** Fetch-only clone directory. */
61
- readonly dir: string
62
- /** The configured branch, or the remote's default branch when none is configured. */
63
- readonly branch: string
64
- }
65
-
66
43
  /** Held snapshots of one dispatch; release exactly once. */
67
44
  export interface RepoLease {
68
45
  readonly apps: ReadonlyMap<string, LeasedApp>
69
46
  release(): void
70
47
  }
71
48
 
72
- interface Snapshot {
73
- readonly commit: string
74
- readonly dir: string
75
- /** Leases holding this snapshot. */
76
- users: number
77
- /** Settles once `git worktree add` finished; rejects when it failed. */
78
- readonly created: Promise<void>
79
- }
80
-
81
49
  interface RepoEntry {
82
50
  readonly spec: RepoSpec
83
- /** Fetch-only clone. */
84
- readonly dir: string
51
+ /** Mirror directory reported by the last successful sync. */
52
+ dir?: string
53
+ /** Branch the last successful sync resolved. */
54
+ branch?: string
85
55
  commit?: string
86
56
  commitTime?: string
87
57
  app?: AppManifest
88
58
  error?: string
89
59
  syncedAt?: number
90
60
  syncing?: Promise<void>
91
- /** Snapshots by commit. */
92
- readonly snapshots: Map<string, Snapshot>
93
61
  activeRuns: number
94
62
  /** Removed from the configuration; kept only until in-flight work settles. */
95
63
  retired: boolean
96
64
  }
97
65
 
98
- /**
99
- * Checkout directory name for a git address: its last path segment without
100
- * `.git`, restricted to `[A-Za-z0-9._-]`.
101
- * @param url - git address.
102
- * @returns directory name.
103
- */
104
- export function repoDirName(url: string): string {
105
- const path = url.replace(/^[^@/]+@[^:/]+:/, '').replace(/[?#].*$/, '').replace(/\/+$/, '')
106
- const last = path.split(/[/\\:]/).pop() ?? ''
107
- const name = last.replace(/\.git$/, '').replace(/[^A-Za-z0-9._-]/g, '-').replace(/^[.-]+/, '')
108
- return name === '' ? 'repo' : name
109
- }
110
-
111
- /**
112
- * Assign each address a distinct directory; colliding names get a short hash
113
- * of their address appended.
114
- * @param reposDir - parent directory (may start with `~`).
115
- * @param urls - configured addresses.
116
- * @returns absolute directory per address.
117
- */
118
- export function assignDirs(reposDir: string, urls: readonly string[]): Map<string, string> {
119
- const root = expandHome(reposDir)
120
- const counts = new Map<string, number>()
121
- for (const url of urls) counts.set(repoDirName(url), (counts.get(repoDirName(url)) ?? 0) + 1)
122
- const dirs = new Map<string, string>()
123
- for (const url of urls) {
124
- const name = repoDirName(url)
125
- const unique = (counts.get(name) ?? 0) > 1
126
- ? `${name}-${createHash('sha1').update(url).digest('hex').slice(0, 8)}`
127
- : name
128
- dirs.set(url, join(root, unique))
129
- }
130
- return dirs
131
- }
132
-
133
- /**
134
- * Directory holding the commit snapshots of one clone.
135
- * @param dir - clone directory `<reposDir>/<name>`.
136
- * @returns `<reposDir>/.snapshots/<name>`.
137
- */
138
- export function snapshotRoot(dir: string): string {
139
- return join(dirname(dir), '.snapshots', basename(dir))
140
- }
141
-
142
- /**
143
- * Run a task after every task queued earlier under the same key.
144
- * @param locks - tail of each key's queue.
145
- * @param key - queue key.
146
- * @param task - work to run.
147
- * @returns the task's result.
148
- */
149
- function serialize<T>(locks: Map<string, Promise<void>>, key: string, task: () => Promise<T>): Promise<T> {
150
- const run = (locks.get(key) ?? Promise.resolve()).then(task)
151
- // The queue only orders work; callers observe the failure through `run`.
152
- const tail = run.then(() => undefined, () => undefined)
153
- locks.set(key, tail)
154
- void tail.then(() => { if (locks.get(key) === tail) locks.delete(key) })
155
- return run
156
- }
157
-
158
- function messageOf(error: unknown): string {
159
- if (error instanceof GitError) return error.output === '' ? error.message : error.output
160
- return error instanceof Error ? error.message : String(error)
161
- }
162
-
163
- async function pathKind(path: string): Promise<'missing' | 'directory' | 'other'> {
164
- try {
165
- return (await stat(path)).isDirectory() ? 'directory' : 'other'
166
- } catch (error) {
167
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing'
168
- throw error
169
- }
170
- }
171
-
172
- /** Clones, updates, and indexes the configured application repositories. */
66
+ /** Mirrors, updates, and indexes the configured application repositories. */
173
67
  export class RepoRegistry {
174
68
  private entries: RepoEntry[] = []
175
- /** Orders clone and fetch work per clone directory. */
176
- private readonly syncLocks = new Map<string, Promise<void>>()
177
- /** Orders `git worktree` work per clone directory. */
178
- private readonly worktreeLocks = new Map<string, Promise<void>>()
179
- /** Snapshot roots already cleared of leftovers from an earlier run. */
180
- private readonly clearedRoots = new Set<string>()
181
69
  private readonly now: () => number
182
70
 
183
- /** @param deps - git runner, timeout, and change notification. */
71
+ /** @param deps - service lookup and change notification. */
184
72
  constructor(private readonly deps: RepoRegistryDeps) {
185
73
  this.now = deps.now ?? Date.now
186
74
  }
187
75
 
188
76
  /**
189
77
  * Replace the configured repositories. An address keeps its status when its
190
- * branch and directory are unchanged; clones of removed addresses stay on
191
- * disk, and their snapshots are deleted once no dispatch holds them.
192
- * @param reposDir - parent directory of the clones.
78
+ * branch is unchanged.
193
79
  * @param repos - configured repositories.
194
- * @returns entries that are new or changed and therefore need a sync.
80
+ * @returns addresses that are new or changed and therefore need a sync.
195
81
  */
196
- configure(reposDir: string, repos: readonly RepoSpec[]): string[] {
197
- const dirs = assignDirs(reposDir, repos.map(repo => repo.url))
82
+ configure(repos: readonly RepoSpec[]): string[] {
198
83
  const previous = new Map(this.entries.map(entry => [entry.spec.url, entry]))
199
84
  const changed: string[] = []
200
85
  this.entries = repos.map((spec) => {
201
- const dir = dirs.get(spec.url) as string
202
86
  const kept = previous.get(spec.url)
203
- if (kept !== undefined && kept.dir === dir && kept.spec.branch === spec.branch) {
87
+ if (kept !== undefined && kept.spec.branch === spec.branch) {
204
88
  previous.delete(spec.url)
205
89
  return kept
206
90
  }
207
91
  changed.push(spec.url)
208
- return { spec: { ...spec }, dir, snapshots: new Map(), activeRuns: 0, retired: false }
92
+ return { spec: { ...spec }, activeRuns: 0, retired: false }
209
93
  })
210
- for (const entry of previous.values()) {
211
- entry.retired = true
212
- this.pruneSnapshots(entry)
213
- }
94
+ for (const entry of previous.values()) entry.retired = true
214
95
  this.deps.onChange()
215
96
  return changed
216
97
  }
@@ -234,16 +115,14 @@ export class RepoRegistry {
234
115
  }
235
116
 
236
117
  /**
237
- * Sync the configured repositories with the given addresses again after any
238
- * sync already running for them, so the new sync sees settings changed since
239
- * that sync started (an access token, for example). An idle repository
240
- * starts syncing before this returns, so its status reads `syncing` at once.
241
- * @param urls - addresses to sync.
118
+ * Sync every configured repository again after any sync already running for
119
+ * it, so the new sync sees changes made since that sync started (a host
120
+ * token, for example). An idle repository starts syncing before this
121
+ * returns, so its status reads `syncing` at once.
242
122
  * @returns when those syncs settled.
243
123
  */
244
- async resyncUrls(urls: readonly string[]): Promise<void> {
245
- const wanted = new Set(urls)
246
- await Promise.all(this.entries.filter(entry => wanted.has(entry.spec.url)).map(entry => entry.syncing === undefined
124
+ async resyncAll(): Promise<void> {
125
+ await Promise.all(this.entries.map(entry => entry.syncing === undefined
247
126
  ? this.syncEntry(entry)
248
127
  // The running sync's outcome is superseded by the one started after it.
249
128
  : entry.syncing.then(() => undefined, () => undefined).then(() => this.syncEntry(entry))))
@@ -284,7 +163,7 @@ export class RepoRegistry {
284
163
  return {
285
164
  url: entry.spec.url,
286
165
  ...entry.spec.branch === undefined ? {} : { branch: entry.spec.branch },
287
- dir: entry.dir,
166
+ ...entry.dir === undefined ? {} : { dir: entry.dir },
288
167
  phase,
289
168
  ...entry.commit === undefined ? {} : { commit: entry.commit },
290
169
  ...entry.commitTime === undefined ? {} : { commitTime: entry.commitTime },
@@ -315,65 +194,57 @@ export class RepoRegistry {
315
194
 
316
195
  /**
317
196
  * Hold a snapshot of each requested application's latest synced commit for
318
- * one dispatch, creating the snapshots that do not exist yet. Every id must
319
- * resolve to a usable application, or nothing is held.
197
+ * one dispatch. Every id must resolve to a usable application, or nothing is
198
+ * held.
320
199
  * @param ids - requested application ids (duplicates allowed).
321
200
  * @returns the lease.
322
- * @throws when an id is unknown, its repository is not usable, or a snapshot cannot be created.
201
+ * @throws when an id is unknown, its repository is not usable, dsh-repo is unavailable, or a snapshot cannot be created.
323
202
  */
324
203
  async acquire(ids: readonly string[]): Promise<RepoLease> {
325
204
  const unique = [...new Set(ids)]
326
205
  const ready = new Map(this.readyEntries().map(item => [item.ready.app.id, item]))
327
206
  const missing = unique.filter(id => !ready.has(id))
328
207
  if (missing.length > 0) throw new Error(this.unknownMessage(missing))
208
+ const service = requireRepos(this.deps.repos())
329
209
  const held = unique.map((id) => {
330
210
  const { entry, ready: app } = ready.get(id) as { entry: RepoEntry; ready: ReadyApp }
331
- const snapshot = this.snapshotFor(entry, app.commit)
332
- snapshot.users += 1
333
211
  entry.activeRuns += 1
334
- return { id, entry, app, snapshot }
212
+ return { id, entry, app, snapshot: service.acquireSnapshot(app.url, app.commit) }
335
213
  })
336
214
  this.deps.onChange()
215
+ const outcomes = await Promise.allSettled(held.map(item => item.snapshot))
216
+ const leases = outcomes.flatMap(outcome => outcome.status === 'fulfilled' ? [outcome.value] : [])
337
217
  let released = false
338
218
  const release = (): void => {
339
219
  if (released) return
340
220
  released = true
341
- for (const { entry, snapshot } of held) {
342
- snapshot.users -= 1
343
- entry.activeRuns -= 1
344
- }
345
- for (const entry of new Set(held.map(item => item.entry))) this.pruneSnapshots(entry)
221
+ for (const lease of leases) lease.release()
222
+ for (const { entry } of held) entry.activeRuns -= 1
346
223
  this.deps.onChange()
347
224
  }
348
- const created = await Promise.allSettled(held.map(item => item.snapshot.created))
349
- for (const [index, outcome] of created.entries()) {
225
+ for (const [index, outcome] of outcomes.entries()) {
350
226
  if (outcome.status === 'fulfilled') continue
351
227
  release()
352
228
  throw new Error(`应用「${held[index]?.id}」的代码快照创建失败:${messageOf(outcome.reason)}`)
353
229
  }
354
230
  return {
355
- apps: new Map(held.map(({ id, app, snapshot }) => [id, { ...app, dir: snapshot.dir }])),
231
+ apps: new Map(held.map(({ id, app }, index) => [id, { ...app, dir: (leases[index] as SnapshotLease).dir }])),
356
232
  release,
357
233
  }
358
234
  }
359
235
 
360
236
  /**
361
- * Run a task on the clone of a configured address after every sync queued
362
- * before it, so the task never overlaps a fetch of the same clone.
237
+ * The branch the last successful sync of a configured address resolved: the
238
+ * configured branch, or the remote's default branch when none is configured.
363
239
  * @param url - configured repository address.
364
- * @param task - work on the clone.
365
- * @returns the task's result.
366
- * @throws when the address is not configured, its token cannot be read, or the task fails.
240
+ * @returns the branch.
241
+ * @throws when the address is not configured or has not synced yet.
367
242
  */
368
- async withClone<T>(url: string, task: (clone: CloneAccess) => Promise<T>): Promise<T> {
243
+ branchOf(url: string): string {
369
244
  const entry = this.entries.find(item => item.spec.url === url)
370
245
  if (entry === undefined) throw new Error(`仓库 ${url} 已不在配置中`)
371
- return await serialize(this.syncLocks, entry.dir, async () => {
372
- if (entry.retired) throw new Error(`仓库 ${url} 已不在配置中`)
373
- const env = await this.deps.auth?.(url) ?? {}
374
- const branch = entry.spec.branch ?? (await this.remoteHead(entry.dir, env)).replace(/^refs\/remotes\/origin\//, '')
375
- return await task({ dir: entry.dir, branch })
376
- })
246
+ if (entry.branch === undefined) throw new Error(`仓库 ${url} 还没有同步成功`)
247
+ return entry.branch
377
248
  }
378
249
 
379
250
  private unknownMessage(ids: readonly string[]): string {
@@ -397,7 +268,7 @@ export class RepoRegistry {
397
268
 
398
269
  private syncEntry(entry: RepoEntry): Promise<void> {
399
270
  if (entry.syncing !== undefined) return entry.syncing
400
- const run = serialize(this.syncLocks, entry.dir, () => this.runSync(entry)).finally(() => {
271
+ const run = this.runSync(entry).finally(() => {
401
272
  entry.syncing = undefined
402
273
  this.deps.onChange()
403
274
  })
@@ -408,9 +279,11 @@ export class RepoRegistry {
408
279
 
409
280
  private async runSync(entry: RepoEntry): Promise<void> {
410
281
  if (entry.retired) return
411
- let head: { commit: string; commitTime: string }
282
+ let service: DshRepoService
283
+ let head: { dir: string; branch: string; commit: string; commitTime: string }
412
284
  try {
413
- head = await this.update(entry)
285
+ service = requireRepos(this.deps.repos())
286
+ head = await service.syncMirror(entry.spec.url, entry.spec.branch === undefined ? {} : { branch: entry.spec.branch })
414
287
  } catch (error) {
415
288
  entry.syncedAt = this.now()
416
289
  entry.error = entry.commit !== undefined && entry.app !== undefined
@@ -418,8 +291,10 @@ export class RepoRegistry {
418
291
  : messageOf(error)
419
292
  return
420
293
  }
421
- const manifest = await checkAppManifest(this.commitReader(entry.dir, head.commit))
294
+ const manifest = await checkAppManifest(path => service.readMirrorFile(entry.spec.url, head.commit, path))
422
295
  entry.syncedAt = this.now()
296
+ entry.dir = head.dir
297
+ entry.branch = head.branch
423
298
  entry.commit = head.commit
424
299
  entry.commitTime = head.commitTime
425
300
  if (manifest.ok) {
@@ -429,116 +304,5 @@ export class RepoRegistry {
429
304
  entry.app = undefined
430
305
  entry.error = manifest.error
431
306
  }
432
- this.pruneSnapshots(entry)
433
- }
434
-
435
- /**
436
- * Clone or fetch, and resolve the commit the configured branch points to.
437
- * The first visit of a snapshot root in this process deletes the snapshots
438
- * an earlier run left behind.
439
- */
440
- private async update(entry: RepoEntry): Promise<{ commit: string; commitTime: string }> {
441
- const { git, gitTimeoutMs: timeoutMs } = this.deps
442
- const { dir, spec } = entry
443
- const root = snapshotRoot(dir)
444
- const firstVisit = !this.clearedRoots.has(root)
445
- if (firstVisit) {
446
- this.clearedRoots.add(root)
447
- await rm(root, { recursive: true, force: true })
448
- }
449
- const env = await this.deps.auth?.(spec.url) ?? {}
450
- const kind = await pathKind(dir)
451
- if (kind === 'missing') {
452
- await mkdir(dirname(dir), { recursive: true })
453
- const partial = `${dir}.partial-${randomBytes(4).toString('hex')}`
454
- try {
455
- await git(['clone', '--quiet', '--no-checkout', ...spec.branch === undefined ? [] : ['--branch', spec.branch], '--', spec.url, partial], { timeoutMs, env })
456
- await rename(partial, dir)
457
- } catch (error) {
458
- await rm(partial, { recursive: true, force: true })
459
- throw error
460
- }
461
- } else {
462
- if (kind !== 'directory') throw new Error(`${dir} 已存在且不是目录`)
463
- if (await pathKind(join(dir, '.git')) === 'missing') {
464
- const empty = (await readdir(dir)).length === 0
465
- if (!empty) throw new Error(`${dir} 已存在且不是 git 仓库,请移走后重新拉取`)
466
- await rm(dir, { recursive: true })
467
- return await this.update(entry)
468
- }
469
- const origin = (await git(['remote', 'get-url', 'origin'], { cwd: dir, timeoutMs })).trim()
470
- if (origin !== spec.url) throw new Error(`${dir} 已被另一个仓库占用(origin 为 ${origin})`)
471
- await git(['fetch', '--quiet', '--prune', 'origin'], { cwd: dir, timeoutMs, env })
472
- if (firstVisit) await git(['worktree', 'prune'], { cwd: dir, timeoutMs })
473
- }
474
- const target = spec.branch === undefined ? await this.remoteHead(dir, env) : `refs/remotes/origin/${spec.branch}`
475
- const [commit = '', commitTime = ''] = (await git(['log', '-1', '--format=%H%n%cI', target, '--'], { cwd: dir, timeoutMs })).trim().split('\n')
476
- return { commit, commitTime }
477
- }
478
-
479
- /**
480
- * Reader for the files of one commit, from the clone's object database.
481
- * Only regular files are read; a symbolic link is reported as unreadable.
482
- */
483
- private commitReader(dir: string, commit: string): RepoFileReader {
484
- const { git, gitTimeoutMs: timeoutMs } = this.deps
485
- return async (path) => {
486
- const listing = await git(['ls-tree', '-z', commit, '--', path], { cwd: dir, timeoutMs })
487
- if (listing === '') return undefined
488
- const [mode, type, object] = listing.slice(0, listing.indexOf('\t')).split(' ')
489
- if (type !== 'blob' || mode === '120000' || object === undefined) throw new Error(`${path} 不是普通文件`)
490
- return await git(['cat-file', 'blob', object], { cwd: dir, timeoutMs })
491
- }
492
- }
493
-
494
- /** The snapshot of `commit`, created in the background on first use. */
495
- private snapshotFor(entry: RepoEntry, commit: string): Snapshot {
496
- const existing = entry.snapshots.get(commit)
497
- if (existing !== undefined) return existing
498
- const dir = join(snapshotRoot(entry.dir), `${commit.slice(0, 12)}-${randomBytes(3).toString('hex')}`)
499
- const created = serialize(this.worktreeLocks, entry.dir, async () => {
500
- await mkdir(dirname(dir), { recursive: true })
501
- await this.deps.git(['worktree', 'add', '--detach', dir, commit], { cwd: entry.dir, timeoutMs: this.deps.gitTimeoutMs })
502
- })
503
- const snapshot: Snapshot = { commit, dir, users: 0, created }
504
- entry.snapshots.set(commit, snapshot)
505
- created.catch(() => {
506
- // The acquiring dispatch reports the failure; the next one retries.
507
- if (entry.snapshots.get(commit) === snapshot) entry.snapshots.delete(commit)
508
- void this.removeSnapshot(entry.dir, dir)
509
- })
510
- return snapshot
511
- }
512
-
513
- /** Delete the snapshots no lease holds, except the latest commit's of a configured repository. */
514
- private pruneSnapshots(entry: RepoEntry): void {
515
- for (const [commit, snapshot] of entry.snapshots) {
516
- if (snapshot.users > 0 || (commit === entry.commit && !entry.retired)) continue
517
- entry.snapshots.delete(commit)
518
- void snapshot.created.then(() => this.removeSnapshot(entry.dir, snapshot.dir), () => undefined)
519
- }
520
- }
521
-
522
- private async removeSnapshot(clone: string, dir: string): Promise<void> {
523
- try {
524
- await serialize(this.worktreeLocks, clone, async () => {
525
- await rm(dir, { recursive: true, force: true })
526
- await this.deps.git(['worktree', 'prune'], { cwd: clone, timeoutMs: this.deps.gitTimeoutMs })
527
- })
528
- } catch {
529
- // Best effort: the first sync after the next start deletes leftover snapshots.
530
- }
531
- }
532
-
533
- private async remoteHead(dir: string, env: Readonly<Record<string, string>>): Promise<string> {
534
- const { git, gitTimeoutMs: timeoutMs } = this.deps
535
- const read = async (): Promise<string> => (await git(['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd: dir, timeoutMs })).trim()
536
- try {
537
- return await read()
538
- } catch (error) {
539
- if (!(error instanceof GitError)) throw error
540
- await git(['remote', 'set-head', 'origin', '--auto'], { cwd: dir, timeoutMs, env })
541
- return await read()
542
- }
543
307
  }
544
308
  }