dsh-taskboard 0.6.1 → 0.6.3
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 +14 -3
- package/lib/client.js +14 -6308
- 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 +203 -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 +14 -1
- 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/zh.ts +5 -1
- 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
package/src/host/git.ts
CHANGED
|
@@ -34,6 +34,30 @@ const HEAVY_TIMEOUT_MS = 15_000
|
|
|
34
34
|
/** Directory under a workspace where task worktrees live. */
|
|
35
35
|
export const WORKTREE_DIR = '.dsh-worktrees'
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The path segment of one `status --porcelain` line. Shape-aware: the RAW
|
|
39
|
+
* line is `XY path` (path at index 3) while the plugin's trimmed evidence
|
|
40
|
+
* lines collapse a leading-space status to `X path` (path at index 2) —
|
|
41
|
+
* slicing a fixed 3 misparses exactly the gitlink/unstaged shapes a
|
|
42
|
+
* multi-repo mirror produces (` M sub-repo`), 0.6.3 review fix.
|
|
43
|
+
*/
|
|
44
|
+
export function statusLinePath(line: string): string {
|
|
45
|
+
if (line.length >= 3 && line[2] === ' ') return line.slice(3)
|
|
46
|
+
if (line.length >= 2 && line[1] === ' ') return line.slice(2)
|
|
47
|
+
return line
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether a `status --porcelain` line targets one of `rels` (a repo-relative
|
|
52
|
+
* path) or anything under it. Porcelain prints untracked directories with a
|
|
53
|
+
* trailing slash (`?? sub/`), so all three shapes match. Empty rels never
|
|
54
|
+
* match (0.6.3 review fix).
|
|
55
|
+
*/
|
|
56
|
+
export function statusLineUnder(line: string, rels: readonly string[]): boolean {
|
|
57
|
+
const p = statusLinePath(line)
|
|
58
|
+
return rels.some(rel => rel.length > 0 && (p === rel || p === rel + '/' || p.startsWith(rel + '/')))
|
|
59
|
+
}
|
|
60
|
+
|
|
37
61
|
/** Evidence caps: commits kept per execution record (newest first). */
|
|
38
62
|
export const MAX_COMMIT_EVIDENCE = 50
|
|
39
63
|
|
|
@@ -118,18 +142,40 @@ export interface GitFace {
|
|
|
118
142
|
* undefined on any failure — callers degrade to the original directory.
|
|
119
143
|
*/
|
|
120
144
|
prepareWorktree(root: string, path: string, branch: string, mode?: 'fresh' | 'reuse'): Promise<WorktreeInfo | undefined>
|
|
121
|
-
/**
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Collect settlement facts (never throws; missing pieces stay unset).
|
|
147
|
+
* `excludeRelPaths` drops `status --porcelain` lines targeting those
|
|
148
|
+
* repo-relative paths (or anything under them) — the mirror's NESTED repo
|
|
149
|
+
* worktrees read as untracked noise in the root worktree's status and are
|
|
150
|
+
* not this repo's uncommitted changes (0.6.3 review fix).
|
|
151
|
+
*/
|
|
152
|
+
collect(worktreePath: string, baseCommit: string, excludeRelPaths?: readonly string[]): Promise<SettlementFacts>
|
|
153
|
+
/**
|
|
154
|
+
* Uncommitted-change lines of a working tree ([] = clean; undefined on git
|
|
155
|
+
* failure). Mirror removal pre-checks EVERY repo worktree before deleting
|
|
156
|
+
* any, so one dirty repo refuses the whole mirror (0.6.3).
|
|
157
|
+
*/
|
|
158
|
+
dirtyLines(cwd: string): Promise<string[] | undefined>
|
|
159
|
+
/**
|
|
160
|
+
* Merge `branch` into the main worktree (`--no-ff`); THROWS with a readable
|
|
161
|
+
* reason. `exemptRelPaths` extends the main-clean exemption beyond
|
|
162
|
+
* `.dsh-worktrees`: uncommitted-shape noise under those repo-relative paths
|
|
163
|
+
* (parallel repos are self-governing — their content AND their gitlink
|
|
164
|
+
* entries don't gate the root repo's merge, 0.6.3 review fix).
|
|
165
|
+
*/
|
|
166
|
+
merge(root: string, branch: string, exemptRelPaths?: readonly string[]): Promise<void>
|
|
125
167
|
/** Whether `branch` is already an ancestor of HEAD (a merge would be a no-op). */
|
|
126
168
|
isAncestor(root: string, branch: string): Promise<boolean>
|
|
127
169
|
/**
|
|
128
170
|
* Remove a worktree. Resolves 'removed' on success, 'unregistered' when git
|
|
129
171
|
* no longer knows the path (an orphaned directory). THROWS when it still
|
|
130
172
|
* has uncommitted changes, or on any other git failure (readable reason).
|
|
173
|
+
* `opts.exempt` drops mirror-structural noise from the dirty check and
|
|
174
|
+
* `opts.force` lets `git worktree remove` accept it — for callers that have
|
|
175
|
+
* ALREADY aggregated the real-dirty check themselves (the mirror removal:
|
|
176
|
+
* noise-exempt clean → force is safe; real dirt never gets here).
|
|
131
177
|
*/
|
|
132
|
-
removeWorktree(root: string, worktreePath: string): Promise<'removed' | 'unregistered'>
|
|
178
|
+
removeWorktree(root: string, worktreePath: string, opts?: { exempt?: readonly string[]; force?: boolean }): Promise<'removed' | 'unregistered'>
|
|
133
179
|
/** Delete a branch; THROWS (e.g. still checked out in a worktree). */
|
|
134
180
|
deleteBranch(root: string, branch: string): Promise<void>
|
|
135
181
|
/**
|
|
@@ -266,7 +312,7 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
|
|
|
266
312
|
return { path, branch, baseCommit }
|
|
267
313
|
}),
|
|
268
314
|
|
|
269
|
-
async collect(worktreePath, baseCommit) {
|
|
315
|
+
async collect(worktreePath, baseCommit, excludeRelPaths) {
|
|
270
316
|
const facts: SettlementFacts = { commits: [], commitsTotal: 0, dirtyFiles: [], dirtyFilesTotal: 0, changedFiles: 0 }
|
|
271
317
|
const range = `${baseCommit}..HEAD`
|
|
272
318
|
|
|
@@ -292,7 +338,14 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
|
|
|
292
338
|
|
|
293
339
|
const status = await quick(['status', '--porcelain'], worktreePath)
|
|
294
340
|
if (status.ok) {
|
|
295
|
-
|
|
341
|
+
// excludeRelPaths drops the mirror's nested repo worktrees — they
|
|
342
|
+
// read as untracked noise / gitlink drift here, not as this repo's
|
|
343
|
+
// uncommitted changes. Excluded on the RAW line (path extraction is
|
|
344
|
+
// shape-aware); the stored evidence keeps its trimmed 0.3.x shape.
|
|
345
|
+
const dirty = status.stdout.split('\n')
|
|
346
|
+
.filter(l => l.trim().length > 0)
|
|
347
|
+
.filter(l => !statusLineUnder(l, excludeRelPaths ?? []))
|
|
348
|
+
.map(l => l.trim())
|
|
296
349
|
facts.dirtyFilesTotal = dirty.length
|
|
297
350
|
facts.dirtyFiles = dirty.slice(0, MAX_DIRTY_EVIDENCE)
|
|
298
351
|
}
|
|
@@ -306,19 +359,22 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
|
|
|
306
359
|
return facts
|
|
307
360
|
},
|
|
308
361
|
|
|
309
|
-
|
|
362
|
+
async dirtyLines(cwd) {
|
|
363
|
+
const status = await quick(['status', '--porcelain'], cwd)
|
|
364
|
+
if (!status.ok) return undefined
|
|
365
|
+
return status.stdout.split('\n').map(l => l.trim()).filter(l => l.length > 0)
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
merge: (root, branch, exemptRelPaths) => withRootLock(root, async () => {
|
|
310
369
|
// Main-clean check. The plugin's own worktree directory
|
|
311
370
|
// (<root>/.dsh-worktrees) shows up as untracked noise and is EXEMPT —
|
|
312
371
|
// otherwise merging would be impossible without gitignoring it first.
|
|
313
372
|
const status = await quick(['status', '--porcelain'], root)
|
|
314
373
|
if (status.ok) {
|
|
315
374
|
const dirtyLines = status.stdout.split('\n')
|
|
375
|
+
.filter(l => l.trim().length > 0)
|
|
376
|
+
.filter(l => !statusLineUnder(l, [WORKTREE_DIR, ...(exemptRelPaths ?? [])]))
|
|
316
377
|
.map(l => l.trim())
|
|
317
|
-
.filter(l => {
|
|
318
|
-
if (l.length === 0) return false
|
|
319
|
-
const path = l.slice(3)
|
|
320
|
-
return path !== WORKTREE_DIR && !path.startsWith(`${WORKTREE_DIR}/`)
|
|
321
|
-
})
|
|
322
378
|
if (dirtyLines.length > 0) {
|
|
323
379
|
// Machine-readable tag: callers classify without parsing zh-CN text.
|
|
324
380
|
throw Object.assign(new Error(`主工作区有 ${dirtyLines.length} 处未提交修改,请先提交或暂存后再合并`), { code: 'dirty-tree' })
|
|
@@ -339,14 +395,24 @@ export function createGitFace(exec: ExecFn = realExec): GitFace {
|
|
|
339
395
|
return r.ok
|
|
340
396
|
},
|
|
341
397
|
|
|
342
|
-
removeWorktree: (root, worktreePath) => withRootLock(root, async (): Promise<'removed' | 'unregistered'> => {
|
|
398
|
+
removeWorktree: (root, worktreePath, opts) => withRootLock(root, async (): Promise<'removed' | 'unregistered'> => {
|
|
343
399
|
const status = await quick(['status', '--porcelain'], worktreePath)
|
|
344
400
|
if (status.ok && status.stdout.trim().length > 0) {
|
|
345
|
-
|
|
346
|
-
//
|
|
347
|
-
|
|
401
|
+
// opts.exempt drops mirror-structural noise (nested child worktrees /
|
|
402
|
+
// gitlink drift) — the caller's aggregated check already refused real
|
|
403
|
+
// dirt before reaching here.
|
|
404
|
+
const lines = status.stdout.split('\n')
|
|
405
|
+
.filter(l => l.trim().length > 0)
|
|
406
|
+
.filter(l => !statusLineUnder(l, opts?.exempt ?? []))
|
|
407
|
+
.map(l => l.trim())
|
|
408
|
+
if (lines.length > 0) {
|
|
409
|
+
// Machine-readable tag: purge flows classify without parsing zh-CN text.
|
|
410
|
+
throw Object.assign(new Error(`worktree 有 ${lines.length} 处未提交修改,拒绝删除:\n${lines.slice(0, 10).join('\n')}`), { code: 'dirty-worktree' })
|
|
411
|
+
}
|
|
348
412
|
}
|
|
349
|
-
const removed = await heavy(
|
|
413
|
+
const removed = await heavy(opts?.force === true
|
|
414
|
+
? ['worktree', 'remove', '--force', worktreePath]
|
|
415
|
+
: ['worktree', 'remove', worktreePath], root)
|
|
350
416
|
if (removed.ok) return 'removed'
|
|
351
417
|
// S3: classify the failure WITHOUT parsing git's (localizable) stderr —
|
|
352
418
|
// a path absent from `worktree list` is an unregistered leftover, not
|
|
@@ -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
|
+
}
|