@loicngr/kobo 1.11.39 → 1.11.41

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.
Files changed (154) hide show
  1. package/AGENTS.md +101 -32
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +6 -4
  4. package/dist/mcp-server/kobo-tasks-handlers.js +16 -9
  5. package/dist/mcp-server/kobo-tasks-server.js +13 -2
  6. package/dist/mcp-server/workspace-dialogue-client.js +28 -0
  7. package/dist/server/db/mcp-message-schema.js +16 -0
  8. package/dist/server/db/migrations.js +4 -0
  9. package/dist/server/db/schema.js +4 -0
  10. package/dist/server/db/search-schema.js +53 -0
  11. package/dist/server/index.js +17 -86
  12. package/dist/server/middleware/network-auth-middleware.js +5 -1
  13. package/dist/server/routes/changelog.js +4 -49
  14. package/dist/server/routes/git.js +2 -2
  15. package/dist/server/routes/images.js +65 -5
  16. package/dist/server/routes/mcp.js +47 -0
  17. package/dist/server/routes/pull-requests.js +1 -1
  18. package/dist/server/routes/search.js +13 -4
  19. package/dist/server/routes/settings.js +5 -0
  20. package/dist/server/routes/workspaces.js +250 -248
  21. package/dist/server/services/agent/engines/claude-code/engine.js +31 -33
  22. package/dist/server/services/agent/engines/codex/engine.js +37 -8
  23. package/dist/server/services/agent/engines/codex/event-mapper.js +3 -1
  24. package/dist/server/services/agent/engines/codex/options-builder.js +4 -3
  25. package/dist/server/services/agent/engines/codex/server-requests.js +8 -1
  26. package/dist/server/services/agent/orchestrator.js +20 -3
  27. package/dist/server/services/attachment-service.js +122 -0
  28. package/dist/server/services/change-source-branch-service.js +10 -42
  29. package/dist/server/services/chat-receipt-service.js +33 -0
  30. package/dist/server/services/cron-service.js +27 -1
  31. package/dist/server/services/image-service.js +16 -7
  32. package/dist/server/services/mcp-client-context.js +12 -0
  33. package/dist/server/services/mcp-connection-info-service.js +63 -0
  34. package/dist/server/services/mcp-message-delivery-service.js +59 -0
  35. package/dist/server/services/mcp-message-request-service.js +64 -0
  36. package/dist/server/services/pr-checkout-service.js +10 -10
  37. package/dist/server/services/pr-watcher-service.js +14 -9
  38. package/dist/server/services/search/indexer.js +232 -0
  39. package/dist/server/services/search/worker.js +76 -0
  40. package/dist/server/services/search-service.js +104 -110
  41. package/dist/server/services/update-check-service.js +119 -0
  42. package/dist/server/services/websocket-service.js +121 -35
  43. package/dist/server/services/workspace-dialogue-service.js +128 -0
  44. package/dist/server/services/workspace-message-service.js +58 -0
  45. package/dist/server/services/workspace-permission-policy-service.js +33 -3
  46. package/dist/server/services/worktree-restore-service.js +2 -2
  47. package/dist/server/services/worktree-service.js +197 -124
  48. package/dist/server/utils/bounded-process.js +137 -0
  49. package/dist/server/utils/git-ops.js +76 -128
  50. package/dist/server/utils/git-repo-lock.js +27 -13
  51. package/dist/shared/attachments.js +58 -0
  52. package/dist/shared/workspace-dialogue-tools.js +89 -0
  53. package/dist/shared/workspace-message-types.js +19 -0
  54. package/package.json +2 -1
  55. package/src/client/dist/pwa/assets/{ActivityFeed-Chsw1Mh7.css → ActivityFeed-BYR5x7aa.css} +1 -1
  56. package/src/client/dist/pwa/assets/ActivityFeed-DTIe6O9o.js +8 -0
  57. package/src/client/dist/pwa/assets/{ChangelogPage-D8563008.js → ChangelogPage-BWeu0YeI.js} +1 -1
  58. package/src/client/dist/pwa/assets/{ComparisonPanel-Bd0X08sg.js → ComparisonPanel-o_EHlTwV.js} +2 -2
  59. package/src/client/dist/pwa/assets/CreatePage-CGi88k6z.css +1 -0
  60. package/src/client/dist/pwa/assets/CreatePage-DbY5gBv8.js +2 -0
  61. package/src/client/dist/pwa/assets/DashboardPage-CIo_gesD.js +1 -0
  62. package/src/client/dist/pwa/assets/{DiffViewer-DnR79ycb.css → DiffViewer-DEPOa3D7.css} +1 -1
  63. package/src/client/dist/pwa/assets/DiffViewer-DEcf48KI.js +8 -0
  64. package/src/client/dist/pwa/assets/{DocumentsPanel-BJsJ0fNs.js → DocumentsPanel-D9vcB91W.js} +1 -1
  65. package/src/client/dist/pwa/assets/{DrawerToggleButton-BVLO5K5d.js → DrawerToggleButton-Ca0_CxSl.js} +1 -1
  66. package/src/client/dist/pwa/assets/GitPanel-DCmB2R5i.js +3 -0
  67. package/src/client/dist/pwa/assets/{HealthPage-RFZS7Lao.js → HealthPage-_q3vy_xn.js} +1 -1
  68. package/src/client/dist/pwa/assets/MainLayout-Duwnq3cc.js +2 -0
  69. package/src/client/dist/pwa/assets/{QBtnDropdown-X8oNU5Mb.js → QBtnDropdown-BeiZIHVA.js} +1 -1
  70. package/src/client/dist/pwa/assets/{QDrawer-Cij17skX.js → QDrawer-Fj3M9QGD.js} +1 -1
  71. package/src/client/dist/pwa/assets/QSelect-DtSUuK8t.js +1 -0
  72. package/src/client/dist/pwa/assets/{QTooltip-CwcCGSWq.js → QTooltip-DwkX8eQ6.js} +1 -1
  73. package/src/client/dist/pwa/assets/SchedulePanel-BAA4Aajn.css +1 -0
  74. package/src/client/dist/pwa/assets/SchedulePanel-BlZDd7TK.js +1 -0
  75. package/src/client/dist/pwa/assets/SearchPage-BGc1oQSM.css +1 -0
  76. package/src/client/dist/pwa/assets/SearchPage-BGi1yRxU.js +1 -0
  77. package/src/client/dist/pwa/assets/SettingsPage-DnO-ImWE.js +9 -0
  78. package/src/client/dist/pwa/assets/SettingsPage-sVlsJQ0b.css +1 -0
  79. package/src/client/dist/pwa/assets/{SplitWorkspacePage-D7lGFRKs.js → SplitWorkspacePage-vz6jN1DM.js} +1 -1
  80. package/src/client/dist/pwa/assets/{TerminalPanel-DI8DHb9e.js → TerminalPanel-IdPIGp2_.js} +9 -9
  81. package/src/client/dist/pwa/assets/{TouchPan-B3nrkCY8.js → TouchPan-DTJqxVp9.js} +1 -1
  82. package/src/client/dist/pwa/assets/{TourReplayButton-DU1UShMY.js → TourReplayButton-Bj2GGyhU.js} +1 -1
  83. package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-C-yN5IX8.js → WorkspaceAttentionLabels-BBaGZlwv.js} +1 -1
  84. package/src/client/dist/pwa/assets/WorkspacePage-DCg9zSxz.css +1 -0
  85. package/src/client/dist/pwa/assets/WorkspacePage-Dy0spsfi.js +4 -0
  86. package/src/client/dist/pwa/assets/{browser-f9MHQjfi.js → browser-BFj5wwbS.js} +1 -1
  87. package/src/client/dist/pwa/assets/{build-path-tree-BWBzP8os.js → build-path-tree-C-HSiySk.js} +1 -1
  88. package/src/client/dist/pwa/assets/de-BdLe7apJ.js +17 -0
  89. package/src/client/dist/pwa/assets/{documents-C2cLnEBQ.js → documents-C2Xnjh9Z.js} +1 -1
  90. package/src/client/dist/pwa/assets/{editor.api-sw146MWN.js → editor.api-BbKfnNmv.js} +1 -1
  91. package/src/client/dist/pwa/assets/{efforts-C5ZZgCH4.js → efforts-BM7fdaaL.js} +1 -1
  92. package/src/client/dist/pwa/assets/engineFeatures-b74YRODT.js +1 -0
  93. package/src/client/dist/pwa/assets/es-C1LM-3aq.js +17 -0
  94. package/src/client/dist/pwa/assets/expand-template-DiDWiIkx.js +1 -0
  95. package/src/client/dist/pwa/assets/{format-BwWP6Aay.js → format-CnbUxAmw.js} +1 -1
  96. package/src/client/dist/pwa/assets/{formatters-DyWtO-nR.js → formatters-B2uUIbAY.js} +1 -1
  97. package/src/client/dist/pwa/assets/fr-KCK6r5ga.js +17 -0
  98. package/src/client/dist/pwa/assets/{freemarker2-DZ5Hm8UH.js → freemarker2-Cu1vGY0x.js} +1 -1
  99. package/src/client/dist/pwa/assets/{handlebars-BRUGpTA7.js → handlebars-CETuqLyz.js} +1 -1
  100. package/src/client/dist/pwa/assets/{html-DNOayNNs.js → html-BTNpr39P.js} +1 -1
  101. package/src/client/dist/pwa/assets/i18n-BkUirOzl.js +1 -0
  102. package/src/client/dist/pwa/assets/index-mvh5w1cM.js +20 -0
  103. package/src/client/dist/pwa/assets/it-BM6t0IGU.js +17 -0
  104. package/src/client/dist/pwa/assets/{javascript-C0tDQU3z.js → javascript-C7Ncb6V7.js} +1 -1
  105. package/src/client/dist/pwa/assets/{liquid-CN46O6OF.js → liquid-D0hUdg4x.js} +1 -1
  106. package/src/client/dist/pwa/assets/{mdx-DM-lrr8Q.js → mdx-BK5H1Xst.js} +1 -1
  107. package/src/client/dist/pwa/assets/{monaco-contributions-CkN7y-8c.js → monaco-contributions-BBW6JNgz.js} +1 -1
  108. package/src/client/dist/pwa/assets/{monaco.contribution-2BcAOLjc.js → monaco.contribution-1LZrAlY4.js} +2 -2
  109. package/src/client/dist/pwa/assets/network-auth-CVGS__6a.js +1 -0
  110. package/src/client/dist/pwa/assets/{notify-theme-a91Tcr6o.js → notify-theme-DIKLxr8F.js} +1 -1
  111. package/src/client/dist/pwa/assets/{pending-diff-open-DOtELZfB.js → pending-diff-open-B02zuH1j.js} +1 -1
  112. package/src/client/dist/pwa/assets/{permissionModes-C-9Crj-c.js → permissionModes-C3otN1Vk.js} +1 -1
  113. package/src/client/dist/pwa/assets/{project-color-BRe4oyNx.js → project-color-BUcW_eZA.js} +1 -1
  114. package/src/client/dist/pwa/assets/{python-BRpJG6sZ.js → python-DEeUVvnp.js} +1 -1
  115. package/src/client/dist/pwa/assets/{razor-CehCmvHl.js → razor-CG6cL8ya.js} +1 -1
  116. package/src/client/dist/pwa/assets/{render-chat-markdown-DlbQLZ02.js → render-chat-markdown-Ct_T5TPN.js} +1 -1
  117. package/src/client/dist/pwa/assets/{typescript-Dt_8mVuj.js → typescript-CSQAg5UC.js} +1 -1
  118. package/src/client/dist/pwa/assets/use-search-index-status-DtLzygcy.js +1 -0
  119. package/src/client/dist/pwa/assets/use-tours-_6HwJ_Rp.js +2 -0
  120. package/src/client/dist/pwa/assets/{workspace-templates-GCObcjO6.js → workspace-templates-B8WWsWEI.js} +1 -1
  121. package/src/client/dist/pwa/assets/{xml-_gOwdzB4.js → xml-BSB4-DXR.js} +1 -1
  122. package/src/client/dist/pwa/assets/{yaml-BxeBqpGf.js → yaml-CSGKph85.js} +1 -1
  123. package/src/client/dist/pwa/index.html +1 -1
  124. package/src/client/dist/pwa/sw.js +1 -1
  125. package/src/mcp-server/README.md +169 -9
  126. package/src/mcp-server/kobo-tasks-handlers.ts +14 -11
  127. package/src/mcp-server/kobo-tasks-server.ts +19 -2
  128. package/src/mcp-server/workspace-dialogue-client.ts +34 -0
  129. package/src/client/dist/pwa/assets/ActivityFeed-BzzvcOmq.js +0 -8
  130. package/src/client/dist/pwa/assets/CreatePage-A3pVradx.css +0 -1
  131. package/src/client/dist/pwa/assets/CreatePage-Bon8qIka.js +0 -2
  132. package/src/client/dist/pwa/assets/DashboardPage-DYxdxEul.js +0 -1
  133. package/src/client/dist/pwa/assets/DiffViewer-BpsLNxnj.js +0 -8
  134. package/src/client/dist/pwa/assets/GitPanel-Cal_wzC0.js +0 -3
  135. package/src/client/dist/pwa/assets/MainLayout-vvyiRcql.js +0 -2
  136. package/src/client/dist/pwa/assets/QSelect-xd-AlGo_.js +0 -1
  137. package/src/client/dist/pwa/assets/SchedulePanel-BunlzsOY.css +0 -1
  138. package/src/client/dist/pwa/assets/SchedulePanel-CXRUsz6G.js +0 -1
  139. package/src/client/dist/pwa/assets/SearchPage-CVgRa025.css +0 -1
  140. package/src/client/dist/pwa/assets/SearchPage-DopbgLxh.js +0 -1
  141. package/src/client/dist/pwa/assets/SettingsPage-Do9d0Pov.css +0 -1
  142. package/src/client/dist/pwa/assets/SettingsPage-Jx6TnOzR.js +0 -9
  143. package/src/client/dist/pwa/assets/WorkspacePage-BAC6u9OP.css +0 -1
  144. package/src/client/dist/pwa/assets/WorkspacePage-DKRpdpPg.js +0 -4
  145. package/src/client/dist/pwa/assets/de-QkDLrcGq.js +0 -17
  146. package/src/client/dist/pwa/assets/engineFeatures-CAVMDawq.js +0 -1
  147. package/src/client/dist/pwa/assets/es-BgQFKlo8.js +0 -17
  148. package/src/client/dist/pwa/assets/expand-template-B6xAlAaM.js +0 -1
  149. package/src/client/dist/pwa/assets/fr-DAdLWjNM.js +0 -17
  150. package/src/client/dist/pwa/assets/i18n-CBez_vip.js +0 -1
  151. package/src/client/dist/pwa/assets/index-VL3fI9a3.js +0 -20
  152. package/src/client/dist/pwa/assets/it-Bumaewp1.js +0 -17
  153. package/src/client/dist/pwa/assets/network-auth-KYS4snQH.js +0 -1
  154. package/src/client/dist/pwa/assets/use-tours-CSVM_54K.js +0 -2
@@ -1,19 +1,20 @@
1
- import { execFile, execFileSync } from 'node:child_process';
2
- import fs from 'node:fs';
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { runBoundedProcess } from '../utils/bounded-process.js';
4
6
  import { isGitBranchExistsError } from '../utils/git-ops.js';
5
7
  import { withGitRepoLock } from '../utils/git-repo-lock.js';
6
8
  import { resolveWorkspaceWorktreePath, resolveWorktreesRoot } from '../utils/worktree-paths.js';
7
- function git(repoPath, args) {
8
- return execFileSync('git', args, {
9
+ const READ_TIMEOUT_MS = 10_000;
10
+ const MUTATION_TIMEOUT_MS = 5 * 60_000;
11
+ async function git(repoPath, args, timeoutMs = READ_TIMEOUT_MS) {
12
+ return (await runBoundedProcess('git', args, {
9
13
  cwd: repoPath,
10
- encoding: 'utf-8',
11
- stdio: ['pipe', 'pipe', 'pipe'],
12
- // Force the C locale so git (and libc strerror) emit English error messages.
13
- // Without this, a French host reports "Permission non accordée" instead of
14
- // "Permission denied", and permission-failure detection silently misses it.
15
- env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
16
- }).trim();
14
+ timeoutMs,
15
+ stdoutLimit: 64 * 1024 * 1024,
16
+ env: { ...process.env, LC_ALL: 'C', LANG: 'C', GIT_TERMINAL_PROMPT: '0' },
17
+ })).replace(/\n$/, '');
17
18
  }
18
19
  /** True when an exec/git error message indicates a filesystem permission failure.
19
20
  * git runs under LC_ALL=C (English), but we also match common localized phrasings
@@ -26,20 +27,57 @@ export function buildDockerChownArgs(worktreePath, uid, gid, image) {
26
27
  return ['run', '--rm', '-v', `${worktreePath}:/w`, image, 'chown', '-R', `${uid}:${gid}`, '/w'];
27
28
  }
28
29
  const DEFAULT_CLEANUP_IMAGE = 'alpine';
29
- function isDockerAvailable() {
30
+ async function isDockerAvailable() {
30
31
  try {
31
- execFileSync('docker', ['version'], { stdio: ['ignore', 'ignore', 'ignore'] });
32
+ await runBoundedProcess('docker', ['version'], { timeoutMs: READ_TIMEOUT_MS });
32
33
  return true;
33
34
  }
34
35
  catch {
35
36
  return false;
36
37
  }
37
38
  }
38
- function reclaimWorktreeOwnershipViaDocker(worktreePath, uid, gid, image) {
39
- execFileSync('docker', buildDockerChownArgs(worktreePath, uid, gid, image), {
40
- encoding: 'utf-8',
41
- stdio: ['pipe', 'pipe', 'pipe'],
42
- });
39
+ async function reclaimWorktreeOwnershipViaDocker(worktreePath, uid, gid, image) {
40
+ const containerName = `kobo-cleanup-${randomUUID()}`;
41
+ const args = buildDockerChownArgs(worktreePath, uid, gid, image);
42
+ args.splice(1, 0, '--name', containerName);
43
+ let failure;
44
+ try {
45
+ await runBoundedProcess('docker', args, { timeoutMs: MUTATION_TIMEOUT_MS });
46
+ }
47
+ catch (err) {
48
+ failure = err;
49
+ }
50
+ // Docker containers belong to the daemon, not the CLI's process group.
51
+ // A killed/disconnected `docker run` cannot prove that chown has stopped.
52
+ let stopRequested = false;
53
+ let warned = false;
54
+ for (;;) {
55
+ try {
56
+ const state = await runBoundedProcess('docker', ['ps', '-a', '--filter', `name=^/${containerName}$`, '--format', '{{.ID}} {{.State}}'], { timeoutMs: READ_TIMEOUT_MS, stdoutLimit: 8192 });
57
+ const active = state
58
+ .trim()
59
+ .split('\n')
60
+ .filter(Boolean)
61
+ .some((line) => !/ (?:exited|dead|created)$/.test(line));
62
+ if (!active)
63
+ break;
64
+ try {
65
+ await runBoundedProcess('docker', stopRequested ? ['kill', containerName] : ['stop', '--time', '5', containerName], { timeoutMs: READ_TIMEOUT_MS });
66
+ }
67
+ finally {
68
+ stopRequested = true;
69
+ }
70
+ }
71
+ catch (err) {
72
+ if (!warned) {
73
+ warned = true;
74
+ console.error(`[worktree] Cannot yet confirm cleanup container '${containerName}' stopped; retaining ownership:`, err);
75
+ }
76
+ }
77
+ await delay(1000);
78
+ }
79
+ if (failure)
80
+ throw failure;
43
81
  }
44
82
  function getExcludeFilePath(projectPath) {
45
83
  return path.join(projectPath, '.git', 'info', 'exclude');
@@ -50,68 +88,99 @@ function projectRelativeWorktreePath(projectPath, worktreePath) {
50
88
  return null;
51
89
  return relativePath;
52
90
  }
53
- function addToExclude(projectPath, worktreePath, excludeFile = getExcludeFilePath(projectPath)) {
91
+ async function addToExclude(projectPath, worktreePath, excludeFile = getExcludeFilePath(projectPath)) {
54
92
  const relativePath = projectRelativeWorktreePath(projectPath, worktreePath);
55
93
  if (!relativePath)
56
94
  return;
57
95
  // Ensure the .git/info directory exists
58
96
  const infoDir = path.dirname(excludeFile);
59
- if (!fs.existsSync(infoDir)) {
60
- fs.mkdirSync(infoDir, { recursive: true });
97
+ if (!(await restorePathExists(infoDir))) {
98
+ await fs.mkdir(infoDir, { recursive: true });
61
99
  }
62
100
  const entry = `/${relativePath}`;
63
101
  let current = '';
64
- if (fs.existsSync(excludeFile)) {
65
- current = fs.readFileSync(excludeFile, 'utf-8');
102
+ if (await restorePathExists(excludeFile)) {
103
+ current = await fs.readFile(excludeFile, 'utf-8');
66
104
  }
67
105
  if (!current.split('\n').includes(entry)) {
68
106
  const newContent = current.endsWith('\n') || current === '' ? `${current}${entry}\n` : `${current}\n${entry}\n`;
69
- fs.writeFileSync(excludeFile, newContent, 'utf-8');
107
+ await fs.writeFile(excludeFile, newContent, 'utf-8');
70
108
  }
71
109
  }
72
- function removeFromExclude(projectPath, worktreePath) {
110
+ async function removeFromExclude(projectPath, worktreePath) {
73
111
  const relativePath = projectRelativeWorktreePath(projectPath, worktreePath);
74
112
  if (!relativePath)
75
113
  return;
76
114
  const excludeFile = getExcludeFilePath(projectPath);
77
- if (!fs.existsSync(excludeFile))
115
+ if (!(await restorePathExists(excludeFile)))
78
116
  return;
79
117
  const entry = `/${relativePath}`;
80
- const lines = fs.readFileSync(excludeFile, 'utf-8').split('\n');
118
+ const lines = (await fs.readFile(excludeFile, 'utf-8')).split('\n');
81
119
  const filtered = lines.filter((line) => line !== entry);
82
120
  const trimmed = filtered.join('\n').replace(/\n+$/, '');
83
- fs.writeFileSync(excludeFile, trimmed ? `${trimmed}\n` : '', 'utf-8');
121
+ await fs.writeFile(excludeFile, trimmed ? `${trimmed}\n` : '', 'utf-8');
84
122
  }
85
123
  /** Create a git worktree for the given branch, based on `baseRef`.
86
124
  * `baseRef` is passed explicitly by the caller: `origin/<sourceBranch>` in the
87
125
  * nominal path, or the local `<sourceBranch>` when origin is unreachable.
88
126
  * Returns the worktree path and which base was used. */
89
127
  export function createWorktree(projectPath, branchName, baseRef, worktreesPath, projectSlug, explicitPath) {
128
+ return withGitRepoLock(projectPath, () => createWorktreeUnlocked(projectPath, branchName, baseRef, worktreesPath, projectSlug, explicitPath));
129
+ }
130
+ /** A checkout was created even though a Git hook or subsequent bookkeeping failed. */
131
+ export class WorktreeCreationError extends Error {
132
+ worktreePath;
133
+ branchCreated;
134
+ constructor(worktreePath, branchCreated, cause) {
135
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
136
+ this.worktreePath = worktreePath;
137
+ this.branchCreated = branchCreated;
138
+ this.name = 'WorktreeCreationError';
139
+ }
140
+ }
141
+ /** Create a worktree while the caller already owns the shared repository lock. */
142
+ export async function createWorktreeUnlocked(projectPath, branchName, baseRef, worktreesPath, projectSlug, explicitPath) {
90
143
  const worktreesDir = resolveWorktreesRoot(projectPath, worktreesPath);
91
- if (!fs.existsSync(worktreesDir)) {
92
- fs.mkdirSync(worktreesDir, { recursive: true });
144
+ if (!(await restorePathExists(worktreesDir))) {
145
+ await fs.mkdir(worktreesDir, { recursive: true });
93
146
  }
94
147
  const worktreePath = explicitPath || resolveWorkspaceWorktreePath(projectPath, branchName, worktreesPath, projectSlug);
95
148
  const base = baseRef.startsWith('origin/') ? 'origin' : 'local';
96
- // Tracks which of the two git commands below actually ran. Callers use it to
97
- // decide whether a rollback may delete the branch: deleting one we merely
98
- // attached to would destroy commits Kobo never created.
149
+ // Capture ownership evidence before Git runs: a failing post-checkout hook
150
+ // can leave a fully registered checkout even though `git worktree add` fails.
151
+ const pathExisted = await restorePathExists(worktreePath);
152
+ const registeredBefore = (await listWorktrees(projectPath)).some((entry) => path.resolve(entry.path) === path.resolve(worktreePath));
153
+ const branchRef = `refs/heads/${branchName}`;
154
+ const branchExisted = (await git(projectPath, ['for-each-ref', '--format=%(refname)', branchRef]))
155
+ .split('\n')
156
+ .includes(branchRef);
99
157
  let branchCreated = true;
100
158
  try {
101
- git(projectPath, ['worktree', 'add', '-b', branchName, worktreePath, baseRef]);
102
- }
103
- catch (err) {
104
- const message = err instanceof Error ? err.message : String(err);
105
- // If branch already exists, add worktree without creating the branch
106
- if (isGitBranchExistsError(message)) {
107
- git(projectPath, ['worktree', 'add', worktreePath, branchName]);
159
+ try {
160
+ await git(projectPath, ['worktree', 'add', '-b', branchName, worktreePath, baseRef], MUTATION_TIMEOUT_MS);
161
+ }
162
+ catch (err) {
163
+ const message = err instanceof Error ? err.message : String(err);
164
+ if (!isGitBranchExistsError(message))
165
+ throw err;
108
166
  branchCreated = false;
167
+ await git(projectPath, ['worktree', 'add', worktreePath, branchName], MUTATION_TIMEOUT_MS);
109
168
  }
110
- else {
111
- throw new Error(`Failed to create worktree for branch '${branchName}': ${message}`);
169
+ }
170
+ catch (err) {
171
+ if (!pathExisted &&
172
+ !registeredBefore &&
173
+ (await isMatchingWorkspaceWorktree({ projectPath, worktreePath, workingBranch: branchName }))) {
174
+ throw new WorktreeCreationError(worktreePath, branchCreated && !branchExisted, err);
112
175
  }
176
+ throw new Error(`Failed to create worktree for branch '${branchName}': ${err instanceof Error ? err.message : String(err)}`, { cause: err });
177
+ }
178
+ try {
179
+ await addToExclude(projectPath, worktreePath);
180
+ }
181
+ catch (err) {
182
+ throw new WorktreeCreationError(worktreePath, branchCreated, err);
113
183
  }
114
- addToExclude(projectPath, worktreePath);
115
184
  return { worktreePath, base, branchCreated };
116
185
  }
117
186
  /** `git worktree remove` drops the administrative entry even when it fails to
@@ -122,14 +191,14 @@ export function createWorktree(projectPath, branchName, baseRef, worktreesPath,
122
191
  *
123
192
  * Callers hold the repository lock: acquiring it again here would deadlock on
124
193
  * the same key, since the chain is strictly sequential per common git dir. */
125
- function assertWorktreeGone(projectPath, worktreePath) {
194
+ async function assertWorktreeGone(projectPath, worktreePath) {
126
195
  try {
127
- git(projectPath, ['worktree', 'prune']);
196
+ await git(projectPath, ['worktree', 'prune']);
128
197
  }
129
198
  catch (err) {
130
199
  console.warn(`[worktree] prune after removing '${worktreePath}' failed:`, err instanceof Error ? err.message : err);
131
200
  }
132
- if (fs.existsSync(worktreePath)) {
201
+ if (await restorePathExists(worktreePath)) {
133
202
  throw new Error(`Failed to remove worktree '${worktreePath}': the directory is still on disk after removal`);
134
203
  }
135
204
  }
@@ -149,28 +218,28 @@ export function removeWorktree(projectPath, worktreePath) {
149
218
  return withGitRepoLock(projectPath, () => removeWorktreeUnlocked(projectPath, worktreePath));
150
219
  }
151
220
  /** Remove a worktree. Call ONLY while already holding the repository lock. */
152
- export function removeWorktreeUnlocked(projectPath, worktreePath) {
221
+ export async function removeWorktreeUnlocked(projectPath, worktreePath) {
153
222
  try {
154
- git(projectPath, ['worktree', 'remove', worktreePath, '--force']);
223
+ await git(projectPath, ['worktree', 'remove', worktreePath, '--force'], MUTATION_TIMEOUT_MS);
155
224
  }
156
225
  catch (err) {
157
226
  const message = err instanceof Error ? err.message : String(err);
158
227
  const uid = typeof process.getuid === 'function' ? process.getuid() : null;
159
228
  const gid = typeof process.getgid === 'function' ? process.getgid() : null;
160
- if (isPermissionError(message) && uid != null && gid != null && isDockerAvailable()) {
229
+ if (isPermissionError(message) && uid != null && gid != null && (await isDockerAvailable())) {
161
230
  const image = process.env.KOBO_WORKTREE_CLEANUP_IMAGE || DEFAULT_CLEANUP_IMAGE;
162
231
  console.warn(`[worktree] '${worktreePath}' has root-owned files (permission denied); reclaiming ownership via Docker (${image})…`);
163
232
  try {
164
- reclaimWorktreeOwnershipViaDocker(worktreePath, uid, gid, image);
233
+ await reclaimWorktreeOwnershipViaDocker(worktreePath, uid, gid, image);
165
234
  // The first `git worktree remove` already de-registered this worktree (it
166
235
  // drops the admin entry even when the directory rm fails on permission), so
167
236
  // retrying it errors with "is not a working tree". Now that we own the files,
168
237
  // delete the directory directly and prune any dangling worktree metadata.
169
- fs.rmSync(worktreePath, { recursive: true, force: true });
170
- git(projectPath, ['worktree', 'prune']);
238
+ await fs.rm(worktreePath, { recursive: true, force: true });
239
+ await git(projectPath, ['worktree', 'prune']);
171
240
  console.log(`[worktree] Docker cleanup succeeded; removed '${worktreePath}'`);
172
- removeFromExclude(projectPath, worktreePath);
173
- assertWorktreeGone(projectPath, worktreePath);
241
+ await removeFromExclude(projectPath, worktreePath);
242
+ await assertWorktreeGone(projectPath, worktreePath);
174
243
  return;
175
244
  }
176
245
  catch (retryErr) {
@@ -181,20 +250,20 @@ export function removeWorktreeUnlocked(projectPath, worktreePath) {
181
250
  }
182
251
  throw new Error(`Failed to remove worktree '${worktreePath}': ${message}`);
183
252
  }
184
- removeFromExclude(projectPath, worktreePath);
185
- assertWorktreeGone(projectPath, worktreePath);
253
+ await removeFromExclude(projectPath, worktreePath);
254
+ await assertWorktreeGone(projectPath, worktreePath);
186
255
  }
187
256
  /** List all git worktrees for a repository by parsing `git worktree list --porcelain`. */
188
- export function listWorktrees(projectPath) {
189
- const output = git(projectPath, ['worktree', 'list', '--porcelain']);
257
+ export async function listWorktrees(projectPath) {
258
+ const output = await git(projectPath, ['worktree', 'list', '--porcelain', '-z']);
190
259
  const worktrees = [];
191
- const blocks = output.split('\n\n').filter(Boolean);
260
+ const blocks = output.split('\0\0').filter(Boolean);
192
261
  for (const block of blocks) {
193
- const lines = block.split('\n');
262
+ const lines = block.split('\0');
194
263
  const worktree = {};
195
264
  for (const line of lines) {
196
265
  if (line.startsWith('worktree ')) {
197
- worktree.path = line.slice('worktree '.length).trim();
266
+ worktree.path = line.slice('worktree '.length);
198
267
  }
199
268
  else if (line.startsWith('HEAD ')) {
200
269
  worktree.head = line.slice('HEAD '.length).trim();
@@ -219,27 +288,27 @@ export function listWorktrees(projectPath) {
219
288
  return worktrees;
220
289
  }
221
290
  /** Check whether a worktree for the given branch already exists. */
222
- export function worktreeExists(projectPath, branchName) {
291
+ export async function worktreeExists(projectPath, branchName) {
223
292
  try {
224
- const worktrees = listWorktrees(projectPath);
293
+ const worktrees = await listWorktrees(projectPath);
225
294
  return worktrees.some((wt) => wt.branch === branchName);
226
295
  }
227
296
  catch {
228
297
  return false;
229
298
  }
230
299
  }
231
- function canonicalize(p) {
300
+ async function canonicalize(p) {
232
301
  try {
233
- return fs.realpathSync(p);
302
+ return await fs.realpath(p);
234
303
  }
235
304
  catch {
236
305
  return p;
237
306
  }
238
307
  }
239
- function detectSourceBranch(projectPath, worktreePath, branch) {
308
+ async function detectSourceBranch(projectPath, worktreePath, branch) {
240
309
  // 1. Branch's tracked upstream (configured locally)
241
310
  try {
242
- const upstream = git(worktreePath, ['config', '--get', `branch.${branch}.merge`]);
311
+ const upstream = await git(worktreePath, ['config', '--get', `branch.${branch}.merge`]);
243
312
  if (upstream)
244
313
  return upstream.replace(/^refs\/heads\//, '');
245
314
  }
@@ -248,7 +317,7 @@ function detectSourceBranch(projectPath, worktreePath, branch) {
248
317
  }
249
318
  // 2. Repo's default branch (origin/HEAD)
250
319
  try {
251
- const head = git(projectPath, ['symbolic-ref', 'refs/remotes/origin/HEAD']);
320
+ const head = await git(projectPath, ['symbolic-ref', 'refs/remotes/origin/HEAD']);
252
321
  if (head)
253
322
  return head.replace(/^refs\/remotes\/origin\//, '');
254
323
  }
@@ -264,19 +333,23 @@ function detectSourceBranch(projectPath, worktreePath, branch) {
264
333
  * branch to anchor a workspace to). Both sides of the path comparison are
265
334
  * canonicalized to defeat symlinks / trailing-slash variants.
266
335
  */
267
- export function listOrphanWorktrees(projectPath, attachedPaths) {
268
- const canonAttached = new Set(Array.from(attachedPaths).map(canonicalize));
269
- const canonProject = canonicalize(projectPath);
270
- return listWorktrees(projectPath)
271
- .filter((wt) => canonicalize(wt.path) !== canonProject)
272
- .filter((wt) => !!wt.branch && wt.branch !== '(detached HEAD)')
273
- .filter((wt) => !canonAttached.has(canonicalize(wt.path)))
274
- .map((wt) => ({
275
- path: wt.path,
276
- branch: wt.branch,
277
- head: wt.head,
278
- suggestedSourceBranch: detectSourceBranch(projectPath, wt.path, wt.branch),
279
- }));
336
+ export async function listOrphanWorktrees(projectPath, attachedPaths) {
337
+ const canonAttached = new Set(await Promise.all(Array.from(attachedPaths).map(canonicalize)));
338
+ const canonProject = await canonicalize(projectPath);
339
+ const orphans = [];
340
+ for (const worktree of await listWorktrees(projectPath)) {
341
+ const canonicalPath = await canonicalize(worktree.path);
342
+ if (canonicalPath === canonProject ||
343
+ canonAttached.has(canonicalPath) ||
344
+ !worktree.branch ||
345
+ worktree.branch === '(detached HEAD)')
346
+ continue;
347
+ orphans.push({
348
+ ...worktree,
349
+ suggestedSourceBranch: await detectSourceBranch(projectPath, worktree.path, worktree.branch),
350
+ });
351
+ }
352
+ return orphans;
280
353
  }
281
354
  export class WorktreeCheckoutError extends Error {
282
355
  code;
@@ -287,18 +360,17 @@ export class WorktreeCheckoutError extends Error {
287
360
  }
288
361
  }
289
362
  const restoreGitEnv = () => ({ ...process.env, LC_ALL: 'C', LANG: 'C', GIT_TERMINAL_PROMPT: '0' });
290
- function restoreGit(cwd, args) {
291
- return execFileSync('git', ['-c', 'core.hooksPath=/dev/null', ...args], {
363
+ async function restoreGit(cwd, args) {
364
+ return (await runBoundedProcess('git', ['-c', 'core.hooksPath=/dev/null', ...args], {
292
365
  cwd,
293
- encoding: 'utf8',
294
- stdio: ['ignore', 'pipe', 'pipe'],
295
- timeout: 30_000,
366
+ timeoutMs: args[0] === 'fetch' || (args[0] === 'worktree' && args[1] === 'add') ? MUTATION_TIMEOUT_MS : READ_TIMEOUT_MS,
367
+ stdoutLimit: 64 * 1024 * 1024,
296
368
  env: restoreGitEnv(),
297
- }).replace(/\n$/, '');
369
+ })).replace(/\n$/, '');
298
370
  }
299
371
  /** NUL delimiters preserve quoted, whitespace and newline-containing worktree paths. */
300
- function restoreRegistrations(projectPath) {
301
- return restoreGit(projectPath, ['worktree', 'list', '--porcelain', '-z'])
372
+ async function restoreRegistrations(projectPath) {
373
+ return (await restoreGit(projectPath, ['worktree', 'list', '--porcelain', '-z']))
302
374
  .split('\0\0')
303
375
  .filter(Boolean)
304
376
  .map((block) => {
@@ -309,31 +381,35 @@ function restoreRegistrations(projectPath) {
309
381
  };
310
382
  });
311
383
  }
312
- function commonGitDirectory(cwd) {
313
- return fs.realpathSync(restoreGit(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']));
384
+ async function commonGitDirectory(cwd) {
385
+ return await fs.realpath(await restoreGit(cwd, ['rev-parse', '--path-format=absolute', '--git-common-dir']));
314
386
  }
315
387
  /** Accept only the exact registered root in the same repository on the expected branch. */
316
- export function isMatchingWorkspaceWorktree(input) {
388
+ export async function isMatchingWorkspaceWorktree(input) {
317
389
  try {
318
- if (!fs.lstatSync(input.worktreePath).isDirectory())
390
+ if (!(await fs.lstat(input.worktreePath)).isDirectory())
319
391
  return false;
320
- const target = fs.realpathSync(input.worktreePath);
321
- if (fs.realpathSync(restoreGit(input.worktreePath, ['rev-parse', '--show-toplevel'])) !== target)
392
+ const target = await fs.realpath(input.worktreePath);
393
+ if ((await fs.realpath(await restoreGit(input.worktreePath, ['rev-parse', '--show-toplevel']))) !== target)
322
394
  return false;
323
- if (commonGitDirectory(input.projectPath) !== commonGitDirectory(input.worktreePath))
395
+ if ((await commonGitDirectory(input.projectPath)) !== (await commonGitDirectory(input.worktreePath)))
324
396
  return false;
325
397
  const ref = `refs/heads/${input.workingBranch}`;
326
- if (restoreGit(input.worktreePath, ['symbolic-ref', 'HEAD']) !== ref)
398
+ if ((await restoreGit(input.worktreePath, ['symbolic-ref', 'HEAD'])) !== ref)
327
399
  return false;
328
- return restoreRegistrations(input.projectPath).some((entry) => canonicalize(entry.path) === target && entry.branch === ref);
400
+ for (const entry of await restoreRegistrations(input.projectPath)) {
401
+ if ((await canonicalize(entry.path)) === target && entry.branch === ref)
402
+ return true;
403
+ }
404
+ return false;
329
405
  }
330
406
  catch {
331
407
  return false;
332
408
  }
333
409
  }
334
- function restorePathExists(target) {
410
+ async function restorePathExists(target) {
335
411
  try {
336
- fs.lstatSync(target);
412
+ await fs.lstat(target);
337
413
  return true;
338
414
  }
339
415
  catch (err) {
@@ -342,9 +418,9 @@ function restorePathExists(target) {
342
418
  throw err;
343
419
  }
344
420
  }
345
- function resolveRestoreCommit(projectPath, ref) {
421
+ async function resolveRestoreCommit(projectPath, ref) {
346
422
  try {
347
- return restoreGit(projectPath, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]);
423
+ return await restoreGit(projectPath, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]);
348
424
  }
349
425
  catch {
350
426
  return null;
@@ -355,7 +431,7 @@ export async function restoreWorktreeCheckoutUnlocked(input) {
355
431
  const { projectPath, workingBranch } = input;
356
432
  const worktreePath = path.resolve(input.worktreePath);
357
433
  try {
358
- commonGitDirectory(projectPath);
434
+ await commonGitDirectory(projectPath);
359
435
  }
360
436
  catch {
361
437
  throw new WorktreeCheckoutError('project-unavailable', 'The project Git repository is unavailable. Restore its location first.');
@@ -363,14 +439,17 @@ export async function restoreWorktreeCheckoutUnlocked(input) {
363
439
  try {
364
440
  if (workingBranch.startsWith('-'))
365
441
  throw new Error('Branch names cannot start with a dash');
366
- restoreGit(projectPath, ['check-ref-format', `refs/heads/${workingBranch}`]);
367
- if (restorePathExists(worktreePath)) {
368
- if (!isMatchingWorkspaceWorktree(input)) {
442
+ await restoreGit(projectPath, ['check-ref-format', `refs/heads/${workingBranch}`]);
443
+ if (await restorePathExists(worktreePath)) {
444
+ if (!(await isMatchingWorkspaceWorktree(input))) {
369
445
  throw new WorktreeCheckoutError('path-conflict', 'The destination is occupied by a different checkout or directory. Move it before retrying.');
370
446
  }
371
- return { source: 'existing-worktree', headCommitSha: restoreGit(worktreePath, ['rev-parse', '--verify', 'HEAD']) };
447
+ return {
448
+ source: 'existing-worktree',
449
+ headCommitSha: await restoreGit(worktreePath, ['rev-parse', '--verify', 'HEAD']),
450
+ };
372
451
  }
373
- const registrations = restoreRegistrations(projectPath);
452
+ const registrations = await restoreRegistrations(projectPath);
374
453
  // Do not globally prune: another missing checkout may still need its registration.
375
454
  if (registrations.some((entry) => path.resolve(entry.path) === worktreePath)) {
376
455
  throw new WorktreeCheckoutError('path-conflict', 'Git still registers the missing destination. Repair that worktree registration before retrying.');
@@ -379,25 +458,19 @@ export async function restoreWorktreeCheckoutUnlocked(input) {
379
458
  throw new WorktreeCheckoutError('branch-in-use', 'The working branch is already checked out elsewhere. Free that checkout before retrying.');
380
459
  }
381
460
  let source = 'local-branch';
382
- const localCommit = resolveRestoreCommit(projectPath, `refs/heads/${workingBranch}`);
461
+ const localCommit = await resolveRestoreCommit(projectPath, `refs/heads/${workingBranch}`);
383
462
  let commit = localCommit;
384
463
  if (!commit) {
385
464
  source = 'saved-commit';
386
465
  commit =
387
466
  input.headCommitSha && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(input.headCommitSha)
388
- ? resolveRestoreCommit(projectPath, input.headCommitSha)
467
+ ? await resolveRestoreCommit(projectPath, input.headCommitSha)
389
468
  : null;
390
469
  if (!commit) {
391
470
  source = 'remote-branch';
392
471
  try {
393
- await new Promise((resolve, reject) => {
394
- execFile('git', ['-c', 'core.hooksPath=/dev/null', 'fetch', '--no-tags', 'origin', `refs/heads/${workingBranch}`], {
395
- cwd: projectPath,
396
- timeout: 60_000,
397
- env: restoreGitEnv(),
398
- }, (error) => (error ? reject(error) : resolve()));
399
- });
400
- commit = resolveRestoreCommit(projectPath, 'FETCH_HEAD');
472
+ await restoreGit(projectPath, ['fetch', '--no-tags', 'origin', `refs/heads/${workingBranch}`]);
473
+ commit = await resolveRestoreCommit(projectPath, 'FETCH_HEAD');
401
474
  }
402
475
  catch {
403
476
  // Avoid exposing remote credentials embedded in Git error output.
@@ -408,18 +481,18 @@ export async function restoreWorktreeCheckoutUnlocked(input) {
408
481
  }
409
482
  }
410
483
  // Recheck after the asynchronous fetch, including empty directories and dangling links.
411
- if (restorePathExists(worktreePath)) {
484
+ if (await restorePathExists(worktreePath)) {
412
485
  throw new WorktreeCheckoutError('path-conflict', 'The destination appeared during recovery. Move the conflicting directory before retrying.');
413
486
  }
414
487
  if (localCommit)
415
- restoreGit(projectPath, ['worktree', 'add', '--', worktreePath, workingBranch]);
488
+ await restoreGit(projectPath, ['worktree', 'add', '--', worktreePath, workingBranch]);
416
489
  else
417
- restoreGit(projectPath, ['worktree', 'add', '-b', workingBranch, '--', worktreePath, commit]);
418
- if (!isMatchingWorkspaceWorktree(input)) {
490
+ await restoreGit(projectPath, ['worktree', 'add', '-b', workingBranch, '--', worktreePath, commit]);
491
+ if (!(await isMatchingWorkspaceWorktree(input))) {
419
492
  throw new WorktreeCheckoutError('git-failed', 'The restored checkout could not be verified. Inspect the checkout before retrying.');
420
493
  }
421
- addToExclude(projectPath, worktreePath, path.join(commonGitDirectory(projectPath), 'info', 'exclude'));
422
- return { source, headCommitSha: restoreGit(worktreePath, ['rev-parse', '--verify', 'HEAD']) };
494
+ await addToExclude(projectPath, worktreePath, path.join(await commonGitDirectory(projectPath), 'info', 'exclude'));
495
+ return { source, headCommitSha: await restoreGit(worktreePath, ['rev-parse', '--verify', 'HEAD']) };
423
496
  }
424
497
  catch (err) {
425
498
  if (err instanceof WorktreeCheckoutError)