@modesto_ma/dsh-guanxiang 0.2.0-beta.2 → 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,164 +40,58 @@ export interface LeasedApp extends ReadyApp {
55
40
  readonly dir: string
56
41
  }
57
42
 
58
- /** A clone opened for writing objects and pushing; 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
- /** Environment for network commands of this address, such as an access token. */
65
- readonly env: Readonly<Record<string, string>>
66
- }
67
-
68
43
  /** Held snapshots of one dispatch; release exactly once. */
69
44
  export interface RepoLease {
70
45
  readonly apps: ReadonlyMap<string, LeasedApp>
71
46
  release(): void
72
47
  }
73
48
 
74
- interface Snapshot {
75
- readonly commit: string
76
- readonly dir: string
77
- /** Leases holding this snapshot. */
78
- users: number
79
- /** Settles once `git worktree add` finished; rejects when it failed. */
80
- readonly created: Promise<void>
81
- }
82
-
83
49
  interface RepoEntry {
84
50
  readonly spec: RepoSpec
85
- /** Fetch-only clone. */
86
- 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
87
55
  commit?: string
88
56
  commitTime?: string
89
57
  app?: AppManifest
90
58
  error?: string
91
59
  syncedAt?: number
92
60
  syncing?: Promise<void>
93
- /** Snapshots by commit. */
94
- readonly snapshots: Map<string, Snapshot>
95
61
  activeRuns: number
96
62
  /** Removed from the configuration; kept only until in-flight work settles. */
97
63
  retired: boolean
98
64
  }
99
65
 
100
- /**
101
- * Checkout directory name for a git address: its last path segment without
102
- * `.git`, restricted to `[A-Za-z0-9._-]`.
103
- * @param url - git address.
104
- * @returns directory name.
105
- */
106
- export function repoDirName(url: string): string {
107
- const path = url.replace(/^[^@/]+@[^:/]+:/, '').replace(/[?#].*$/, '').replace(/\/+$/, '')
108
- const last = path.split(/[/\\:]/).pop() ?? ''
109
- const name = last.replace(/\.git$/, '').replace(/[^A-Za-z0-9._-]/g, '-').replace(/^[.-]+/, '')
110
- return name === '' ? 'repo' : name
111
- }
112
-
113
- /**
114
- * Assign each address a distinct directory; colliding names get a short hash
115
- * of their address appended.
116
- * @param reposDir - parent directory (may start with `~`).
117
- * @param urls - configured addresses.
118
- * @returns absolute directory per address.
119
- */
120
- export function assignDirs(reposDir: string, urls: readonly string[]): Map<string, string> {
121
- const root = expandHome(reposDir)
122
- const counts = new Map<string, number>()
123
- for (const url of urls) counts.set(repoDirName(url), (counts.get(repoDirName(url)) ?? 0) + 1)
124
- const dirs = new Map<string, string>()
125
- for (const url of urls) {
126
- const name = repoDirName(url)
127
- const unique = (counts.get(name) ?? 0) > 1
128
- ? `${name}-${createHash('sha1').update(url).digest('hex').slice(0, 8)}`
129
- : name
130
- dirs.set(url, join(root, unique))
131
- }
132
- return dirs
133
- }
134
-
135
- /**
136
- * Directory holding the commit snapshots of one clone.
137
- * @param dir - clone directory `<reposDir>/<name>`.
138
- * @returns `<reposDir>/.snapshots/<name>`.
139
- */
140
- export function snapshotRoot(dir: string): string {
141
- return join(dirname(dir), '.snapshots', basename(dir))
142
- }
143
-
144
- /**
145
- * Run a task after every task queued earlier under the same key.
146
- * @param locks - tail of each key's queue.
147
- * @param key - queue key.
148
- * @param task - work to run.
149
- * @returns the task's result.
150
- */
151
- function serialize<T>(locks: Map<string, Promise<void>>, key: string, task: () => Promise<T>): Promise<T> {
152
- const run = (locks.get(key) ?? Promise.resolve()).then(task)
153
- // The queue only orders work; callers observe the failure through `run`.
154
- const tail = run.then(() => undefined, () => undefined)
155
- locks.set(key, tail)
156
- void tail.then(() => { if (locks.get(key) === tail) locks.delete(key) })
157
- return run
158
- }
159
-
160
- function messageOf(error: unknown): string {
161
- if (error instanceof GitError) return error.output === '' ? error.message : error.output
162
- return error instanceof Error ? error.message : String(error)
163
- }
164
-
165
- async function pathKind(path: string): Promise<'missing' | 'directory' | 'other'> {
166
- try {
167
- return (await stat(path)).isDirectory() ? 'directory' : 'other'
168
- } catch (error) {
169
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing'
170
- throw error
171
- }
172
- }
173
-
174
- /** Clones, updates, and indexes the configured application repositories. */
66
+ /** Mirrors, updates, and indexes the configured application repositories. */
175
67
  export class RepoRegistry {
176
68
  private entries: RepoEntry[] = []
177
- /** Orders clone and fetch work per clone directory. */
178
- private readonly syncLocks = new Map<string, Promise<void>>()
179
- /** Orders `git worktree` work per clone directory. */
180
- private readonly worktreeLocks = new Map<string, Promise<void>>()
181
- /** Snapshot roots already cleared of leftovers from an earlier run. */
182
- private readonly clearedRoots = new Set<string>()
183
69
  private readonly now: () => number
184
70
 
185
- /** @param deps - git runner, timeout, and change notification. */
71
+ /** @param deps - service lookup and change notification. */
186
72
  constructor(private readonly deps: RepoRegistryDeps) {
187
73
  this.now = deps.now ?? Date.now
188
74
  }
189
75
 
190
76
  /**
191
77
  * Replace the configured repositories. An address keeps its status when its
192
- * branch and directory are unchanged; clones of removed addresses stay on
193
- * disk, and their snapshots are deleted once no dispatch holds them.
194
- * @param reposDir - parent directory of the clones.
78
+ * branch is unchanged.
195
79
  * @param repos - configured repositories.
196
- * @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.
197
81
  */
198
- configure(reposDir: string, repos: readonly RepoSpec[]): string[] {
199
- const dirs = assignDirs(reposDir, repos.map(repo => repo.url))
82
+ configure(repos: readonly RepoSpec[]): string[] {
200
83
  const previous = new Map(this.entries.map(entry => [entry.spec.url, entry]))
201
84
  const changed: string[] = []
202
85
  this.entries = repos.map((spec) => {
203
- const dir = dirs.get(spec.url) as string
204
86
  const kept = previous.get(spec.url)
205
- if (kept !== undefined && kept.dir === dir && kept.spec.branch === spec.branch) {
87
+ if (kept !== undefined && kept.spec.branch === spec.branch) {
206
88
  previous.delete(spec.url)
207
89
  return kept
208
90
  }
209
91
  changed.push(spec.url)
210
- return { spec: { ...spec }, dir, snapshots: new Map(), activeRuns: 0, retired: false }
92
+ return { spec: { ...spec }, activeRuns: 0, retired: false }
211
93
  })
212
- for (const entry of previous.values()) {
213
- entry.retired = true
214
- this.pruneSnapshots(entry)
215
- }
94
+ for (const entry of previous.values()) entry.retired = true
216
95
  this.deps.onChange()
217
96
  return changed
218
97
  }
@@ -236,16 +115,14 @@ export class RepoRegistry {
236
115
  }
237
116
 
238
117
  /**
239
- * Sync the configured repositories with the given addresses again after any
240
- * sync already running for them, so the new sync sees settings changed since
241
- * that sync started (an access token, for example). An idle repository
242
- * starts syncing before this returns, so its status reads `syncing` at once.
243
- * @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.
244
122
  * @returns when those syncs settled.
245
123
  */
246
- async resyncUrls(urls: readonly string[]): Promise<void> {
247
- const wanted = new Set(urls)
248
- 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
249
126
  ? this.syncEntry(entry)
250
127
  // The running sync's outcome is superseded by the one started after it.
251
128
  : entry.syncing.then(() => undefined, () => undefined).then(() => this.syncEntry(entry))))
@@ -286,7 +163,7 @@ export class RepoRegistry {
286
163
  return {
287
164
  url: entry.spec.url,
288
165
  ...entry.spec.branch === undefined ? {} : { branch: entry.spec.branch },
289
- dir: entry.dir,
166
+ ...entry.dir === undefined ? {} : { dir: entry.dir },
290
167
  phase,
291
168
  ...entry.commit === undefined ? {} : { commit: entry.commit },
292
169
  ...entry.commitTime === undefined ? {} : { commitTime: entry.commitTime },
@@ -317,65 +194,57 @@ export class RepoRegistry {
317
194
 
318
195
  /**
319
196
  * Hold a snapshot of each requested application's latest synced commit for
320
- * one dispatch, creating the snapshots that do not exist yet. Every id must
321
- * 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.
322
199
  * @param ids - requested application ids (duplicates allowed).
323
200
  * @returns the lease.
324
- * @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.
325
202
  */
326
203
  async acquire(ids: readonly string[]): Promise<RepoLease> {
327
204
  const unique = [...new Set(ids)]
328
205
  const ready = new Map(this.readyEntries().map(item => [item.ready.app.id, item]))
329
206
  const missing = unique.filter(id => !ready.has(id))
330
207
  if (missing.length > 0) throw new Error(this.unknownMessage(missing))
208
+ const service = requireRepos(this.deps.repos())
331
209
  const held = unique.map((id) => {
332
210
  const { entry, ready: app } = ready.get(id) as { entry: RepoEntry; ready: ReadyApp }
333
- const snapshot = this.snapshotFor(entry, app.commit)
334
- snapshot.users += 1
335
211
  entry.activeRuns += 1
336
- return { id, entry, app, snapshot }
212
+ return { id, entry, app, snapshot: service.acquireSnapshot(app.url, app.commit) }
337
213
  })
338
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] : [])
339
217
  let released = false
340
218
  const release = (): void => {
341
219
  if (released) return
342
220
  released = true
343
- for (const { entry, snapshot } of held) {
344
- snapshot.users -= 1
345
- entry.activeRuns -= 1
346
- }
347
- 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
348
223
  this.deps.onChange()
349
224
  }
350
- const created = await Promise.allSettled(held.map(item => item.snapshot.created))
351
- for (const [index, outcome] of created.entries()) {
225
+ for (const [index, outcome] of outcomes.entries()) {
352
226
  if (outcome.status === 'fulfilled') continue
353
227
  release()
354
228
  throw new Error(`应用「${held[index]?.id}」的代码快照创建失败:${messageOf(outcome.reason)}`)
355
229
  }
356
230
  return {
357
- 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 }])),
358
232
  release,
359
233
  }
360
234
  }
361
235
 
362
236
  /**
363
- * Run a task on the clone of a configured address after every sync queued
364
- * 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.
365
239
  * @param url - configured repository address.
366
- * @param task - work on the clone.
367
- * @returns the task's result.
368
- * @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.
369
242
  */
370
- async withClone<T>(url: string, task: (clone: CloneAccess) => Promise<T>): Promise<T> {
243
+ branchOf(url: string): string {
371
244
  const entry = this.entries.find(item => item.spec.url === url)
372
245
  if (entry === undefined) throw new Error(`仓库 ${url} 已不在配置中`)
373
- return await serialize(this.syncLocks, entry.dir, async () => {
374
- if (entry.retired) throw new Error(`仓库 ${url} 已不在配置中`)
375
- const env = await this.deps.auth?.(url) ?? {}
376
- const branch = entry.spec.branch ?? (await this.remoteHead(entry.dir, env)).replace(/^refs\/remotes\/origin\//, '')
377
- return await task({ dir: entry.dir, branch, env })
378
- })
246
+ if (entry.branch === undefined) throw new Error(`仓库 ${url} 还没有同步成功`)
247
+ return entry.branch
379
248
  }
380
249
 
381
250
  private unknownMessage(ids: readonly string[]): string {
@@ -399,7 +268,7 @@ export class RepoRegistry {
399
268
 
400
269
  private syncEntry(entry: RepoEntry): Promise<void> {
401
270
  if (entry.syncing !== undefined) return entry.syncing
402
- const run = serialize(this.syncLocks, entry.dir, () => this.runSync(entry)).finally(() => {
271
+ const run = this.runSync(entry).finally(() => {
403
272
  entry.syncing = undefined
404
273
  this.deps.onChange()
405
274
  })
@@ -410,9 +279,11 @@ export class RepoRegistry {
410
279
 
411
280
  private async runSync(entry: RepoEntry): Promise<void> {
412
281
  if (entry.retired) return
413
- let head: { commit: string; commitTime: string }
282
+ let service: DshRepoService
283
+ let head: { dir: string; branch: string; commit: string; commitTime: string }
414
284
  try {
415
- 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 })
416
287
  } catch (error) {
417
288
  entry.syncedAt = this.now()
418
289
  entry.error = entry.commit !== undefined && entry.app !== undefined
@@ -420,8 +291,10 @@ export class RepoRegistry {
420
291
  : messageOf(error)
421
292
  return
422
293
  }
423
- const manifest = await checkAppManifest(this.commitReader(entry.dir, head.commit))
294
+ const manifest = await checkAppManifest(path => service.readMirrorFile(entry.spec.url, head.commit, path))
424
295
  entry.syncedAt = this.now()
296
+ entry.dir = head.dir
297
+ entry.branch = head.branch
425
298
  entry.commit = head.commit
426
299
  entry.commitTime = head.commitTime
427
300
  if (manifest.ok) {
@@ -431,116 +304,5 @@ export class RepoRegistry {
431
304
  entry.app = undefined
432
305
  entry.error = manifest.error
433
306
  }
434
- this.pruneSnapshots(entry)
435
- }
436
-
437
- /**
438
- * Clone or fetch, and resolve the commit the configured branch points to.
439
- * The first visit of a snapshot root in this process deletes the snapshots
440
- * an earlier run left behind.
441
- */
442
- private async update(entry: RepoEntry): Promise<{ commit: string; commitTime: string }> {
443
- const { git, gitTimeoutMs: timeoutMs } = this.deps
444
- const { dir, spec } = entry
445
- const root = snapshotRoot(dir)
446
- const firstVisit = !this.clearedRoots.has(root)
447
- if (firstVisit) {
448
- this.clearedRoots.add(root)
449
- await rm(root, { recursive: true, force: true })
450
- }
451
- const env = await this.deps.auth?.(spec.url) ?? {}
452
- const kind = await pathKind(dir)
453
- if (kind === 'missing') {
454
- await mkdir(dirname(dir), { recursive: true })
455
- const partial = `${dir}.partial-${randomBytes(4).toString('hex')}`
456
- try {
457
- await git(['clone', '--quiet', '--no-checkout', ...spec.branch === undefined ? [] : ['--branch', spec.branch], '--', spec.url, partial], { timeoutMs, env })
458
- await rename(partial, dir)
459
- } catch (error) {
460
- await rm(partial, { recursive: true, force: true })
461
- throw error
462
- }
463
- } else {
464
- if (kind !== 'directory') throw new Error(`${dir} 已存在且不是目录`)
465
- if (await pathKind(join(dir, '.git')) === 'missing') {
466
- const empty = (await readdir(dir)).length === 0
467
- if (!empty) throw new Error(`${dir} 已存在且不是 git 仓库,请移走后重新拉取`)
468
- await rm(dir, { recursive: true })
469
- return await this.update(entry)
470
- }
471
- const origin = (await git(['remote', 'get-url', 'origin'], { cwd: dir, timeoutMs })).trim()
472
- if (origin !== spec.url) throw new Error(`${dir} 已被另一个仓库占用(origin 为 ${origin})`)
473
- await git(['fetch', '--quiet', '--prune', 'origin'], { cwd: dir, timeoutMs, env })
474
- if (firstVisit) await git(['worktree', 'prune'], { cwd: dir, timeoutMs })
475
- }
476
- const target = spec.branch === undefined ? await this.remoteHead(dir, env) : `refs/remotes/origin/${spec.branch}`
477
- const [commit = '', commitTime = ''] = (await git(['log', '-1', '--format=%H%n%cI', target, '--'], { cwd: dir, timeoutMs })).trim().split('\n')
478
- return { commit, commitTime }
479
- }
480
-
481
- /**
482
- * Reader for the files of one commit, from the clone's object database.
483
- * Only regular files are read; a symbolic link is reported as unreadable.
484
- */
485
- private commitReader(dir: string, commit: string): RepoFileReader {
486
- const { git, gitTimeoutMs: timeoutMs } = this.deps
487
- return async (path) => {
488
- const listing = await git(['ls-tree', '-z', commit, '--', path], { cwd: dir, timeoutMs })
489
- if (listing === '') return undefined
490
- const [mode, type, object] = listing.slice(0, listing.indexOf('\t')).split(' ')
491
- if (type !== 'blob' || mode === '120000' || object === undefined) throw new Error(`${path} 不是普通文件`)
492
- return await git(['cat-file', 'blob', object], { cwd: dir, timeoutMs })
493
- }
494
- }
495
-
496
- /** The snapshot of `commit`, created in the background on first use. */
497
- private snapshotFor(entry: RepoEntry, commit: string): Snapshot {
498
- const existing = entry.snapshots.get(commit)
499
- if (existing !== undefined) return existing
500
- const dir = join(snapshotRoot(entry.dir), `${commit.slice(0, 12)}-${randomBytes(3).toString('hex')}`)
501
- const created = serialize(this.worktreeLocks, entry.dir, async () => {
502
- await mkdir(dirname(dir), { recursive: true })
503
- await this.deps.git(['worktree', 'add', '--detach', dir, commit], { cwd: entry.dir, timeoutMs: this.deps.gitTimeoutMs })
504
- })
505
- const snapshot: Snapshot = { commit, dir, users: 0, created }
506
- entry.snapshots.set(commit, snapshot)
507
- created.catch(() => {
508
- // The acquiring dispatch reports the failure; the next one retries.
509
- if (entry.snapshots.get(commit) === snapshot) entry.snapshots.delete(commit)
510
- void this.removeSnapshot(entry.dir, dir)
511
- })
512
- return snapshot
513
- }
514
-
515
- /** Delete the snapshots no lease holds, except the latest commit's of a configured repository. */
516
- private pruneSnapshots(entry: RepoEntry): void {
517
- for (const [commit, snapshot] of entry.snapshots) {
518
- if (snapshot.users > 0 || (commit === entry.commit && !entry.retired)) continue
519
- entry.snapshots.delete(commit)
520
- void snapshot.created.then(() => this.removeSnapshot(entry.dir, snapshot.dir), () => undefined)
521
- }
522
- }
523
-
524
- private async removeSnapshot(clone: string, dir: string): Promise<void> {
525
- try {
526
- await serialize(this.worktreeLocks, clone, async () => {
527
- await rm(dir, { recursive: true, force: true })
528
- await this.deps.git(['worktree', 'prune'], { cwd: clone, timeoutMs: this.deps.gitTimeoutMs })
529
- })
530
- } catch {
531
- // Best effort: the first sync after the next start deletes leftover snapshots.
532
- }
533
- }
534
-
535
- private async remoteHead(dir: string, env: Readonly<Record<string, string>>): Promise<string> {
536
- const { git, gitTimeoutMs: timeoutMs } = this.deps
537
- const read = async (): Promise<string> => (await git(['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd: dir, timeoutMs })).trim()
538
- try {
539
- return await read()
540
- } catch (error) {
541
- if (!(error instanceof GitError)) throw error
542
- await git(['remote', 'set-head', 'origin', '--auto'], { cwd: dir, timeoutMs, env })
543
- return await read()
544
- }
545
307
  }
546
308
  }