@goodandready/dsh-kanban 0.2.19 → 0.2.21
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/lib/board-tool.js +18 -48
- package/lib/client.js +85 -176
- package/lib/commands.js +21 -38
- package/lib/config.js +30 -55
- package/lib/cron.js +13 -13
- package/lib/events.js +17 -5
- package/lib/filters.js +58 -82
- package/lib/git-provider.js +23 -23
- package/lib/gitea.js +59 -110
- package/lib/github.js +39 -37
- package/lib/import.js +46 -67
- package/lib/index.js +131 -140
- package/lib/intake.js +18 -26
- package/lib/launcher.js +107 -170
- package/lib/lifecycle.js +11 -17
- package/lib/markdown.js +16 -23
- package/lib/metrics.js +23 -25
- package/lib/order.js +25 -28
- package/lib/outbound.js +28 -28
- package/lib/plan.js +23 -45
- package/lib/power.js +2 -2
- package/lib/progress-dump.js +17 -17
- package/lib/routes.js +92 -145
- package/lib/store.js +68 -77
- package/lib/sync.js +56 -98
- package/lib/time.js +8 -17
- package/lib/transitions.js +44 -61
- package/lib/webhook.js +16 -23
- package/lib/workflow-bridge.js +72 -68
- package/lib/worktree.js +103 -60
- package/package.json +1 -1
package/lib/worktree.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Managed worktrees for task agent sessions (Issue #193).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Isolating agent sessions in git worktrees protects the main repository
|
|
4
|
+
// from concurrent modifications, prevents branch overwrite conflicts,
|
|
5
|
+
// and scopes working directory per task.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
// 1.
|
|
9
|
-
// 2.
|
|
10
|
-
// 3.
|
|
11
|
-
// 4.
|
|
12
|
-
// 5.
|
|
7
|
+
// Lifecycle:
|
|
8
|
+
// 1. On transition to In Progress: create worktree task/<id>-<slug>
|
|
9
|
+
// 2. Register worktree in DSH workspaceRegistry as active workspace
|
|
10
|
+
// 3. Launch agent session with cwd = worktreePath
|
|
11
|
+
// 4. In Cleanup column: verify uncommitted files (dirtyFiles)
|
|
12
|
+
// 5. Upon confirmation: remove worktree and deregister workspace.
|
|
13
13
|
|
|
14
14
|
import { execFile } from 'node:child_process'
|
|
15
15
|
import { promisify } from 'node:util'
|
|
@@ -20,8 +20,8 @@ import { isAbsolute, join, resolve, sep } from 'node:path'
|
|
|
20
20
|
const execFileAsync = promisify(execFile)
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* Standard git command runner.
|
|
24
|
+
* Accepts argument array to eliminate shell injection vulnerabilities.
|
|
25
25
|
*/
|
|
26
26
|
export async function defaultGitRunner(args, cwd) {
|
|
27
27
|
try {
|
|
@@ -41,7 +41,7 @@ export async function defaultGitRunner(args, cwd) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
*
|
|
44
|
+
* Convert string into safe branch/directory slug.
|
|
45
45
|
*/
|
|
46
46
|
export function slugify(text) {
|
|
47
47
|
if (typeof text !== 'string') return 'task'
|
|
@@ -49,7 +49,7 @@ export function slugify(text) {
|
|
|
49
49
|
.toLowerCase()
|
|
50
50
|
.normalize('NFD')
|
|
51
51
|
.replace(/[\u0300-\u036f]/g, '')
|
|
52
|
-
.replace(/[
|
|
52
|
+
.replace(/[^\p{Letter}0-9_-]+/gu, '-')
|
|
53
53
|
.replace(/-+/g, '-')
|
|
54
54
|
.replace(/^-|-$/g, '')
|
|
55
55
|
.slice(0, 40)
|
|
@@ -57,7 +57,7 @@ export function slugify(text) {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
*
|
|
60
|
+
* Safe repository key for worktree root directory.
|
|
61
61
|
*/
|
|
62
62
|
export function safeRepoKey(task) {
|
|
63
63
|
const owner = typeof task?.owner === 'string' ? task.owner.trim() : ''
|
|
@@ -68,7 +68,7 @@ export function safeRepoKey(task) {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
*
|
|
71
|
+
* Resolve root directory for all managed worktrees.
|
|
72
72
|
*/
|
|
73
73
|
export function resolveWorktreeRoot(config, env = process.env, cwdOf = () => process.cwd()) {
|
|
74
74
|
const custom = typeof config?.worktreeRoot === 'string' ? config.worktreeRoot.trim() : ''
|
|
@@ -85,8 +85,24 @@ export function resolveWorktreeRoot(config, env = process.env, cwdOf = () => pro
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
*
|
|
88
|
+
* Resolve canonical worktree path for task with path traversal protection.
|
|
89
89
|
*/
|
|
90
|
+
export function isSafeBranchName(name) {
|
|
91
|
+
if (typeof name !== 'string') return false
|
|
92
|
+
const trimmed = name.trim()
|
|
93
|
+
if (trimmed === '' || trimmed.startsWith('-') || trimmed.startsWith('/') || trimmed.endsWith('/')) return false
|
|
94
|
+
if (trimmed.includes('..') || trimmed.includes('~') || trimmed.includes('^') || trimmed.includes(':') || trimmed.includes('?') || trimmed.includes('*') || trimmed.includes('[') || trimmed.includes('\\') || trimmed.includes('@{') || trimmed.endsWith('.lock')) return false
|
|
95
|
+
return /^[A-Za-z0-9_./-]+$/.test(trimmed)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isPathFenced(targetPath, rootPath) {
|
|
99
|
+
if (!targetPath || !rootPath) return false
|
|
100
|
+
const resolvedTarget = resolve(targetPath)
|
|
101
|
+
const resolvedRoot = resolve(rootPath)
|
|
102
|
+
const fence = resolvedRoot.endsWith(sep) || resolvedRoot.endsWith('/') ? resolvedRoot : resolvedRoot + sep
|
|
103
|
+
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(fence)
|
|
104
|
+
}
|
|
105
|
+
|
|
90
106
|
export function resolveTaskWorktreePath({ task, config, env, cwdOf }) {
|
|
91
107
|
const root = resolveWorktreeRoot(config, env, cwdOf)
|
|
92
108
|
const repoKey = safeRepoKey(task)
|
|
@@ -95,13 +111,13 @@ export function resolveTaskWorktreePath({ task, config, env, cwdOf }) {
|
|
|
95
111
|
|
|
96
112
|
const fenceSep = root.endsWith(sep) || root.endsWith('/') ? root : root + sep
|
|
97
113
|
if (target !== root && !target.startsWith(fenceSep) && !target.startsWith(root + '/')) {
|
|
98
|
-
throw new Error('
|
|
114
|
+
throw new Error('worktree path escapes worktree root')
|
|
99
115
|
}
|
|
100
116
|
return target
|
|
101
117
|
}
|
|
102
118
|
|
|
103
119
|
/**
|
|
104
|
-
*
|
|
120
|
+
* Generate task branch name.
|
|
105
121
|
*/
|
|
106
122
|
export function resolveTaskBranch(task) {
|
|
107
123
|
const prefix = typeof task?.issueNumber === 'number'
|
|
@@ -112,7 +128,7 @@ export function resolveTaskBranch(task) {
|
|
|
112
128
|
}
|
|
113
129
|
|
|
114
130
|
/**
|
|
115
|
-
*
|
|
131
|
+
* Check if directory is a valid git repository.
|
|
116
132
|
*/
|
|
117
133
|
export async function isGitRepo(repoDir, gitRunner = defaultGitRunner) {
|
|
118
134
|
if (!repoDir) return false
|
|
@@ -125,7 +141,7 @@ export async function isGitRepo(repoDir, gitRunner = defaultGitRunner) {
|
|
|
125
141
|
}
|
|
126
142
|
|
|
127
143
|
/**
|
|
128
|
-
*
|
|
144
|
+
* List existing worktree paths for repository.
|
|
129
145
|
*/
|
|
130
146
|
export async function listWorktrees(repoDir, gitRunner = defaultGitRunner) {
|
|
131
147
|
try {
|
|
@@ -143,7 +159,7 @@ export async function listWorktrees(repoDir, gitRunner = defaultGitRunner) {
|
|
|
143
159
|
}
|
|
144
160
|
|
|
145
161
|
/**
|
|
146
|
-
*
|
|
162
|
+
* Resolve base ref for worktree creation.
|
|
147
163
|
*/
|
|
148
164
|
export async function determineBaseRef(repoDir, gitRunner = defaultGitRunner) {
|
|
149
165
|
for (const candidate of ['origin/main', 'origin/master', 'main', 'master', 'HEAD']) {
|
|
@@ -158,7 +174,7 @@ export async function determineBaseRef(repoDir, gitRunner = defaultGitRunner) {
|
|
|
158
174
|
}
|
|
159
175
|
|
|
160
176
|
/**
|
|
161
|
-
*
|
|
177
|
+
* Create or reuse worktree for task.
|
|
162
178
|
*/
|
|
163
179
|
export async function createTaskWorktree({
|
|
164
180
|
task,
|
|
@@ -180,13 +196,13 @@ export async function createTaskWorktree({
|
|
|
180
196
|
const worktreePath = resolveTaskWorktreePath({ task, config, env, cwdOf })
|
|
181
197
|
const branchName = resolveTaskBranch(task)
|
|
182
198
|
|
|
183
|
-
//
|
|
199
|
+
// Check if worktree is already registered
|
|
184
200
|
const existing = await listWorktrees(repoDir, gitRunner)
|
|
185
201
|
if (existing.includes(resolve(worktreePath))) {
|
|
186
202
|
return { worktreePath, branchName, reused: true }
|
|
187
203
|
}
|
|
188
204
|
|
|
189
|
-
//
|
|
205
|
+
// Check if local branch already exists
|
|
190
206
|
let branchExists = false
|
|
191
207
|
try {
|
|
192
208
|
await gitRunner(['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}`], repoDir)
|
|
@@ -206,7 +222,7 @@ export async function createTaskWorktree({
|
|
|
206
222
|
}
|
|
207
223
|
|
|
208
224
|
/**
|
|
209
|
-
*
|
|
225
|
+
* Check for uncommitted changes in worktree.
|
|
210
226
|
*/
|
|
211
227
|
function isSubmoduleDrift(worktreePath, line) {
|
|
212
228
|
try {
|
|
@@ -226,14 +242,14 @@ function isSubmoduleDrift(worktreePath, line) {
|
|
|
226
242
|
}
|
|
227
243
|
}
|
|
228
244
|
} catch {
|
|
229
|
-
//
|
|
245
|
+
// Ignore file access errors during check
|
|
230
246
|
}
|
|
231
247
|
return false
|
|
232
248
|
}
|
|
233
249
|
|
|
234
250
|
/**
|
|
235
|
-
*
|
|
236
|
-
*
|
|
251
|
+
* Check for uncommitted changes in worktree.
|
|
252
|
+
* Excludes submodule noise and gitlink drift (#213).
|
|
237
253
|
*/
|
|
238
254
|
export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitRunner }) {
|
|
239
255
|
if (!worktreePath || !existsSync(worktreePath)) {
|
|
@@ -253,7 +269,7 @@ export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitR
|
|
|
253
269
|
}
|
|
254
270
|
|
|
255
271
|
/**
|
|
256
|
-
*
|
|
272
|
+
* Safely remove task worktree.
|
|
257
273
|
*/
|
|
258
274
|
export async function removeTaskWorktree({
|
|
259
275
|
repoDir,
|
|
@@ -262,11 +278,16 @@ export async function removeTaskWorktree({
|
|
|
262
278
|
force = false,
|
|
263
279
|
deleteBranch = true,
|
|
264
280
|
gitRunner = defaultGitRunner,
|
|
281
|
+
rootPath,
|
|
265
282
|
}) {
|
|
266
283
|
if (!worktreePath || !existsSync(worktreePath)) {
|
|
267
284
|
return { removed: false, reason: 'not-found' }
|
|
268
285
|
}
|
|
269
286
|
|
|
287
|
+
if (rootPath && !isPathFenced(worktreePath, rootPath)) {
|
|
288
|
+
return { removed: false, error: 'unfenced-worktree-path' }
|
|
289
|
+
}
|
|
290
|
+
|
|
270
291
|
const dirtyCheck = await checkWorktreeDirty({ worktreePath, gitRunner })
|
|
271
292
|
if (dirtyCheck.dirty && !force) {
|
|
272
293
|
return {
|
|
@@ -281,7 +302,7 @@ export async function removeTaskWorktree({
|
|
|
281
302
|
: ['worktree', 'remove', worktreePath]
|
|
282
303
|
|
|
283
304
|
await gitRunner(removeArgs, repoDir)
|
|
284
|
-
try { await gitRunner(['worktree', 'prune'], repoDir) } catch { /* prune
|
|
305
|
+
try { await gitRunner(['worktree', 'prune'], repoDir) } catch { /* prune ignores errors */ }
|
|
285
306
|
|
|
286
307
|
let branchDeleted = false
|
|
287
308
|
if (deleteBranch && branchName && repoDir) {
|
|
@@ -289,7 +310,7 @@ export async function removeTaskWorktree({
|
|
|
289
310
|
await gitRunner(['branch', '-d', branchName], repoDir)
|
|
290
311
|
branchDeleted = true
|
|
291
312
|
} catch {
|
|
292
|
-
//
|
|
313
|
+
// Ignored: branch not deleted if not yet merged
|
|
293
314
|
branchDeleted = false
|
|
294
315
|
}
|
|
295
316
|
}
|
|
@@ -298,7 +319,7 @@ export async function removeTaskWorktree({
|
|
|
298
319
|
}
|
|
299
320
|
|
|
300
321
|
/**
|
|
301
|
-
*
|
|
322
|
+
* Register worktree path in DSH workspaceRegistry.
|
|
302
323
|
*/
|
|
303
324
|
export async function registerDshWorkspace({ workspaceRegistry, worktreePath, title }) {
|
|
304
325
|
if (!workspaceRegistry || typeof workspaceRegistry.create !== 'function') return undefined
|
|
@@ -311,7 +332,7 @@ export async function registerDshWorkspace({ workspaceRegistry, worktreePath, ti
|
|
|
311
332
|
}
|
|
312
333
|
|
|
313
334
|
/**
|
|
314
|
-
*
|
|
335
|
+
* Deregister workspace from DSH workspaceRegistry.
|
|
315
336
|
*/
|
|
316
337
|
export async function deregisterDshWorkspace({ workspaceRegistry, workspaceId }) {
|
|
317
338
|
if (!workspaceRegistry || typeof workspaceRegistry.delete !== 'function' || !workspaceId) return false
|
|
@@ -323,7 +344,7 @@ export async function deregisterDshWorkspace({ workspaceRegistry, workspaceId })
|
|
|
323
344
|
}
|
|
324
345
|
|
|
325
346
|
/**
|
|
326
|
-
*
|
|
347
|
+
* Clean up task worktree with dirty files guard.
|
|
327
348
|
*/
|
|
328
349
|
export async function cleanupTaskWorktree({
|
|
329
350
|
store,
|
|
@@ -333,11 +354,26 @@ export async function cleanupTaskWorktree({
|
|
|
333
354
|
force = false,
|
|
334
355
|
deleteBranch = true,
|
|
335
356
|
gitRunner = defaultGitRunner,
|
|
357
|
+
config,
|
|
358
|
+
env,
|
|
359
|
+
cwdOf,
|
|
336
360
|
}) {
|
|
337
361
|
const current = typeof task === 'object' && task !== null ? task : store?.getTask?.(task)
|
|
338
362
|
if (!current) return { error: 'task-not-found', status: 404 }
|
|
339
363
|
if (!current.worktree) return { noop: true, removed: false }
|
|
340
364
|
|
|
365
|
+
const root = resolveWorktreeRoot(config, env, cwdOf)
|
|
366
|
+
const candidateRoots = [
|
|
367
|
+
root,
|
|
368
|
+
repoDir,
|
|
369
|
+
typeof cwdOf === 'function' ? cwdOf() : process.cwd(),
|
|
370
|
+
process.cwd(),
|
|
371
|
+
].filter(Boolean)
|
|
372
|
+
const isFenced = candidateRoots.some((c) => isPathFenced(current.worktree, c))
|
|
373
|
+
if (!isFenced) {
|
|
374
|
+
return { error: 'unfenced-worktree-path', status: 403 }
|
|
375
|
+
}
|
|
376
|
+
|
|
341
377
|
const targetRepoDir = repoDir || current.worktree
|
|
342
378
|
const result = await removeTaskWorktree({
|
|
343
379
|
repoDir: targetRepoDir,
|
|
@@ -354,7 +390,7 @@ export async function cleanupTaskWorktree({
|
|
|
354
390
|
fromCol: current.column,
|
|
355
391
|
toCol: current.column,
|
|
356
392
|
source: 'session',
|
|
357
|
-
detail:
|
|
393
|
+
detail: `cleanup deferred: uncommitted files detected: ${result.files.slice(0, 3).join(', ')}`,
|
|
358
394
|
})
|
|
359
395
|
return { error: 'worktree-dirty', files: result.files }
|
|
360
396
|
}
|
|
@@ -369,14 +405,14 @@ export async function cleanupTaskWorktree({
|
|
|
369
405
|
fromCol: current.column,
|
|
370
406
|
toCol: current.column,
|
|
371
407
|
source: 'session',
|
|
372
|
-
detail: '
|
|
408
|
+
detail: 'cleanup: worktree removed' + (result.branchDeleted ? ', branch deleted' : ''),
|
|
373
409
|
})
|
|
374
410
|
|
|
375
411
|
return { success: true, removed: true, branchDeleted: result.branchDeleted }
|
|
376
412
|
}
|
|
377
413
|
|
|
378
414
|
/**
|
|
379
|
-
*
|
|
415
|
+
* Generate unified diff of changes in task worktree (#211).
|
|
380
416
|
*
|
|
381
417
|
* @param {{worktreePath: string, baseRef?: string, gitRunner?: Function}} options
|
|
382
418
|
* @returns {Promise<{diff: string, truncated: boolean, filesChanged: number}>}
|
|
@@ -391,7 +427,7 @@ export async function getTaskDiff({ worktreePath, baseRef, gitRunner = defaultGi
|
|
|
391
427
|
base = await determineBaseRef(worktreePath, gitRunner)
|
|
392
428
|
}
|
|
393
429
|
|
|
394
|
-
//
|
|
430
|
+
// Try git diff base...HEAD first if base ref is available
|
|
395
431
|
let diffOut = ''
|
|
396
432
|
if (base) {
|
|
397
433
|
const res = await gitRunner(['diff', `${base}...HEAD`], worktreePath)
|
|
@@ -400,23 +436,23 @@ export async function getTaskDiff({ worktreePath, baseRef, gitRunner = defaultGi
|
|
|
400
436
|
}
|
|
401
437
|
}
|
|
402
438
|
|
|
403
|
-
//
|
|
439
|
+
// Include uncommitted changes (unstaged + staged)
|
|
404
440
|
const unstaged = await gitRunner(['diff', 'HEAD'], worktreePath)
|
|
405
441
|
if (unstaged.exitCode === 0 && unstaged.stdout.trim() !== '') {
|
|
406
442
|
diffOut = diffOut ? `${diffOut}\n\n# Uncommitted Changes:\n${unstaged.stdout}` : unstaged.stdout
|
|
407
443
|
}
|
|
408
444
|
|
|
409
|
-
//
|
|
445
|
+
// Fallback to plain git diff if base...HEAD produced no output
|
|
410
446
|
if (!diffOut.trim()) {
|
|
411
447
|
const fallback = await gitRunner(['diff'], worktreePath)
|
|
412
448
|
if (fallback.exitCode === 0) diffOut = fallback.stdout
|
|
413
449
|
}
|
|
414
450
|
|
|
415
|
-
//
|
|
451
|
+
// Count affected files
|
|
416
452
|
const fileLines = diffOut.split('\n').filter((l) => l.startsWith('diff --git'))
|
|
417
453
|
const filesChanged = fileLines.length
|
|
418
454
|
|
|
419
|
-
//
|
|
455
|
+
// Memory guard: cap diff output at 250 KiB
|
|
420
456
|
const MAX_DIFF_BYTES = 250 * 1024
|
|
421
457
|
let truncated = false
|
|
422
458
|
if (Buffer.byteLength(diffOut, 'utf8') > MAX_DIFF_BYTES) {
|
|
@@ -428,7 +464,7 @@ export async function getTaskDiff({ worktreePath, baseRef, gitRunner = defaultGi
|
|
|
428
464
|
}
|
|
429
465
|
|
|
430
466
|
/**
|
|
431
|
-
*
|
|
467
|
+
* List task branch commits relative to base branch (#211).
|
|
432
468
|
*
|
|
433
469
|
* @param {{worktreePath: string, baseRef?: string, gitRunner?: Function}} options
|
|
434
470
|
* @returns {Promise<Array<{hash: string, subject: string, author: string, date: string}>>}
|
|
@@ -446,7 +482,7 @@ export async function getTaskCommits({ worktreePath, baseRef, gitRunner = defaul
|
|
|
446
482
|
const range = base ? `${base}..HEAD` : '-n 20'
|
|
447
483
|
const logRes = await gitRunner(['log', range, '--pretty=format:%h%x09%an%x09%ad%x09%s', '--date=short'], worktreePath)
|
|
448
484
|
if (logRes.exitCode !== 0 || !logRes.stdout.trim()) {
|
|
449
|
-
//
|
|
485
|
+
// Fallback to last 10 commits
|
|
450
486
|
const fallback = await gitRunner(['log', '-n', '10', '--pretty=format:%h%x09%an%x09%ad%x09%s', '--date=short'], worktreePath)
|
|
451
487
|
if (fallback.exitCode !== 0 || !fallback.stdout.trim()) return []
|
|
452
488
|
return fallback.stdout.trim().split('\n').map(parseCommitLine).filter(Boolean)
|
|
@@ -467,7 +503,7 @@ function parseCommitLine(line) {
|
|
|
467
503
|
}
|
|
468
504
|
|
|
469
505
|
/**
|
|
470
|
-
*
|
|
506
|
+
* Merge worktree branch into repository main branch (--no-ff) upon acceptance (#212).
|
|
471
507
|
*
|
|
472
508
|
* @param {{task: object, targetBranch?: string, gitRunner?: Function, workspaceRegistry?: object}} options
|
|
473
509
|
* @returns {Promise<{success: boolean, merged: boolean, error?: string}>}
|
|
@@ -479,13 +515,17 @@ export async function mergeTaskWorktree({
|
|
|
479
515
|
return { success: false, merged: false, error: 'no-worktree-or-branch' }
|
|
480
516
|
}
|
|
481
517
|
|
|
482
|
-
|
|
518
|
+
if (targetBranch && !isSafeBranchName(targetBranch)) {
|
|
519
|
+
return { success: false, merged: false, error: 'invalid-target-branch' }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// 1. Verify worktree cleanliness
|
|
483
523
|
const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree, gitRunner })
|
|
484
524
|
if (dirtyCheck.dirty) {
|
|
485
525
|
return { success: false, merged: false, error: 'worktree-dirty', files: dirtyCheck.files }
|
|
486
526
|
}
|
|
487
527
|
|
|
488
|
-
// 2.
|
|
528
|
+
// 2. Locate main repository root
|
|
489
529
|
let mainRepoDir = ''
|
|
490
530
|
const revParse = await gitRunner(['rev-parse', '--git-common-dir'], task.worktree)
|
|
491
531
|
if (revParse.exitCode === 0 && revParse.stdout.trim()) {
|
|
@@ -497,32 +537,35 @@ export async function mergeTaskWorktree({
|
|
|
497
537
|
return { success: false, merged: false, error: 'main-repo-not-found' }
|
|
498
538
|
}
|
|
499
539
|
|
|
500
|
-
// 3.
|
|
540
|
+
// 3. Resolve target merge branch
|
|
501
541
|
let target = targetBranch
|
|
542
|
+
if (target && !isSafeBranchName(target)) {
|
|
543
|
+
return { success: false, merged: false, error: 'invalid-target-branch' }
|
|
544
|
+
}
|
|
502
545
|
if (!target) {
|
|
503
546
|
target = await determineBaseRef(mainRepoDir, gitRunner)
|
|
504
547
|
}
|
|
505
|
-
if (!target) target = 'main'
|
|
548
|
+
if (!target || !isSafeBranchName(target)) target = 'main'
|
|
506
549
|
|
|
507
|
-
// 4.
|
|
550
|
+
// 4. Switch or verify target branch in main repository
|
|
508
551
|
const currentBranch = await gitRunner(['branch', '--show-current'], mainRepoDir)
|
|
509
552
|
if (currentBranch.stdout.trim() !== target) {
|
|
510
|
-
const checkoutRes = await gitRunner(['checkout', target], mainRepoDir)
|
|
553
|
+
const checkoutRes = await gitRunner(['checkout', '--', target], mainRepoDir)
|
|
511
554
|
if (checkoutRes.exitCode !== 0) {
|
|
512
555
|
return { success: false, merged: false, error: 'checkout-target-failed', detail: checkoutRes.stderr }
|
|
513
556
|
}
|
|
514
557
|
}
|
|
515
558
|
|
|
516
|
-
// 5.
|
|
559
|
+
// 5. Execute git merge --no-ff
|
|
517
560
|
const mergeMsg = `Merge branch '${task.branch}' for task ${task.id}`
|
|
518
561
|
const mergeRes = await gitRunner(['merge', '--no-ff', task.branch, '-m', mergeMsg], mainRepoDir)
|
|
519
562
|
if (mergeRes.exitCode !== 0) {
|
|
520
|
-
//
|
|
563
|
+
// Roll back merge on conflict
|
|
521
564
|
await gitRunner(['merge', '--abort'], mainRepoDir)
|
|
522
565
|
return { success: false, merged: false, error: 'merge-conflict', detail: mergeRes.stderr || mergeRes.stdout }
|
|
523
566
|
}
|
|
524
567
|
|
|
525
|
-
// 6.
|
|
568
|
+
// 6. Clean up task worktree
|
|
526
569
|
await cleanupTaskWorktree({
|
|
527
570
|
task,
|
|
528
571
|
workspaceRegistry,
|
|
@@ -535,9 +578,9 @@ export async function mergeTaskWorktree({
|
|
|
535
578
|
|
|
536
579
|
|
|
537
580
|
/**
|
|
538
|
-
*
|
|
581
|
+
* Recursive discovery of git repositories within workspace directory (#210).
|
|
539
582
|
*
|
|
540
|
-
* @param {string} rootDir
|
|
583
|
+
* @param {string} rootDir Root directory to scan
|
|
541
584
|
* @param {{maxDepth?: number, maxRepos?: number, gitRunner?: Function}} options
|
|
542
585
|
* @returns {Promise<Array<{relPath: string, fullPath: string, name: string}>>}
|
|
543
586
|
*/
|
|
@@ -561,7 +604,7 @@ export async function findWorkspaceGitRepos(rootDir, options = {}) {
|
|
|
561
604
|
fullPath: resolve(dir),
|
|
562
605
|
name: basename(resolve(dir)),
|
|
563
606
|
})
|
|
564
|
-
//
|
|
607
|
+
// Do not recurse into nested non-root repositories
|
|
565
608
|
if (depth > 0) return
|
|
566
609
|
}
|
|
567
610
|
|
|
@@ -572,7 +615,7 @@ export async function findWorkspaceGitRepos(rootDir, options = {}) {
|
|
|
572
615
|
await scan(join(dir, entry.name), depth + 1)
|
|
573
616
|
}
|
|
574
617
|
} catch {
|
|
575
|
-
//
|
|
618
|
+
// Ignore inaccessible directories
|
|
576
619
|
}
|
|
577
620
|
}
|
|
578
621
|
|
|
@@ -581,7 +624,7 @@ export async function findWorkspaceGitRepos(rootDir, options = {}) {
|
|
|
581
624
|
}
|
|
582
625
|
|
|
583
626
|
/**
|
|
584
|
-
*
|
|
627
|
+
* Multi-repository mirror setup for workspaces with multiple git repositories (#210).
|
|
585
628
|
*
|
|
586
629
|
* @param {{task: object, workspacePath: string, gitRunner?: Function, config?: object}} options
|
|
587
630
|
* @returns {Promise<{mirrors: Array<{repo: string, worktreePath: string, branch: string}>, errors?: Array<string>}>}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.21",
|
|
4
4
|
"description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|