@goodandready/dsh-kanban 0.2.19 → 0.2.20

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/worktree.js CHANGED
@@ -1,15 +1,15 @@
1
- // Управляемые воркдеревья для сессий агентов под задачи (Issue #193).
1
+ // Managed worktrees for task agent sessions (Issue #193).
2
2
  //
3
- // Изоляция сессии агента в отдельном git worktree защищает основной
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. При переходе в In Progress создаётся воркдерево task/<id>-<slug>
9
- // 2. Воркдерево регистрируется в DSH workspaceRegistry как рабочий каталог
10
- // 3. Сессия агента запускается с cwd = worktreePath
11
- // 4. В колонке Cleanup проверяются незакоммиченные файлы (dirtyFiles)
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
- * Стандартный исполнитель git-команд.
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(/[^a-z0-9а-яё_-]+/gi, '-')
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,7 +85,7 @@ export function resolveWorktreeRoot(config, env = process.env, cwdOf = () => pro
85
85
  }
86
86
 
87
87
  /**
88
- * Вычислить канонический путь к воркдереву задачи с защитой от path traversal.
88
+ * Resolve canonical worktree path for task with path traversal protection.
89
89
  */
90
90
  export function resolveTaskWorktreePath({ task, config, env, cwdOf }) {
91
91
  const root = resolveWorktreeRoot(config, env, cwdOf)
@@ -95,13 +95,13 @@ export function resolveTaskWorktreePath({ task, config, env, cwdOf }) {
95
95
 
96
96
  const fenceSep = root.endsWith(sep) || root.endsWith('/') ? root : root + sep
97
97
  if (target !== root && !target.startsWith(fenceSep) && !target.startsWith(root + '/')) {
98
- throw new Error('путь воркдерева выходит за пределы корня воркдеревьев')
98
+ throw new Error('worktree path escapes worktree root')
99
99
  }
100
100
  return target
101
101
  }
102
102
 
103
103
  /**
104
- * Сформировать имя ветки задачи.
104
+ * Generate task branch name.
105
105
  */
106
106
  export function resolveTaskBranch(task) {
107
107
  const prefix = typeof task?.issueNumber === 'number'
@@ -112,7 +112,7 @@ export function resolveTaskBranch(task) {
112
112
  }
113
113
 
114
114
  /**
115
- * Проверить, является ли каталог git-репозиторием.
115
+ * Check if directory is a valid git repository.
116
116
  */
117
117
  export async function isGitRepo(repoDir, gitRunner = defaultGitRunner) {
118
118
  if (!repoDir) return false
@@ -125,7 +125,7 @@ export async function isGitRepo(repoDir, gitRunner = defaultGitRunner) {
125
125
  }
126
126
 
127
127
  /**
128
- * Получить список существующих путей воркдеревьев для репозитория.
128
+ * List existing worktree paths for repository.
129
129
  */
130
130
  export async function listWorktrees(repoDir, gitRunner = defaultGitRunner) {
131
131
  try {
@@ -143,7 +143,7 @@ export async function listWorktrees(repoDir, gitRunner = defaultGitRunner) {
143
143
  }
144
144
 
145
145
  /**
146
- * Определить базовую ветку для создания воркдерева.
146
+ * Resolve base ref for worktree creation.
147
147
  */
148
148
  export async function determineBaseRef(repoDir, gitRunner = defaultGitRunner) {
149
149
  for (const candidate of ['origin/main', 'origin/master', 'main', 'master', 'HEAD']) {
@@ -158,7 +158,7 @@ export async function determineBaseRef(repoDir, gitRunner = defaultGitRunner) {
158
158
  }
159
159
 
160
160
  /**
161
- * Создать или переиспользовать воркдерево под задачу.
161
+ * Create or reuse worktree for task.
162
162
  */
163
163
  export async function createTaskWorktree({
164
164
  task,
@@ -180,13 +180,13 @@ export async function createTaskWorktree({
180
180
  const worktreePath = resolveTaskWorktreePath({ task, config, env, cwdOf })
181
181
  const branchName = resolveTaskBranch(task)
182
182
 
183
- // Проверяем, не зарегистрировано ли уже это воркдерево
183
+ // Check if worktree is already registered
184
184
  const existing = await listWorktrees(repoDir, gitRunner)
185
185
  if (existing.includes(resolve(worktreePath))) {
186
186
  return { worktreePath, branchName, reused: true }
187
187
  }
188
188
 
189
- // Проверяем, существует ли уже локальная ветка с таким именем
189
+ // Check if local branch already exists
190
190
  let branchExists = false
191
191
  try {
192
192
  await gitRunner(['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}`], repoDir)
@@ -206,7 +206,7 @@ export async function createTaskWorktree({
206
206
  }
207
207
 
208
208
  /**
209
- * Проверить наличие незакоммиченных изменений в воркдереве.
209
+ * Check for uncommitted changes in worktree.
210
210
  */
211
211
  function isSubmoduleDrift(worktreePath, line) {
212
212
  try {
@@ -226,14 +226,14 @@ function isSubmoduleDrift(worktreePath, line) {
226
226
  }
227
227
  }
228
228
  } catch {
229
- // Игнорируем ошибки доступа к файлам при проверке
229
+ // Ignore file access errors during check
230
230
  }
231
231
  return false
232
232
  }
233
233
 
234
234
  /**
235
- * Проверить наличие незакоммиченных изменений в воркдереве.
236
- * Исключает структурный шум субмодулей и дрейф gitlinks (#213).
235
+ * Check for uncommitted changes in worktree.
236
+ * Excludes submodule noise and gitlink drift (#213).
237
237
  */
238
238
  export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitRunner }) {
239
239
  if (!worktreePath || !existsSync(worktreePath)) {
@@ -253,7 +253,7 @@ export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitR
253
253
  }
254
254
 
255
255
  /**
256
- * Безопасно удалить воркдерево задачи.
256
+ * Safely remove task worktree.
257
257
  */
258
258
  export async function removeTaskWorktree({
259
259
  repoDir,
@@ -281,7 +281,7 @@ export async function removeTaskWorktree({
281
281
  : ['worktree', 'remove', worktreePath]
282
282
 
283
283
  await gitRunner(removeArgs, repoDir)
284
- try { await gitRunner(['worktree', 'prune'], repoDir) } catch { /* prune игнорирует ошибки */ }
284
+ try { await gitRunner(['worktree', 'prune'], repoDir) } catch { /* prune ignores errors */ }
285
285
 
286
286
  let branchDeleted = false
287
287
  if (deleteBranch && branchName && repoDir) {
@@ -289,7 +289,7 @@ export async function removeTaskWorktree({
289
289
  await gitRunner(['branch', '-d', branchName], repoDir)
290
290
  branchDeleted = true
291
291
  } catch {
292
- // Игнорируем: ветка не удалена, если ещё не влита
292
+ // Ignored: branch not deleted if not yet merged
293
293
  branchDeleted = false
294
294
  }
295
295
  }
@@ -298,7 +298,7 @@ export async function removeTaskWorktree({
298
298
  }
299
299
 
300
300
  /**
301
- * Зарегистрировать каталог воркдерева в DSH workspaceRegistry.
301
+ * Register worktree path in DSH workspaceRegistry.
302
302
  */
303
303
  export async function registerDshWorkspace({ workspaceRegistry, worktreePath, title }) {
304
304
  if (!workspaceRegistry || typeof workspaceRegistry.create !== 'function') return undefined
@@ -311,7 +311,7 @@ export async function registerDshWorkspace({ workspaceRegistry, worktreePath, ti
311
311
  }
312
312
 
313
313
  /**
314
- * Дерегистрировать воркспейс из DSH workspaceRegistry.
314
+ * Deregister workspace from DSH workspaceRegistry.
315
315
  */
316
316
  export async function deregisterDshWorkspace({ workspaceRegistry, workspaceId }) {
317
317
  if (!workspaceRegistry || typeof workspaceRegistry.delete !== 'function' || !workspaceId) return false
@@ -323,7 +323,7 @@ export async function deregisterDshWorkspace({ workspaceRegistry, workspaceId })
323
323
  }
324
324
 
325
325
  /**
326
- * Выполнить очистку воркдерева задачи с проверкой незакоммиченных изменений.
326
+ * Clean up task worktree with dirty files guard.
327
327
  */
328
328
  export async function cleanupTaskWorktree({
329
329
  store,
@@ -354,7 +354,7 @@ export async function cleanupTaskWorktree({
354
354
  fromCol: current.column,
355
355
  toCol: current.column,
356
356
  source: 'session',
357
- detail: `очистка отложена: обнаружены незакоммиченные файлы: ${result.files.slice(0, 3).join(', ')}`,
357
+ detail: `cleanup deferred: uncommitted files detected: ${result.files.slice(0, 3).join(', ')}`,
358
358
  })
359
359
  return { error: 'worktree-dirty', files: result.files }
360
360
  }
@@ -369,14 +369,14 @@ export async function cleanupTaskWorktree({
369
369
  fromCol: current.column,
370
370
  toCol: current.column,
371
371
  source: 'session',
372
- detail: 'очистка: воркдерево удалено' + (result.branchDeleted ? ', ветка удалена' : ''),
372
+ detail: 'cleanup: worktree removed' + (result.branchDeleted ? ', branch deleted' : ''),
373
373
  })
374
374
 
375
375
  return { success: true, removed: true, branchDeleted: result.branchDeleted }
376
376
  }
377
377
 
378
378
  /**
379
- * Получить unified diff изменений в воркдереве задачи (#211).
379
+ * Generate unified diff of changes in task worktree (#211).
380
380
  *
381
381
  * @param {{worktreePath: string, baseRef?: string, gitRunner?: Function}} options
382
382
  * @returns {Promise<{diff: string, truncated: boolean, filesChanged: number}>}
@@ -391,7 +391,7 @@ export async function getTaskDiff({ worktreePath, baseRef, gitRunner = defaultGi
391
391
  base = await determineBaseRef(worktreePath, gitRunner)
392
392
  }
393
393
 
394
- // Сначала пробуем git diff base...HEAD, если base доступен
394
+ // Try git diff base...HEAD first if base ref is available
395
395
  let diffOut = ''
396
396
  if (base) {
397
397
  const res = await gitRunner(['diff', `${base}...HEAD`], worktreePath)
@@ -400,23 +400,23 @@ export async function getTaskDiff({ worktreePath, baseRef, gitRunner = defaultGi
400
400
  }
401
401
  }
402
402
 
403
- // Дополнительно смотрим незакоммиченные изменения (unstaged + staged)
403
+ // Include uncommitted changes (unstaged + staged)
404
404
  const unstaged = await gitRunner(['diff', 'HEAD'], worktreePath)
405
405
  if (unstaged.exitCode === 0 && unstaged.stdout.trim() !== '') {
406
406
  diffOut = diffOut ? `${diffOut}\n\n# Uncommitted Changes:\n${unstaged.stdout}` : unstaged.stdout
407
407
  }
408
408
 
409
- // Если и base...HEAD, и HEAD ничего не дали, берём чистый git diff
409
+ // Fallback to plain git diff if base...HEAD produced no output
410
410
  if (!diffOut.trim()) {
411
411
  const fallback = await gitRunner(['diff'], worktreePath)
412
412
  if (fallback.exitCode === 0) diffOut = fallback.stdout
413
413
  }
414
414
 
415
- // Подсчитываем число затронутых файлов
415
+ // Count affected files
416
416
  const fileLines = diffOut.split('\n').filter((l) => l.startsWith('diff --git'))
417
417
  const filesChanged = fileLines.length
418
418
 
419
- // Защита от переполнения: обрезаем diff до 250 КБ
419
+ // Memory guard: cap diff output at 250 KiB
420
420
  const MAX_DIFF_BYTES = 250 * 1024
421
421
  let truncated = false
422
422
  if (Buffer.byteLength(diffOut, 'utf8') > MAX_DIFF_BYTES) {
@@ -428,7 +428,7 @@ export async function getTaskDiff({ worktreePath, baseRef, gitRunner = defaultGi
428
428
  }
429
429
 
430
430
  /**
431
- * Получить список коммитов ветки задачи относительно базовой ветки (#211).
431
+ * List task branch commits relative to base branch (#211).
432
432
  *
433
433
  * @param {{worktreePath: string, baseRef?: string, gitRunner?: Function}} options
434
434
  * @returns {Promise<Array<{hash: string, subject: string, author: string, date: string}>>}
@@ -446,7 +446,7 @@ export async function getTaskCommits({ worktreePath, baseRef, gitRunner = defaul
446
446
  const range = base ? `${base}..HEAD` : '-n 20'
447
447
  const logRes = await gitRunner(['log', range, '--pretty=format:%h%x09%an%x09%ad%x09%s', '--date=short'], worktreePath)
448
448
  if (logRes.exitCode !== 0 || !logRes.stdout.trim()) {
449
- // fallback на последние 10 коммитов
449
+ // Fallback to last 10 commits
450
450
  const fallback = await gitRunner(['log', '-n', '10', '--pretty=format:%h%x09%an%x09%ad%x09%s', '--date=short'], worktreePath)
451
451
  if (fallback.exitCode !== 0 || !fallback.stdout.trim()) return []
452
452
  return fallback.stdout.trim().split('\n').map(parseCommitLine).filter(Boolean)
@@ -467,7 +467,7 @@ function parseCommitLine(line) {
467
467
  }
468
468
 
469
469
  /**
470
- * Слияние ветки воркдерева в основную ветку репозитория (--no-ff) при приёмке (#212).
470
+ * Merge worktree branch into repository main branch (--no-ff) upon acceptance (#212).
471
471
  *
472
472
  * @param {{task: object, targetBranch?: string, gitRunner?: Function, workspaceRegistry?: object}} options
473
473
  * @returns {Promise<{success: boolean, merged: boolean, error?: string}>}
@@ -479,13 +479,13 @@ export async function mergeTaskWorktree({
479
479
  return { success: false, merged: false, error: 'no-worktree-or-branch' }
480
480
  }
481
481
 
482
- // 1. Проверяем чистоту воркдерева
482
+ // 1. Verify worktree cleanliness
483
483
  const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree, gitRunner })
484
484
  if (dirtyCheck.dirty) {
485
485
  return { success: false, merged: false, error: 'worktree-dirty', files: dirtyCheck.files }
486
486
  }
487
487
 
488
- // 2. Находим корень основного репозитория (из .git файла воркдерева или config)
488
+ // 2. Locate main repository root
489
489
  let mainRepoDir = ''
490
490
  const revParse = await gitRunner(['rev-parse', '--git-common-dir'], task.worktree)
491
491
  if (revParse.exitCode === 0 && revParse.stdout.trim()) {
@@ -497,14 +497,14 @@ export async function mergeTaskWorktree({
497
497
  return { success: false, merged: false, error: 'main-repo-not-found' }
498
498
  }
499
499
 
500
- // 3. Определяем целевую ветку для слияния
500
+ // 3. Resolve target merge branch
501
501
  let target = targetBranch
502
502
  if (!target) {
503
503
  target = await determineBaseRef(mainRepoDir, gitRunner)
504
504
  }
505
505
  if (!target) target = 'main'
506
506
 
507
- // 4. Переключаемся или проверяем, что в целевой ветке в основном репо
507
+ // 4. Switch or verify target branch in main repository
508
508
  const currentBranch = await gitRunner(['branch', '--show-current'], mainRepoDir)
509
509
  if (currentBranch.stdout.trim() !== target) {
510
510
  const checkoutRes = await gitRunner(['checkout', target], mainRepoDir)
@@ -513,16 +513,16 @@ export async function mergeTaskWorktree({
513
513
  }
514
514
  }
515
515
 
516
- // 5. Выполняем git merge --no-ff
516
+ // 5. Execute git merge --no-ff
517
517
  const mergeMsg = `Merge branch '${task.branch}' for task ${task.id}`
518
518
  const mergeRes = await gitRunner(['merge', '--no-ff', task.branch, '-m', mergeMsg], mainRepoDir)
519
519
  if (mergeRes.exitCode !== 0) {
520
- // В случае конфликта откатываем мерж
520
+ // Roll back merge on conflict
521
521
  await gitRunner(['merge', '--abort'], mainRepoDir)
522
522
  return { success: false, merged: false, error: 'merge-conflict', detail: mergeRes.stderr || mergeRes.stdout }
523
523
  }
524
524
 
525
- // 6. Очищаем воркдерево задачи
525
+ // 6. Clean up task worktree
526
526
  await cleanupTaskWorktree({
527
527
  task,
528
528
  workspaceRegistry,
@@ -535,9 +535,9 @@ export async function mergeTaskWorktree({
535
535
 
536
536
 
537
537
  /**
538
- * Рекурсивное обнаружение всех git-репозиториев в каталоге воркспейса (#210).
538
+ * Recursive discovery of git repositories within workspace directory (#210).
539
539
  *
540
- * @param {string} rootDir Корневой каталог для поиска
540
+ * @param {string} rootDir Root directory to scan
541
541
  * @param {{maxDepth?: number, maxRepos?: number, gitRunner?: Function}} options
542
542
  * @returns {Promise<Array<{relPath: string, fullPath: string, name: string}>>}
543
543
  */
@@ -561,7 +561,7 @@ export async function findWorkspaceGitRepos(rootDir, options = {}) {
561
561
  fullPath: resolve(dir),
562
562
  name: basename(resolve(dir)),
563
563
  })
564
- // Если это репозиторий (не root), глубже в него обычно не сканируем (кроме субмодулей если нужно)
564
+ // Do not recurse into nested non-root repositories
565
565
  if (depth > 0) return
566
566
  }
567
567
 
@@ -572,7 +572,7 @@ export async function findWorkspaceGitRepos(rootDir, options = {}) {
572
572
  await scan(join(dir, entry.name), depth + 1)
573
573
  }
574
574
  } catch {
575
- // Игнорируем недоступные директории
575
+ // Ignore inaccessible directories
576
576
  }
577
577
  }
578
578
 
@@ -581,7 +581,7 @@ export async function findWorkspaceGitRepos(rootDir, options = {}) {
581
581
  }
582
582
 
583
583
  /**
584
- * Подготовка мультирепозиторного зеркалирования для воркспейса с несколькими git-репозиториями (#210).
584
+ * Multi-repository mirror setup for workspaces with multiple git repositories (#210).
585
585
  *
586
586
  * @param {{task: object, workspacePath: string, gitRunner?: Function, config?: object}} options
587
587
  * @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.19",
3
+ "version": "0.2.20",
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",