@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.
- package/README.md +25 -35
- package/lib/client.js +173 -151
- package/lib/client.js.map +1 -1
- package/lib/index.js +493 -891
- package/lib/index.js.map +1 -1
- package/package.json +3 -1
- package/src/client/api.ts +43 -6
- package/src/client/page.tsx +64 -99
- package/src/config.ts +28 -45
- package/src/index.ts +11 -4
- package/src/repos/lookup.ts +92 -0
- package/src/repos/registry.ts +57 -295
- package/src/service.ts +166 -59
- 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 +74 -49
- package/src/repos/credentials.ts +0 -104
- package/src/repos/git.ts +0 -74
- package/src/skill-update/git.ts +0 -157
|
@@ -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,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
|
-
/**
|
|
86
|
-
|
|
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 -
|
|
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
|
|
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
|
|
80
|
+
* @returns addresses that are new or changed and therefore need a sync.
|
|
197
81
|
*/
|
|
198
|
-
configure(
|
|
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.
|
|
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 },
|
|
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
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
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
|
|
247
|
-
|
|
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
|
|
321
|
-
*
|
|
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
|
|
344
|
-
|
|
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
|
|
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,
|
|
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
|
-
*
|
|
364
|
-
*
|
|
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
|
-
* @
|
|
367
|
-
* @
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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 =
|
|
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
|
|
282
|
+
let service: DshRepoService
|
|
283
|
+
let head: { dir: string; branch: string; commit: string; commitTime: string }
|
|
414
284
|
try {
|
|
415
|
-
|
|
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(
|
|
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
|
}
|