@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.
- package/README.md +19 -29
- package/lib/client.js +172 -150
- package/lib/client.js.map +1 -1
- package/lib/index.js +425 -794
- package/lib/index.js.map +1 -1
- package/package.json +3 -2
- package/src/client/api.ts +43 -6
- package/src/client/page.tsx +63 -98
- package/src/config.ts +26 -43
- package/src/index.ts +9 -2
- package/src/repos/lookup.ts +92 -0
- package/src/repos/registry.ts +57 -293
- package/src/service.ts +165 -60
- package/src/settings/contract.ts +50 -26
- package/src/settings/routes.ts +19 -1
- package/src/skill-update/change.ts +81 -0
- package/src/skill-update/tool.ts +20 -30
- package/src/repos/credentials.ts +0 -104
- package/src/repos/git.ts +0 -74
- package/src/skill-update/git.ts +0 -84
- package/src/skill-update/repos.ts +0 -51
|
@@ -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
|
+
}
|
package/src/repos/registry.ts
CHANGED
|
@@ -1,42 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* 应用注册表:经 dsh-repo 的本地镜像拉取设置中的 git 地址,从远端分支最新提交
|
|
3
|
+
* 读取各仓库的应用描述,对外提供「已就绪的应用」,并为每次派发持有该提交的
|
|
4
|
+
* 只读代码快照。
|
|
5
5
|
*
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
/**
|
|
84
|
-
|
|
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 -
|
|
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
|
|
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
|
|
80
|
+
* @returns addresses that are new or changed and therefore need a sync.
|
|
195
81
|
*/
|
|
196
|
-
configure(
|
|
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.
|
|
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 },
|
|
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
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
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
|
|
245
|
-
|
|
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
|
|
319
|
-
*
|
|
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
|
|
342
|
-
|
|
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
|
|
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,
|
|
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
|
-
*
|
|
362
|
-
*
|
|
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
|
-
* @
|
|
365
|
-
* @
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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 =
|
|
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
|
|
282
|
+
let service: DshRepoService
|
|
283
|
+
let head: { dir: string; branch: string; commit: string; commitTime: string }
|
|
412
284
|
try {
|
|
413
|
-
|
|
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(
|
|
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
|
}
|