dsh-taskboard 0.6.2 → 0.6.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 +18 -3
- package/lib/client.js +13 -4
- package/lib/host/execution.js +160 -78
- package/lib/host/execution.js.map +1 -1
- package/lib/host/git.js +41 -13
- package/lib/host/git.js.map +1 -1
- package/lib/host/isolation.js +192 -0
- package/lib/host/isolation.js.map +1 -0
- package/lib/host/repos.js +91 -0
- package/lib/host/repos.js.map +1 -0
- package/lib/host/routes.js +205 -60
- package/lib/host/routes.js.map +1 -1
- package/lib/host/tools.js +1 -1
- package/lib/host/tools.js.map +1 -1
- package/lib/index.js +4 -0
- package/lib/index.js.map +1 -1
- package/lib/shared/api.js.map +1 -1
- package/lib/shared/protocol.js +62 -1
- package/lib/shared/protocol.js.map +1 -1
- package/package.json +5 -2
- package/src/client/api.ts +2 -1
- package/src/client/board/TaskDetail.tsx +100 -32
- package/src/client/board/TaskFormModal.tsx +7 -0
- package/src/client/controller.ts +15 -6
- package/src/client/i18n/en.ts +5 -1
- package/src/client/i18n/runtime.ts +71 -5
- package/src/client/i18n/zh.ts +5 -1
- package/src/client/index.ts +5 -2
- package/src/client/styles.ts +7 -0
- package/src/host/execution.ts +199 -115
- package/src/host/git.ts +84 -18
- package/src/host/isolation.ts +268 -0
- package/src/host/repos.ts +146 -0
- package/src/host/routes.ts +211 -73
- package/src/host/tools.ts +2 -2
- package/src/index.ts +6 -1
- package/src/shared/api.ts +29 -5
- package/src/shared/protocol.ts +124 -0
- package/src/shared/version.ts +1 -1
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree isolation orchestration (0.6.3): turns the single-repo worktree
|
|
3
|
+
* flow into a whole-workspace MIRROR when a workspace holds parallel git
|
|
4
|
+
* repositories — the workspace root repo plus its nested ones (plan §3/§4).
|
|
5
|
+
*
|
|
6
|
+
* Responsibilities (git.ts stays the narrow per-repo face):
|
|
7
|
+
* - prepareMirror: discover the repos, prepare one worktree per repo under
|
|
8
|
+
* the task mirror directory (root repo at the mirror root, each nested
|
|
9
|
+
* repo at its relative path), with per-repo reuse (续跑) and a bounded
|
|
10
|
+
* partial-failure policy: the FIRST repo failing degrades the whole run
|
|
11
|
+
* to the original directory (legacy semantics), a later repo failing
|
|
12
|
+
* just drops it from the mirror (framing marks it 禁改 — the isolation
|
|
13
|
+
* boundary never blurs).
|
|
14
|
+
* - removeMirror: children-first removal with an aggregated dirty
|
|
15
|
+
* pre-check (one dirty repo refuses the WHOLE mirror before anything is
|
|
16
|
+
* deleted). Children must go first: a nested worktree under the root
|
|
17
|
+
* worktree reads as untracked noise there, so the root worktree is only
|
|
18
|
+
* removable once they are gone.
|
|
19
|
+
*
|
|
20
|
+
* Every git interaction stays fail-soft at the boundaries the execution
|
|
21
|
+
* service already owns: this module returns outcomes, it never fails a run.
|
|
22
|
+
*
|
|
23
|
+
* @module dsh-taskboard/host/isolation
|
|
24
|
+
*/
|
|
25
|
+
import { MAX_MIRROR_REPOS, isValidRelRepoPath } from '../shared/protocol.ts'
|
|
26
|
+
import type { GitFace } from './git.ts'
|
|
27
|
+
import { statusLineUnder, worktreePathOf } from './git.ts'
|
|
28
|
+
import type { RepoRef, RepoScanner } from './repos.ts'
|
|
29
|
+
|
|
30
|
+
/** One prepared repo worktree inside a task mirror. */
|
|
31
|
+
export interface PreparedMirrorRepo {
|
|
32
|
+
/** Repo path relative to the workspace ('' = the workspace root repo). */
|
|
33
|
+
repo: string
|
|
34
|
+
/** The task branch checked out there. */
|
|
35
|
+
branch: string
|
|
36
|
+
/** Absolute worktree path (the working directory for this repo in the run). */
|
|
37
|
+
worktreePath: string
|
|
38
|
+
/** Baseline for evidence collection: main HEAD (fresh) or worktree HEAD (reuse). */
|
|
39
|
+
baseCommit: string
|
|
40
|
+
/** True when an existing live worktree was kept as-is (续跑). */
|
|
41
|
+
reused?: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The mirror of one run: prepared repos + repos deliberately left out. */
|
|
45
|
+
export interface PreparedMirror {
|
|
46
|
+
/** Absolute path of the task mirror directory (the framing names it). */
|
|
47
|
+
root: string
|
|
48
|
+
/** Prepared worktrees in mirror order (root repo first when present). */
|
|
49
|
+
repos: PreparedMirrorRepo[]
|
|
50
|
+
/** Repos discovered but NOT mirrored (prepare failed); framing marks them 禁改. */
|
|
51
|
+
skipped: Array<{ repo: string; reason: string }>
|
|
52
|
+
/** True when EVERY prepared worktree was kept as-is (续跑 — framing picks the resume wording). */
|
|
53
|
+
allReused: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Outcome of a mirror preparation: either a mirror or a degrade note. */
|
|
57
|
+
export type MirrorPrepareOutcome =
|
|
58
|
+
| { mirror: PreparedMirror }
|
|
59
|
+
| { note: string }
|
|
60
|
+
|
|
61
|
+
/** Whether this mirror is exactly the legacy single-repo shape. */
|
|
62
|
+
export function isLegacySingle(mirror: PreparedMirror): boolean {
|
|
63
|
+
return mirror.repos.length === 1
|
|
64
|
+
&& mirror.repos[0]!.repo === ''
|
|
65
|
+
&& mirror.skipped.length === 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether a repo key may ride into a mirror path (defense in depth under worktreePathOf). */
|
|
69
|
+
function assertRepoKey(repo: string): void {
|
|
70
|
+
if (!isValidRelRepoPath(repo)) {
|
|
71
|
+
throw new Error('Error: invalid_input: illegal repo path ' + JSON.stringify(repo.slice(0, 80)))
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Best-effort filesystem existence probe (fail-soft → false). */
|
|
76
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
77
|
+
try {
|
|
78
|
+
const { stat } = await import('node:fs/promises')
|
|
79
|
+
return await stat(path).then(() => true, () => false)
|
|
80
|
+
} catch {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Join a repo relative path under a base path (forward slashes). */
|
|
86
|
+
function under(base: string, rel: string): string {
|
|
87
|
+
return rel === '' ? base : base + '/' + rel
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Prepare the task mirror across every repo of the workspace.
|
|
92
|
+
*
|
|
93
|
+
* Repo list: the workspace root repo (GitFace.detect decides, whatever its
|
|
94
|
+
* .git shape) leads, nested parallel repos follow in path order. Each repo
|
|
95
|
+
* gets its own worktree on the SAME task branch name; reuse keeps live
|
|
96
|
+
* worktrees as-is per repo (续跑), falling back to a fresh preparation per
|
|
97
|
+
* repo — and a stale blocking directory gets one forced-fresh retry.
|
|
98
|
+
*/
|
|
99
|
+
export async function prepareMirror(
|
|
100
|
+
deps: { git: GitFace; scanner: RepoScanner },
|
|
101
|
+
args: { workspacePath: string; taskId: string; branch: string; reuse: boolean },
|
|
102
|
+
): Promise<MirrorPrepareOutcome> {
|
|
103
|
+
const { git, scanner } = deps
|
|
104
|
+
|
|
105
|
+
// 1. Discover. The root repo is probed through the git face; nested repos
|
|
106
|
+
// come from the bounded scanner.
|
|
107
|
+
const repos: RepoRef[] = []
|
|
108
|
+
let inside = false
|
|
109
|
+
try {
|
|
110
|
+
inside = await git.detect(args.workspacePath)
|
|
111
|
+
} catch { /* fail-soft → treated as "root is not a repo" */ }
|
|
112
|
+
if (inside) repos.push({ relPath: '', absPath: args.workspacePath })
|
|
113
|
+
let nested: RepoRef[] = []
|
|
114
|
+
try {
|
|
115
|
+
nested = await scanner.findNestedRepos(args.workspacePath)
|
|
116
|
+
} catch { nested = [] }
|
|
117
|
+
for (const repo of nested) {
|
|
118
|
+
assertRepoKey(repo.relPath)
|
|
119
|
+
repos.push(repo)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (repos.length === 0) {
|
|
123
|
+
// Distinguish 未装 git from 非 git 仓库 (0.3.1 wording preserved).
|
|
124
|
+
let hasBinary = true
|
|
125
|
+
try {
|
|
126
|
+
hasBinary = await git.binaryAvailable()
|
|
127
|
+
} catch { /* fail-soft → repo-side wording */ }
|
|
128
|
+
return {
|
|
129
|
+
note: hasBinary
|
|
130
|
+
? '当前项目不是 git 仓库,已在原目录执行'
|
|
131
|
+
: 'git 不可用(未安装或不在 PATH),已在原目录执行',
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (repos.length > MAX_MIRROR_REPOS) {
|
|
135
|
+
return { note: '工作区内 git 仓库数超过镜像上限(' + repos.length + ' > ' + MAX_MIRROR_REPOS + '),已在原目录执行' }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 2. Prepare per repo. FIRST repo failing → whole-run degrade (legacy
|
|
139
|
+
// semantics); a later failure drops just that repo from the mirror.
|
|
140
|
+
const mirrorRoot = worktreePathOf(args.workspacePath, args.taskId)
|
|
141
|
+
const prepared: PreparedMirrorRepo[] = []
|
|
142
|
+
const skipped: PreparedMirror['skipped'] = []
|
|
143
|
+
for (let i = 0; i < repos.length; i++) {
|
|
144
|
+
const repo = repos[i]!
|
|
145
|
+
const target = under(mirrorRoot, repo.relPath)
|
|
146
|
+
let info
|
|
147
|
+
try {
|
|
148
|
+
info = await git.prepareWorktree(repo.absPath, target, args.branch, args.reuse ? 'reuse' : 'fresh')
|
|
149
|
+
} catch { /* fail-soft */ }
|
|
150
|
+
if (info === undefined && args.reuse) {
|
|
151
|
+
// A stale non-matching directory can block reuse; force one fresh
|
|
152
|
+
// attempt (fresh mode already drops + prunes the stale worktree).
|
|
153
|
+
try {
|
|
154
|
+
info = await git.prepareWorktree(repo.absPath, target, args.branch, 'fresh')
|
|
155
|
+
} catch { /* fail-soft */ }
|
|
156
|
+
}
|
|
157
|
+
if (info === undefined) {
|
|
158
|
+
if (i === 0) return { note: 'worktree 准备失败(git 报错或目录被占用),已在原目录执行' }
|
|
159
|
+
skipped.push({ repo: repo.relPath, reason: 'worktree 准备失败' })
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
prepared.push({
|
|
163
|
+
repo: repo.relPath,
|
|
164
|
+
branch: info.branch,
|
|
165
|
+
worktreePath: info.path,
|
|
166
|
+
baseCommit: info.baseCommit,
|
|
167
|
+
...(info.reused === true ? { reused: true } : {}),
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
mirror: {
|
|
172
|
+
root: mirrorRoot,
|
|
173
|
+
repos: prepared,
|
|
174
|
+
skipped,
|
|
175
|
+
allReused: prepared.length > 0 && prepared.every(p => p.reused === true),
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Remove a task whole mirror: aggregate the dirty pre-check across EVERY
|
|
182
|
+
* repo worktree first (one dirty repo refuses everything, nothing is
|
|
183
|
+
* deleted), then remove children before the root (see module doc).
|
|
184
|
+
* Unknown-to-git leftovers report as unregistered — the caller fs-removes
|
|
185
|
+
* the mirror root afterwards (scope-verified route flows own that rm).
|
|
186
|
+
* @throws with code dirty-mirror when any repo worktree holds uncommitted changes.
|
|
187
|
+
*/
|
|
188
|
+
export async function removeMirror(
|
|
189
|
+
deps: { git: GitFace; scanner: RepoScanner },
|
|
190
|
+
args: { workspacePath: string; taskId: string },
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
const { git, scanner } = deps
|
|
193
|
+
const mirrorRoot = worktreePathOf(args.workspacePath, args.taskId)
|
|
194
|
+
// Structural teardown must target a FRESH discovery: a stale TTL cache
|
|
195
|
+
// could miss a repo added since the last scan and strand its mirror
|
|
196
|
+
// worktree inside the root (which then reads as unremovable noise). The
|
|
197
|
+
// cache exists for the prepare/merge hot path, not for teardown.
|
|
198
|
+
scanner.clearCache()
|
|
199
|
+
let nested: RepoRef[] = []
|
|
200
|
+
try {
|
|
201
|
+
nested = await scanner.findNestedRepos(args.workspacePath)
|
|
202
|
+
} catch { nested = [] }
|
|
203
|
+
|
|
204
|
+
const targets: Array<{ repo: string; repoRoot: string; path: string }> = []
|
|
205
|
+
for (const repo of nested) {
|
|
206
|
+
assertRepoKey(repo.relPath)
|
|
207
|
+
const path = under(mirrorRoot, repo.relPath)
|
|
208
|
+
if (await pathExists(path)) targets.push({ repo: repo.relPath, repoRoot: repo.absPath, path })
|
|
209
|
+
}
|
|
210
|
+
// The mirror ROOT is always a target (the legacy flow called removeWorktree
|
|
211
|
+
// unconditionally): when the workspace root is a repo this removes the root
|
|
212
|
+
// worktree; when it is not (plain mirror dir, already-gone path) git side
|
|
213
|
+
// reports it unregistered and the caller's fs rm cleans up.
|
|
214
|
+
targets.push({ repo: '', repoRoot: args.workspacePath, path: mirrorRoot });
|
|
215
|
+
|
|
216
|
+
const childRels = targets.filter(t => t.repo !== '').map(t => t.repo)
|
|
217
|
+
const dirty: Array<{ repo: string; lines: string[] }> = []
|
|
218
|
+
for (const target of targets) {
|
|
219
|
+
const raw = await git.dirtyLines(target.path).catch(() => undefined)
|
|
220
|
+
// The root worktree's status lists its nested child worktrees as untracked
|
|
221
|
+
// noise (`?? sub/`) — those trees belong to the children (checked in their
|
|
222
|
+
// own pass below), not to the root repo's uncommitted changes. Without
|
|
223
|
+
// this exemption a fully committed mirror still reads as dirty and every
|
|
224
|
+
// cleanup route refuses forever (0.6.3 review fix).
|
|
225
|
+
const lines = target.repo === '' && raw !== undefined
|
|
226
|
+
? raw.filter(l => !statusLineUnder(l, childRels))
|
|
227
|
+
: raw
|
|
228
|
+
if (lines !== undefined && lines.length > 0) dirty.push({ repo: target.repo, lines })
|
|
229
|
+
}
|
|
230
|
+
if (dirty.length > 0) {
|
|
231
|
+
const detail = dirty
|
|
232
|
+
.map(d => (d.repo === '' ? '根仓库' : d.repo) + ':\n' + d.lines.slice(0, 10).join('\n'))
|
|
233
|
+
.join('\n')
|
|
234
|
+
throw Object.assign(
|
|
235
|
+
new Error('镜像中 ' + dirty.length + ' 个仓库有未提交修改,拒绝删除:\n' + detail),
|
|
236
|
+
{ code: 'dirty-mirror' },
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Removal walks the array FORWARD: children were pushed before the root, so
|
|
241
|
+
// this IS the children-first order. (The original reverse loop removed the
|
|
242
|
+
// root FIRST — its own doc said children-first; the P1 dirty refusal above
|
|
243
|
+
// masked the bug because the loop never actually ran on a real mirror.)
|
|
244
|
+
const failures: string[] = []
|
|
245
|
+
for (const target of targets) {
|
|
246
|
+
try {
|
|
247
|
+
// The root's structural noise (nested child worktrees / gitlink drift)
|
|
248
|
+
// survived the aggregated pre-check above — exempt + force is safe by
|
|
249
|
+
// construction: real dirt never got past it.
|
|
250
|
+
await git.removeWorktree(target.repoRoot, target.path,
|
|
251
|
+
target.repo === '' ? { exempt: childRels, force: true } : undefined)
|
|
252
|
+
} catch (error) {
|
|
253
|
+
failures.push((target.repo === '' ? '根仓库' : target.repo) + ':' + (error instanceof Error ? error.message : String(error)))
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (failures.length > 0) {
|
|
257
|
+
throw new Error('删除镜像失败:\n' + failures.slice(0, 5).join('\n'))
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Absolute path of a repo main checkout inside the workspace (the fallback
|
|
263
|
+
* cwd for diff views after a mirror is gone).
|
|
264
|
+
*/
|
|
265
|
+
export function repoMainPath(workspacePath: string, repo: string): string {
|
|
266
|
+
assertRepoKey(repo)
|
|
267
|
+
return under(workspacePath, repo)
|
|
268
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace repository discovery (0.6.3): locate the PARALLEL nested git
|
|
3
|
+
* repositories inside a workspace so worktree isolation can mirror the
|
|
4
|
+
* whole multi-repo layout under the task's worktree directory (plan §4.1).
|
|
5
|
+
*
|
|
6
|
+
* Scope rules:
|
|
7
|
+
* - Only TRUE independent repositories qualify: a child directory counts
|
|
8
|
+
* when it contains a `.git` DIRECTORY. A `.git` FILE (a linked worktree
|
|
9
|
+
* or a submodule) is skipped — its refs live in some other repository's
|
|
10
|
+
* object store, so treating it as a repo would double-register that one.
|
|
11
|
+
* - Bounded scan (depth ≤ {@link MAX_SCAN_DEPTH}), never descending into a
|
|
12
|
+
* discovered repo, a skip-listed directory (node_modules, build output,
|
|
13
|
+
* the plugin's own .dsh-worktrees), or a dot directory.
|
|
14
|
+
* - FAIL-SOFT: every IO error shrinks the result and never throws — the
|
|
15
|
+
* caller degrades to single-repo (or plain no-git) behavior.
|
|
16
|
+
* - Per-workspace TTL cache: the layout rarely changes mid-run.
|
|
17
|
+
*
|
|
18
|
+
* The workspace ROOT repo is not discovered here — the caller (isolation)
|
|
19
|
+
* composes it in front of this list when `GitFace.detect` says the root is
|
|
20
|
+
* a work tree, whatever `.git` shape it has.
|
|
21
|
+
*
|
|
22
|
+
* @module dsh-taskboard/host/repos
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** How deep below the workspace root nested repos are looked for. */
|
|
26
|
+
export const MAX_SCAN_DEPTH = 3
|
|
27
|
+
|
|
28
|
+
/** Default TTL of the per-workspace discovery cache (aligns routes' git-detect TTL). */
|
|
29
|
+
const CACHE_TTL_MS = 60_000
|
|
30
|
+
|
|
31
|
+
/** Skip-listed directory names at every level of the scan. */
|
|
32
|
+
const SKIP_DIRS: ReadonlySet<string> = new Set([
|
|
33
|
+
'node_modules',
|
|
34
|
+
'.dsh-worktrees',
|
|
35
|
+
'lib',
|
|
36
|
+
'dist',
|
|
37
|
+
'build',
|
|
38
|
+
'out',
|
|
39
|
+
'coverage',
|
|
40
|
+
'.venv',
|
|
41
|
+
'venv',
|
|
42
|
+
'__pycache__',
|
|
43
|
+
'target',
|
|
44
|
+
'.next',
|
|
45
|
+
'.nuxt',
|
|
46
|
+
'.cache',
|
|
47
|
+
'.gradle',
|
|
48
|
+
'Pods',
|
|
49
|
+
])
|
|
50
|
+
|
|
51
|
+
/** One discovered repository: path relative to the workspace + absolute. */
|
|
52
|
+
export interface RepoRef {
|
|
53
|
+
/**
|
|
54
|
+
* Forward-slash path relative to the workspace root. `` (empty) is
|
|
55
|
+
* reserved for the workspace root repo and is NEVER returned here.
|
|
56
|
+
*/
|
|
57
|
+
relPath: string
|
|
58
|
+
/** Absolute working-tree path of the repository. */
|
|
59
|
+
absPath: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Injectable IO face (unit tests script the layout without a filesystem). */
|
|
63
|
+
export interface RepoIo {
|
|
64
|
+
/** Direct child directory names of `dir` ([] on any error). */
|
|
65
|
+
readDir(dir: string): Promise<string[]>
|
|
66
|
+
/** Whether `dir` contains a `.git` DIRECTORY (true independent repo). */
|
|
67
|
+
hasGitDir(dir: string): Promise<boolean>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Real IO over node:fs/promises (dynamic import like the git face). */
|
|
71
|
+
const realRepoIo: RepoIo = {
|
|
72
|
+
async readDir(dir) {
|
|
73
|
+
try {
|
|
74
|
+
const { readdir } = await import('node:fs/promises')
|
|
75
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
76
|
+
return entries.filter(e => e.isDirectory()).map(e => e.name)
|
|
77
|
+
} catch {
|
|
78
|
+
return []
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
async hasGitDir(dir) {
|
|
82
|
+
try {
|
|
83
|
+
const { stat } = await import('node:fs/promises')
|
|
84
|
+
return (await stat(`${dir}/.git`)).isDirectory()
|
|
85
|
+
} catch {
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The scanner: discovery plus its TTL cache. */
|
|
92
|
+
export interface RepoScanner {
|
|
93
|
+
/** Discover the nested parallel repos of a workspace (cache-backed). */
|
|
94
|
+
findNestedRepos(workspacePath: string): Promise<RepoRef[]>
|
|
95
|
+
/** Drop every cached discovery (mirror teardown re-discovers fresh — the TTL cache only serves the prepare/merge hot path). */
|
|
96
|
+
clearCache(): void
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build a scanner over an injectable IO face.
|
|
101
|
+
* @param io - the IO face (real filesystem when omitted).
|
|
102
|
+
* @param ttlMs - cache lifetime; `0` disables caching (tests).
|
|
103
|
+
*/
|
|
104
|
+
export function createRepoScanner(io: RepoIo = realRepoIo, ttlMs: number = CACHE_TTL_MS): RepoScanner {
|
|
105
|
+
const cache = new Map<string, { at: number; repos: RepoRef[] }>()
|
|
106
|
+
|
|
107
|
+
const scanDir = async (dir: string, rel: string, depth: number, out: RepoRef[]): Promise<void> => {
|
|
108
|
+
if (depth > MAX_SCAN_DEPTH) return
|
|
109
|
+
const names = (await io.readDir(dir)).slice().sort()
|
|
110
|
+
for (const name of names) {
|
|
111
|
+
if (name.startsWith('.') || SKIP_DIRS.has(name)) continue
|
|
112
|
+
const childRel = rel.length === 0 ? name : `${rel}/${name}`
|
|
113
|
+
const childAbs = `${dir}/${name}`
|
|
114
|
+
if (await io.hasGitDir(childAbs)) {
|
|
115
|
+
// A discovered repo is a LEAF: repos nested inside it (vendored
|
|
116
|
+
// copies) are not part of the workspace's parallel layout.
|
|
117
|
+
out.push({ relPath: childRel, absPath: childAbs })
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
// One unreadable subtree must not sink the whole walk (fail-soft).
|
|
121
|
+
try {
|
|
122
|
+
await scanDir(childAbs, childRel, depth + 1, out)
|
|
123
|
+
} catch { /* skip the unreadable subtree */ }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
async findNestedRepos(workspacePath) {
|
|
129
|
+
const root = workspacePath.replace(/[\\/]+$/, '')
|
|
130
|
+
const now = Date.now()
|
|
131
|
+
const cached = cache.get(root)
|
|
132
|
+
if (ttlMs > 0 && cached !== undefined && now - cached.at < ttlMs) return cached.repos
|
|
133
|
+
const out: RepoRef[] = []
|
|
134
|
+
try {
|
|
135
|
+
await scanDir(root, '', 1, out)
|
|
136
|
+
} catch {
|
|
137
|
+
/* fail-soft: keep whatever was found before the error */
|
|
138
|
+
}
|
|
139
|
+
if (ttlMs > 0) cache.set(root, { at: now, repos: out })
|
|
140
|
+
return out
|
|
141
|
+
},
|
|
142
|
+
clearCache() {
|
|
143
|
+
cache.clear()
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
}
|