@worca/ui 0.20.0 → 0.22.0

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/app/styles.css CHANGED
@@ -221,6 +221,19 @@ h1, h2, h3, h4, h5, h6 {
221
221
  opacity: 1;
222
222
  }
223
223
 
224
+ /* Loading spinner shown in sidebar nav items while underlying data is still
225
+ being fetched. Delayed fade-in avoids a noisy flash on fast loads. */
226
+ .sidebar-loading-spinner {
227
+ font-size: 14px;
228
+ --indicator-color: var(--text-secondary, currentColor);
229
+ opacity: 0;
230
+ animation: sidebar-spinner-fade 200ms ease-out 150ms forwards;
231
+ }
232
+
233
+ @keyframes sidebar-spinner-fade {
234
+ to { opacity: 0.7; }
235
+ }
236
+
224
237
  .sidebar-item.active sl-badge {
225
238
  --sl-color-primary-600: #ffffff;
226
239
  --sl-color-neutral-600: #ffffff;
@@ -4635,6 +4648,33 @@ sl-tooltip.bead-tooltip::part(body) {
4635
4648
  color: var(--muted);
4636
4649
  word-break: break-all;
4637
4650
  }
4651
+ /* Worktree card mid-cleanup state — surfaces server-side cleanup_state
4652
+ so a reload during a long bulk cleanup shows the same progress. */
4653
+ .worktree-card-cleaning {
4654
+ opacity: 0.72;
4655
+ pointer-events: none;
4656
+ }
4657
+ .worktree-card-cleaning .btn-cleanup {
4658
+ pointer-events: none;
4659
+ }
4660
+ .status-badge-cleaning {
4661
+ display: inline-flex;
4662
+ align-items: center;
4663
+ gap: 4px;
4664
+ }
4665
+ .badge-spinner,
4666
+ .btn-cleanup-spinner {
4667
+ font-size: 12px;
4668
+ }
4669
+ .worktree-card-cleanup-error {
4670
+ margin-top: 6px;
4671
+ padding: 6px 10px;
4672
+ border-radius: 6px;
4673
+ background: var(--sl-color-danger-50, #fef2f2);
4674
+ color: var(--sl-color-danger-700, #b91c1c);
4675
+ font-size: 12px;
4676
+ }
4677
+
4638
4678
  .worktrees-bulk-groups {
4639
4679
  margin: 8px 0 0;
4640
4680
  padding-left: 18px;
@@ -4650,3 +4690,167 @@ sl-tooltip.bead-tooltip::part(body) {
4650
4690
  justify-content: flex-end;
4651
4691
  width: 100%;
4652
4692
  }
4693
+
4694
+ /* ─── Models tab — model cards + env rows ──────────────────────────── */
4695
+ .models-cards {
4696
+ grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
4697
+ }
4698
+
4699
+ .model-card {
4700
+ display: flex;
4701
+ flex-direction: column;
4702
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
4703
+ }
4704
+
4705
+ .model-card.is-dirty {
4706
+ border-color: var(--status-running, #3b82f6);
4707
+ box-shadow: 0 0 0 1px var(--status-running, #3b82f6);
4708
+ }
4709
+
4710
+ .model-delete-btn {
4711
+ /* Aligned left in the footer action row via the flex layout (the status
4712
+ span between it and Discard/Save has flex:1 and grows). */
4713
+ }
4714
+
4715
+ .model-id-input::part(input) {
4716
+ font-family: var(--sl-font-mono);
4717
+ font-size: 12px;
4718
+ }
4719
+
4720
+ .settings-label-row {
4721
+ display: flex;
4722
+ align-items: baseline;
4723
+ justify-content: space-between;
4724
+ gap: 8px;
4725
+ }
4726
+
4727
+ .settings-muted-small {
4728
+ font-size: 11px;
4729
+ color: var(--muted);
4730
+ font-weight: 500;
4731
+ text-transform: uppercase;
4732
+ letter-spacing: 0.03em;
4733
+ }
4734
+
4735
+ .model-env-table {
4736
+ display: flex;
4737
+ flex-direction: column;
4738
+ gap: 6px;
4739
+ }
4740
+
4741
+ .model-env-row {
4742
+ display: grid;
4743
+ grid-template-columns: minmax(0, 240px) minmax(0, 1fr) 18px 30px;
4744
+ gap: 6px;
4745
+ align-items: center;
4746
+ }
4747
+
4748
+ .model-env-key::part(input) {
4749
+ font-family: var(--sl-font-mono);
4750
+ font-size: 12px;
4751
+ }
4752
+
4753
+ .model-env-key.is-invalid::part(base) {
4754
+ border-color: var(--status-failed, #ef4444);
4755
+ }
4756
+
4757
+ .model-env-value::part(input) {
4758
+ font-size: 12px;
4759
+ }
4760
+
4761
+ .model-env-warn {
4762
+ display: inline-flex;
4763
+ align-items: center;
4764
+ justify-content: center;
4765
+ color: var(--status-failed, #ef4444);
4766
+ font-size: 14px;
4767
+ line-height: 1;
4768
+ cursor: help;
4769
+ }
4770
+
4771
+ .model-env-warn-spacer {
4772
+ display: inline-block;
4773
+ width: 18px;
4774
+ }
4775
+
4776
+ .model-env-remove {
4777
+ color: var(--muted);
4778
+ --sl-spacing-medium: 0;
4779
+ }
4780
+
4781
+ .model-env-add-btn {
4782
+ align-self: flex-start;
4783
+ margin-top: 4px;
4784
+ }
4785
+
4786
+ .model-card-actions {
4787
+ display: flex;
4788
+ align-items: center;
4789
+ gap: 8px;
4790
+ margin-top: 14px;
4791
+ padding-top: 12px;
4792
+ border-top: 1px solid var(--border-subtle);
4793
+ }
4794
+
4795
+ .model-card-status {
4796
+ flex: 1;
4797
+ font-size: 11px;
4798
+ color: var(--muted);
4799
+ font-style: italic;
4800
+ text-align: center;
4801
+ }
4802
+
4803
+ .settings-tab-description {
4804
+ font-size: 12px;
4805
+ color: var(--muted);
4806
+ margin: 0 0 16px 0;
4807
+ line-height: 1.5;
4808
+ }
4809
+
4810
+ .settings-tab-description code {
4811
+ background: var(--bg-tertiary);
4812
+ padding: 1px 5px;
4813
+ border-radius: 3px;
4814
+ font-family: var(--sl-font-mono);
4815
+ font-size: 11px;
4816
+ color: var(--fg);
4817
+ }
4818
+
4819
+ .models-add-row {
4820
+ margin-top: 24px;
4821
+ padding-top: 16px;
4822
+ border-top: 1px solid var(--border-subtle);
4823
+ }
4824
+
4825
+ .models-add-controls {
4826
+ display: flex;
4827
+ gap: 8px;
4828
+ align-items: flex-end;
4829
+ }
4830
+
4831
+ .models-add-controls sl-input {
4832
+ flex: 1;
4833
+ }
4834
+
4835
+ /* ─── Confirmation dialog — "cannot be undone" warning row ────────── */
4836
+ .confirm-warning {
4837
+ display: flex;
4838
+ align-items: flex-start;
4839
+ gap: 8px;
4840
+ margin: 0.75rem 0 0;
4841
+ padding: 8px 10px;
4842
+ background: rgba(245, 158, 11, 0.08);
4843
+ border-left: 3px solid var(--status-paused, #f59e0b);
4844
+ border-radius: 4px;
4845
+ color: var(--fg);
4846
+ font-size: 13px;
4847
+ font-weight: 500;
4848
+ line-height: 1.4;
4849
+ }
4850
+
4851
+ .confirm-warning > span:first-child {
4852
+ color: var(--status-paused, #f59e0b);
4853
+ font-size: 15px;
4854
+ line-height: 1.2;
4855
+ flex: 0 0 auto;
4856
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -25,6 +25,7 @@
25
25
  "bin/worca-ui.js",
26
26
  "server/**/*.js",
27
27
  "server/schemas/keys.json",
28
+ "server/reserved-env-keys.json",
28
29
  "!server/**/*.test.js",
29
30
  "!server/test/**",
30
31
  "!server/**/test/**",
@@ -0,0 +1,189 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { Router } from 'express';
4
+ import { atomicWriteSync } from './atomic-write.js';
5
+ import { localPathFor } from './settings-merge.js';
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const denylist = require('./reserved-env-keys.json');
9
+ const RESERVED_KEYS = new Set(denylist.keys);
10
+ const RESERVED_PREFIXES = denylist.prefixes;
11
+
12
+ function isReservedKey(key) {
13
+ if (RESERVED_KEYS.has(key)) return true;
14
+ return RESERVED_PREFIXES.some((p) => key.startsWith(p));
15
+ }
16
+
17
+ function readJsonOr(path, fallback) {
18
+ if (!existsSync(path)) return fallback;
19
+ try {
20
+ return JSON.parse(readFileSync(path, 'utf8'));
21
+ } catch {
22
+ return fallback;
23
+ }
24
+ }
25
+
26
+ export function createModelEnvRouter({ settingsPath: staticPath } = {}) {
27
+ const router = Router({ mergeParams: true });
28
+
29
+ function resolveSettingsPath(req) {
30
+ return req.project?.settingsPath || staticPath;
31
+ }
32
+
33
+ router.put('/', (req, res) => {
34
+ const { model, id, env } = req.body || {};
35
+
36
+ if (!model || typeof model !== 'string') {
37
+ return res
38
+ .status(400)
39
+ .json({ ok: false, error: 'model name is required' });
40
+ }
41
+ if (id != null && typeof id !== 'string') {
42
+ return res.status(400).json({ ok: false, error: 'id must be a string' });
43
+ }
44
+ if (env != null && (typeof env !== 'object' || Array.isArray(env))) {
45
+ return res
46
+ .status(400)
47
+ .json({ ok: false, error: 'env must be an object' });
48
+ }
49
+
50
+ const envIn = env || {};
51
+ for (const [key, value] of Object.entries(envIn)) {
52
+ if (typeof key !== 'string' || key === '') {
53
+ return res
54
+ .status(400)
55
+ .json({ ok: false, error: 'env keys must be non-empty strings' });
56
+ }
57
+ if (isReservedKey(key)) {
58
+ return res.status(400).json({
59
+ ok: false,
60
+ key,
61
+ error: `Key "${key}" is reserved and cannot be used as a model env var`,
62
+ });
63
+ }
64
+ if (typeof value !== 'string') {
65
+ return res.status(400).json({
66
+ ok: false,
67
+ key,
68
+ error: `value for "${key}" must be a string`,
69
+ });
70
+ }
71
+ }
72
+
73
+ const settingsPath = resolveSettingsPath(req);
74
+ if (!settingsPath) {
75
+ return res
76
+ .status(501)
77
+ .json({ ok: false, error: 'settingsPath not configured' });
78
+ }
79
+
80
+ // Storage split (deliberate after the W-051 simplification):
81
+ // settings.json — public model entry: string id or { id }, NEVER env
82
+ // settings.local.json — { env } only, NEVER id
83
+ //
84
+ // Writing env to local while leaving env behind in settings.json would
85
+ // let deep-merge resurrect deleted keys (a key removed in the UI but
86
+ // still present in settings.json would reappear on next load). So PUT
87
+ // actively strips env from the settings.json entry whenever it writes
88
+ // env to local, and conversely never lets id leak into local.
89
+ const localPath = localPathFor(settingsPath);
90
+ const local = readJsonOr(localPath, {});
91
+ if (!local.worca) local.worca = {};
92
+ if (!local.worca.models) local.worca.models = {};
93
+
94
+ if (Object.keys(envIn).length === 0) {
95
+ delete local.worca.models[model];
96
+ } else {
97
+ local.worca.models[model] = { env: { ...envIn } };
98
+ }
99
+ atomicWriteSync(localPath, `${JSON.stringify(local, null, 2)}\n`);
100
+
101
+ // settings.json: keep/update id, drop env entirely. If the model
102
+ // doesn't exist there and no id was supplied, skip the file. If id is
103
+ // explicitly an empty string, treat it as "no id" and drop the entry.
104
+ const base = readJsonOr(settingsPath, {});
105
+ if (!base.worca) base.worca = {};
106
+ if (!base.worca.models) base.worca.models = {};
107
+
108
+ const baseEntry = base.worca.models[model];
109
+ let resolvedId = id;
110
+ if (resolvedId == null) {
111
+ if (typeof baseEntry === 'string') resolvedId = baseEntry;
112
+ else if (baseEntry && typeof baseEntry === 'object')
113
+ resolvedId = baseEntry.id;
114
+ }
115
+
116
+ let baseChanged = false;
117
+ if (resolvedId) {
118
+ // Prefer string form when there's no other metadata — keeps JSON minimal.
119
+ const nextBaseEntry = resolvedId;
120
+ if (JSON.stringify(baseEntry) !== JSON.stringify(nextBaseEntry)) {
121
+ base.worca.models[model] = nextBaseEntry;
122
+ baseChanged = true;
123
+ }
124
+ } else if (baseEntry !== undefined) {
125
+ delete base.worca.models[model];
126
+ baseChanged = true;
127
+ }
128
+ if (baseChanged) {
129
+ atomicWriteSync(settingsPath, `${JSON.stringify(base, null, 2)}\n`);
130
+ }
131
+
132
+ res.json({ ok: true, model, id: resolvedId || null, env: { ...envIn } });
133
+ });
134
+
135
+ router.delete('/', (req, res) => {
136
+ const model =
137
+ req.body?.model ||
138
+ (typeof req.query?.model === 'string' ? req.query.model : null);
139
+
140
+ if (!model) {
141
+ return res
142
+ .status(400)
143
+ .json({ ok: false, error: 'model name is required' });
144
+ }
145
+
146
+ const settingsPath = resolveSettingsPath(req);
147
+ if (!settingsPath) {
148
+ return res
149
+ .status(501)
150
+ .json({ ok: false, error: 'settingsPath not configured' });
151
+ }
152
+
153
+ // Remove from BOTH files so deep-merge can't resurrect the entry. The
154
+ // settings POST endpoint deep-merges and cannot remove a key, so we
155
+ // operate on disk directly. This is intentional — "Delete model" in
156
+ // the UI means the model goes away, full stop.
157
+ let removedFromBase = false;
158
+ let removedFromLocal = false;
159
+
160
+ if (existsSync(settingsPath)) {
161
+ const base = readJsonOr(settingsPath, {});
162
+ if (base?.worca?.models && model in base.worca.models) {
163
+ delete base.worca.models[model];
164
+ atomicWriteSync(settingsPath, `${JSON.stringify(base, null, 2)}\n`);
165
+ removedFromBase = true;
166
+ }
167
+ }
168
+
169
+ const localPath = localPathFor(settingsPath);
170
+ if (existsSync(localPath)) {
171
+ const local = readJsonOr(localPath, {});
172
+ if (local?.worca?.models && model in local.worca.models) {
173
+ delete local.worca.models[model];
174
+ atomicWriteSync(localPath, `${JSON.stringify(local, null, 2)}\n`);
175
+ removedFromLocal = true;
176
+ }
177
+ }
178
+
179
+ res.json({
180
+ ok: true,
181
+ model,
182
+ removed: removedFromBase || removedFromLocal,
183
+ fromBase: removedFromBase,
184
+ fromLocal: removedFromLocal,
185
+ });
186
+ });
187
+
188
+ return router;
189
+ }
@@ -0,0 +1,13 @@
1
+ const DEFAULT_MODELS = ['opus', 'sonnet', 'haiku'];
2
+
3
+ export function deriveValidModels(worcaObj) {
4
+ const configuredModels =
5
+ worcaObj?.models &&
6
+ typeof worcaObj.models === 'object' &&
7
+ !Array.isArray(worcaObj.models)
8
+ ? Object.keys(worcaObj.models)
9
+ : [];
10
+ return [...new Set([...DEFAULT_MODELS, ...configuredModels])];
11
+ }
12
+
13
+ export { DEFAULT_MODELS };
@@ -1,9 +1,9 @@
1
1
  import { join } from 'node:path';
2
2
  import { Router } from 'express';
3
+ import { deriveValidModels } from './model-validation.js';
3
4
  import { readGlobalSettings, writeGlobalSettings } from './settings-reader.js';
4
5
 
5
6
  const VALID_CLEANUP_POLICIES = ['never', 'on-success', 'manual-only'];
6
- const VALID_MODELS = ['opus', 'sonnet', 'haiku'];
7
7
  const MIN_DISK_BYTES = 500_000_000;
8
8
  const MAX_DISK_BYTES = 50_000_000_000;
9
9
 
@@ -73,9 +73,10 @@ export function validateGlobalSettingsPayload(body) {
73
73
  ) {
74
74
  details.push('worca.circuit_breaker must be an object');
75
75
  } else if (w.circuit_breaker.classifier_model !== undefined) {
76
- if (!VALID_MODELS.includes(w.circuit_breaker.classifier_model)) {
76
+ const validModels = deriveValidModels(w);
77
+ if (!validModels.includes(w.circuit_breaker.classifier_model)) {
77
78
  details.push(
78
- `classifier_model must be one of: ${VALID_MODELS.join(', ')}`,
79
+ `classifier_model must be one of: ${validModels.join(', ')}`,
79
80
  );
80
81
  }
81
82
  }
@@ -27,6 +27,7 @@ import { dispatchExternal } from './dispatch-external.js';
27
27
  import { ensureWebhookForUi } from './ensure-webhook.js';
28
28
  import { extractAndStripGlobalKeys } from './global-keys.js';
29
29
  import { LaunchLock } from './launch-lock.js';
30
+ import { createModelEnvRouter } from './model-env-routes.js';
30
31
  import { readPreferences } from './preferences.js';
31
32
  import { ProcessManager } from './process-manager.js';
32
33
  import { countRunningPipelinesAcrossProjects } from './process-registry.js';
@@ -430,6 +431,9 @@ export function createProjectScopedRoutes({
430
431
  res.json({ ok: true, files });
431
432
  });
432
433
 
434
+ // --- Model env endpoints (writes wholesale to settings.local.json) ---
435
+ router.use('/settings/model-env', createModelEnvRouter());
436
+
433
437
  // --- Project-scoped settings endpoints ---
434
438
 
435
439
  // GET /api/projects/:projectId/settings
@@ -578,6 +582,7 @@ export function createProjectScopedRoutes({
578
582
  // DELETE /api/projects/:projectId/settings/:section
579
583
  const SECTION_KEYS = {
580
584
  agents: { worca: ['agents'] },
585
+ models: { worca: ['models'] },
581
586
  pipeline: { worca: ['stages', 'loops', 'plan_path_template', 'defaults'] },
582
587
  governance: { worca: ['governance'], top: ['permissions'] },
583
588
  pricing: { worca: ['pricing'] },
@@ -0,0 +1,19 @@
1
+ {
2
+ "keys": [
3
+ "PATH",
4
+ "CLAUDECODE",
5
+ "WORCA_AGENT",
6
+ "WORCA_PROJECT_ROOT",
7
+ "WORCA_RUN_ID",
8
+ "WORCA_RUN_DIR",
9
+ "WORCA_PLAN_FILE",
10
+ "WORCA_EVENTS_PATH",
11
+ "WORCA_TARGET_BRANCH",
12
+ "WORCA_COVERAGE",
13
+ "WORCA_SKIP_BEADS",
14
+ "WORCA_CLAUDE_BIN"
15
+ ],
16
+ "prefixes": [
17
+ "WORCA_"
18
+ ]
19
+ }
@@ -1,6 +1,7 @@
1
1
  // server/settings-validator.js
2
2
  import { STAGE_ORDER } from '../app/utils/stage-order.js';
3
3
  import { GLOBAL_ONLY_KEYS } from './keys-schema.js';
4
+ import { DEFAULT_MODELS, deriveValidModels } from './model-validation.js';
4
5
 
5
6
  const VALID_AGENTS = [
6
7
  'planner',
@@ -13,7 +14,7 @@ const VALID_AGENTS = [
13
14
  'learner',
14
15
  ];
15
16
  const VALID_STAGES = STAGE_ORDER;
16
- export const VALID_MODELS = ['opus', 'sonnet', 'haiku'];
17
+ export const VALID_MODELS = DEFAULT_MODELS;
17
18
  const VALID_LOOPS = [
18
19
  'implement_test',
19
20
  'pr_changes',
@@ -27,7 +28,7 @@ const VALID_GUARDS = [
27
28
  'block_force_push',
28
29
  'restrict_git_commit',
29
30
  ];
30
- const VALID_PRICING_MODELS = ['opus', 'sonnet'];
31
+ const DEFAULT_PRICING_MODELS = ['opus', 'sonnet'];
31
32
  const VALID_PRICING_FIELDS = [
32
33
  'input_per_mtok',
33
34
  'output_per_mtok',
@@ -48,6 +49,7 @@ export function validateSettingsPayload(body) {
48
49
  return { valid: false, details };
49
50
  }
50
51
  const w = body.worca;
52
+ const validModels = deriveValidModels(w);
51
53
 
52
54
  // agents
53
55
  if (w.agents !== undefined) {
@@ -63,7 +65,7 @@ export function validateSettingsPayload(body) {
63
65
  details.push(`Unknown agent name: "${name}"`);
64
66
  continue;
65
67
  }
66
- if (cfg.model !== undefined && !VALID_MODELS.includes(cfg.model)) {
68
+ if (cfg.model !== undefined && !validModels.includes(cfg.model)) {
67
69
  details.push(`Invalid model "${cfg.model}" for agent "${name}"`);
68
70
  }
69
71
  if (cfg.max_turns !== undefined) {
@@ -192,6 +194,9 @@ export function validateSettingsPayload(body) {
192
194
  details.push('pricing must be an object');
193
195
  } else {
194
196
  const p = w.pricing;
197
+ const validPricingModels = [
198
+ ...new Set([...DEFAULT_PRICING_MODELS, ...validModels]),
199
+ ];
195
200
  if (p.models !== undefined) {
196
201
  if (
197
202
  typeof p.models !== 'object' ||
@@ -201,7 +206,7 @@ export function validateSettingsPayload(body) {
201
206
  details.push('pricing.models must be an object');
202
207
  } else {
203
208
  for (const [model, costs] of Object.entries(p.models)) {
204
- if (!VALID_PRICING_MODELS.includes(model)) {
209
+ if (!validPricingModels.includes(model)) {
205
210
  details.push(`Unknown pricing model: "${model}"`);
206
211
  continue;
207
212
  }
@@ -864,12 +869,13 @@ export function validateGlobalSettings(prefs) {
864
869
  }
865
870
  }
866
871
 
872
+ const globalValidModels = deriveValidModels(w);
867
873
  if (
868
874
  w.circuit_breaker?.classifier_model !== undefined &&
869
- !VALID_MODELS.includes(w.circuit_breaker.classifier_model)
875
+ !globalValidModels.includes(w.circuit_breaker.classifier_model)
870
876
  ) {
871
877
  details.push(
872
- `circuit_breaker.classifier_model must be one of: ${VALID_MODELS.join(', ')}`,
878
+ `circuit_breaker.classifier_model must be one of: ${globalValidModels.join(', ')}`,
873
879
  );
874
880
  }
875
881
 
@@ -2,33 +2,37 @@
2
2
  * Shared worktree operations — single owner of `git worktree remove` shell-out.
3
3
  */
4
4
 
5
- import { execFileSync } from 'node:child_process';
6
- import {
7
- existsSync,
8
- lstatSync,
9
- readFileSync,
10
- rmSync,
11
- unlinkSync,
12
- } from 'node:fs';
5
+ import { execFile } from 'node:child_process';
6
+ import { existsSync, lstatSync } from 'node:fs';
7
+ import { readFile, rm, unlink } from 'node:fs/promises';
13
8
  import { join } from 'node:path';
9
+ import { promisify } from 'node:util';
10
+
11
+ const execFileAsync = promisify(execFile);
14
12
 
15
13
  /**
16
14
  * Remove a worktree and its registry entry.
17
15
  * Mirrors WorktreeSource.remove from src/worca/cli/cleanup.py:
18
16
  * 1. Attempt `git worktree remove --force <path>` from the project root
19
- * 2. On failure (e.g. non-worktree temp dir in tests), fall back to rmSync
17
+ * 2. On failure (e.g. non-worktree temp dir in tests), fall back to rm (async)
20
18
  * 3. Run `git worktree prune` so git's metadata (`.git/worktrees/<id>/`)
21
19
  * drops the entry even when the directory was removed manually
20
+ * (skipped when skipPrune is true — caller is responsible for pruning later)
22
21
  * 4. Delete the registry file
23
22
  */
24
- export function removeWorktree(worcaDir, runId) {
23
+ export async function removeWorktree(
24
+ worcaDir,
25
+ runId,
26
+ { skipPrune = false } = {},
27
+ ) {
25
28
  const regFile = join(worcaDir, 'multi', 'pipelines.d', `${runId}.json`);
26
29
  const projectRoot = join(worcaDir, '..');
27
30
  let worktreePath = null;
28
31
 
29
32
  if (existsSync(regFile)) {
30
33
  try {
31
- const reg = JSON.parse(readFileSync(regFile, 'utf8'));
34
+ const content = await readFile(regFile, 'utf8');
35
+ const reg = JSON.parse(content);
32
36
  worktreePath = reg.worktree_path || null;
33
37
  } catch {
34
38
  /* ignore */
@@ -37,11 +41,15 @@ export function removeWorktree(worcaDir, runId) {
37
41
 
38
42
  if (worktreePath && existsSync(worktreePath)) {
39
43
  try {
40
- execFileSync('git', ['worktree', 'remove', '--force', worktreePath], {
41
- cwd: projectRoot,
42
- stdio: 'pipe',
43
- timeout: 30_000,
44
- });
44
+ await execFileAsync(
45
+ 'git',
46
+ ['worktree', 'remove', '--force', worktreePath],
47
+ {
48
+ cwd: projectRoot,
49
+ stdio: 'pipe',
50
+ timeout: 30_000,
51
+ },
52
+ );
45
53
  } catch {
46
54
  let isRealDir = false;
47
55
  try {
@@ -51,13 +59,36 @@ export function removeWorktree(worcaDir, runId) {
51
59
  /* ignore */
52
60
  }
53
61
  if (isRealDir) {
54
- rmSync(worktreePath, { recursive: true, force: true });
62
+ await rm(worktreePath, { recursive: true, force: true });
55
63
  }
56
64
  }
57
65
  }
58
66
 
67
+ if (!skipPrune) {
68
+ try {
69
+ await execFileAsync('git', ['worktree', 'prune'], {
70
+ cwd: projectRoot,
71
+ stdio: 'pipe',
72
+ timeout: 30_000,
73
+ });
74
+ } catch {
75
+ /* non-fatal */
76
+ }
77
+ }
78
+
79
+ if (existsSync(regFile)) {
80
+ await unlink(regFile);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Run `git worktree prune` once for the project at worcaDir.
86
+ * Use after a batch of removeWorktree({ skipPrune: true }) calls.
87
+ */
88
+ export async function pruneWorktrees(worcaDir) {
89
+ const projectRoot = join(worcaDir, '..');
59
90
  try {
60
- execFileSync('git', ['worktree', 'prune'], {
91
+ await execFileAsync('git', ['worktree', 'prune'], {
61
92
  cwd: projectRoot,
62
93
  stdio: 'pipe',
63
94
  timeout: 30_000,
@@ -65,8 +96,4 @@ export function removeWorktree(worcaDir, runId) {
65
96
  } catch {
66
97
  /* non-fatal */
67
98
  }
68
-
69
- if (existsSync(regFile)) {
70
- unlinkSync(regFile);
71
- }
72
99
  }