specsmd 0.0.0-dev.85 → 0.0.0-dev.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/bin/cli.js +15 -1
- package/flows/fire/agents/builder/agent.md +2 -2
- package/flows/fire/agents/builder/skills/code-review/SKILL.md +1 -1
- package/flows/fire/agents/builder/skills/run-execute/SKILL.md +16 -7
- package/flows/fire/agents/builder/skills/run-execute/scripts/complete-run.cjs +22 -3
- package/flows/fire/agents/builder/skills/run-execute/scripts/init-run.cjs +63 -20
- package/flows/fire/agents/builder/skills/run-execute/scripts/update-checkpoint.cjs +254 -0
- package/flows/fire/agents/builder/skills/run-execute/scripts/update-phase.cjs +17 -6
- package/flows/fire/agents/builder/skills/run-status/SKILL.md +1 -1
- package/flows/fire/agents/builder/skills/walkthrough-generate/SKILL.md +30 -27
- package/flows/fire/agents/orchestrator/agent.md +1 -1
- package/flows/fire/agents/orchestrator/skills/status/SKILL.md +2 -2
- package/flows/fire/memory-bank.yaml +4 -4
- package/flows/ideation/agents/orchestrator/agent.md +8 -7
- package/flows/ideation/agents/orchestrator/skills/flame/SKILL.md +1 -0
- package/flows/ideation/agents/orchestrator/skills/flame/references/evaluation-criteria.md +4 -0
- package/flows/ideation/agents/orchestrator/skills/flame/references/six-hats-method.md +12 -0
- package/flows/ideation/agents/orchestrator/skills/forge/SKILL.md +1 -0
- package/flows/ideation/agents/orchestrator/skills/forge/references/disney-method.md +8 -0
- package/flows/ideation/agents/orchestrator/skills/forge/references/pitch-framework.md +15 -0
- package/flows/ideation/agents/orchestrator/skills/spark/SKILL.md +1 -0
- package/flows/ideation/agents/orchestrator/skills/spark/references/techniques/analogy.md +7 -0
- package/flows/ideation/agents/orchestrator/skills/spark/references/techniques/first-principles.md +5 -0
- package/flows/ideation/agents/orchestrator/skills/spark/references/techniques/inversion.md +6 -0
- package/flows/ideation/agents/orchestrator/skills/spark/references/techniques/questorming.md +6 -0
- package/flows/ideation/agents/orchestrator/skills/spark/references/techniques/random-word.md +1 -0
- package/flows/ideation/agents/orchestrator/skills/spark/references/techniques/scamper.md +15 -0
- package/flows/ideation/agents/orchestrator/skills/spark/references/techniques/what-if.md +6 -0
- package/flows/ideation/shared/protocols/anti-bias.md +7 -4
- package/flows/ideation/shared/protocols/deep-thinking.md +7 -0
- package/flows/ideation/shared/protocols/diverge-converge.md +2 -0
- package/flows/ideation/shared/protocols/interaction-adaptation.md +7 -0
- package/lib/dashboard/aidlc/parser.js +581 -0
- package/lib/dashboard/fire/model.js +382 -0
- package/lib/dashboard/fire/parser.js +470 -0
- package/lib/dashboard/flow-detect.js +86 -0
- package/lib/dashboard/git/changes.js +362 -0
- package/lib/dashboard/git/worktrees.js +248 -0
- package/lib/dashboard/index.js +709 -0
- package/lib/dashboard/runtime/watch-runtime.js +122 -0
- package/lib/dashboard/simple/parser.js +293 -0
- package/lib/dashboard/tui/app.js +1675 -0
- package/lib/dashboard/tui/components/error-banner.js +35 -0
- package/lib/dashboard/tui/components/header.js +60 -0
- package/lib/dashboard/tui/components/help-footer.js +15 -0
- package/lib/dashboard/tui/components/stats-strip.js +35 -0
- package/lib/dashboard/tui/file-entries.js +383 -0
- package/lib/dashboard/tui/flow-builders.js +991 -0
- package/lib/dashboard/tui/git-builders.js +218 -0
- package/lib/dashboard/tui/helpers.js +236 -0
- package/lib/dashboard/tui/overlays.js +242 -0
- package/lib/dashboard/tui/preview.js +220 -0
- package/lib/dashboard/tui/renderer.js +76 -0
- package/lib/dashboard/tui/row-builders.js +797 -0
- package/lib/dashboard/tui/sections.js +45 -0
- package/lib/dashboard/tui/store.js +44 -0
- package/lib/dashboard/tui/views/overview-view.js +61 -0
- package/lib/dashboard/tui/views/runs-view.js +93 -0
- package/lib/dashboard/tui/worktree-builders.js +229 -0
- package/lib/installers/CodexInstaller.js +72 -1
- package/package.json +7 -3
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
function runGit(args, cwd, options = {}) {
|
|
6
|
+
const acceptedStatuses = Array.isArray(options.acceptedStatuses) && options.acceptedStatuses.length > 0
|
|
7
|
+
? options.acceptedStatuses
|
|
8
|
+
: [0];
|
|
9
|
+
const result = spawnSync('git', args, {
|
|
10
|
+
cwd,
|
|
11
|
+
encoding: 'utf8',
|
|
12
|
+
maxBuffer: 10 * 1024 * 1024
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
if (result.error) {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
error: result.error.message || String(result.error),
|
|
19
|
+
stdout: '',
|
|
20
|
+
stderr: ''
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (typeof result.status === 'number' && !acceptedStatuses.includes(result.status)) {
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
error: String(result.stderr || '').trim() || `git exited with code ${result.status}`,
|
|
28
|
+
stdout: String(result.stdout || ''),
|
|
29
|
+
stderr: String(result.stderr || '')
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
ok: true,
|
|
35
|
+
error: null,
|
|
36
|
+
stdout: String(result.stdout || ''),
|
|
37
|
+
stderr: String(result.stderr || '')
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findGitRoot(worktreePath) {
|
|
42
|
+
if (typeof worktreePath !== 'string' || worktreePath.trim() === '') {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const probe = runGit(['rev-parse', '--show-toplevel'], worktreePath);
|
|
47
|
+
if (!probe.ok) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const root = probe.stdout.trim();
|
|
52
|
+
return root === '' ? null : root;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseBranchSummary(line) {
|
|
56
|
+
const raw = String(line || '').replace(/^##\s*/, '').trim();
|
|
57
|
+
if (raw === '') {
|
|
58
|
+
return {
|
|
59
|
+
branch: '(unknown)',
|
|
60
|
+
upstream: null,
|
|
61
|
+
ahead: 0,
|
|
62
|
+
behind: 0,
|
|
63
|
+
detached: false
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (raw.startsWith('HEAD ')) {
|
|
68
|
+
return {
|
|
69
|
+
branch: '(detached)',
|
|
70
|
+
upstream: null,
|
|
71
|
+
ahead: 0,
|
|
72
|
+
behind: 0,
|
|
73
|
+
detached: true
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const [branchPart, trackingPartRaw] = raw.split(/\s+/, 2);
|
|
78
|
+
let branch = branchPart;
|
|
79
|
+
let upstream = null;
|
|
80
|
+
if (branch.includes('...')) {
|
|
81
|
+
const [name, remote] = branch.split('...', 2);
|
|
82
|
+
branch = name || '(unknown)';
|
|
83
|
+
upstream = remote || null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const trackingPart = typeof trackingPartRaw === 'string' ? trackingPartRaw : '';
|
|
87
|
+
const aheadMatch = trackingPart.match(/ahead\s+(\d+)/);
|
|
88
|
+
const behindMatch = trackingPart.match(/behind\s+(\d+)/);
|
|
89
|
+
const ahead = aheadMatch ? Number.parseInt(aheadMatch[1], 10) : 0;
|
|
90
|
+
const behind = behindMatch ? Number.parseInt(behindMatch[1], 10) : 0;
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
branch: branch || '(unknown)',
|
|
94
|
+
upstream,
|
|
95
|
+
ahead: Number.isFinite(ahead) ? ahead : 0,
|
|
96
|
+
behind: Number.isFinite(behind) ? behind : 0,
|
|
97
|
+
detached: false
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseStatusEntry(line, repoRoot) {
|
|
102
|
+
const raw = String(line || '');
|
|
103
|
+
if (raw.trim() === '' || raw.startsWith('## ')) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const code = raw.slice(0, 2);
|
|
108
|
+
const statusX = code.charAt(0);
|
|
109
|
+
const statusY = code.charAt(1);
|
|
110
|
+
const remainder = raw.length > 3 ? raw.slice(3) : '';
|
|
111
|
+
|
|
112
|
+
let relativePath = remainder.trim();
|
|
113
|
+
if (relativePath.includes(' -> ')) {
|
|
114
|
+
const parts = relativePath.split(' -> ');
|
|
115
|
+
relativePath = parts[parts.length - 1].trim();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const absolutePath = relativePath === ''
|
|
119
|
+
? ''
|
|
120
|
+
: path.join(repoRoot, relativePath);
|
|
121
|
+
const isUntracked = code === '??';
|
|
122
|
+
const isConflicted = statusX === 'U'
|
|
123
|
+
|| statusY === 'U'
|
|
124
|
+
|| code === 'AA'
|
|
125
|
+
|| code === 'DD';
|
|
126
|
+
const isStaged = !isUntracked && statusX !== ' ';
|
|
127
|
+
const isUnstaged = !isUntracked && statusY !== ' ';
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
code,
|
|
131
|
+
statusX,
|
|
132
|
+
statusY,
|
|
133
|
+
relativePath,
|
|
134
|
+
absolutePath,
|
|
135
|
+
staged: isStaged,
|
|
136
|
+
unstaged: isUnstaged,
|
|
137
|
+
untracked: isUntracked,
|
|
138
|
+
conflicted: isConflicted
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildBucketItem(entry, bucket) {
|
|
143
|
+
return {
|
|
144
|
+
key: `${bucket}:${entry.relativePath}`,
|
|
145
|
+
bucket,
|
|
146
|
+
code: entry.code,
|
|
147
|
+
path: entry.absolutePath,
|
|
148
|
+
relativePath: entry.relativePath,
|
|
149
|
+
label: entry.relativePath
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function listGitChanges(worktreePath) {
|
|
154
|
+
const gitRoot = findGitRoot(worktreePath);
|
|
155
|
+
if (!gitRoot) {
|
|
156
|
+
return {
|
|
157
|
+
available: false,
|
|
158
|
+
rootPath: null,
|
|
159
|
+
branch: '(not a git repo)',
|
|
160
|
+
upstream: null,
|
|
161
|
+
ahead: 0,
|
|
162
|
+
behind: 0,
|
|
163
|
+
detached: false,
|
|
164
|
+
clean: true,
|
|
165
|
+
counts: {
|
|
166
|
+
total: 0,
|
|
167
|
+
staged: 0,
|
|
168
|
+
unstaged: 0,
|
|
169
|
+
untracked: 0,
|
|
170
|
+
conflicted: 0
|
|
171
|
+
},
|
|
172
|
+
staged: [],
|
|
173
|
+
unstaged: [],
|
|
174
|
+
untracked: [],
|
|
175
|
+
conflicted: [],
|
|
176
|
+
generatedAt: new Date().toISOString()
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const statusResult = runGit(
|
|
181
|
+
['-c', 'color.ui=false', '-c', 'core.quotepath=false', 'status', '--porcelain', '--branch', '--untracked-files=all'],
|
|
182
|
+
gitRoot
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
if (!statusResult.ok) {
|
|
186
|
+
return {
|
|
187
|
+
available: false,
|
|
188
|
+
rootPath: gitRoot,
|
|
189
|
+
branch: '(status unavailable)',
|
|
190
|
+
upstream: null,
|
|
191
|
+
ahead: 0,
|
|
192
|
+
behind: 0,
|
|
193
|
+
detached: false,
|
|
194
|
+
clean: true,
|
|
195
|
+
counts: {
|
|
196
|
+
total: 0,
|
|
197
|
+
staged: 0,
|
|
198
|
+
unstaged: 0,
|
|
199
|
+
untracked: 0,
|
|
200
|
+
conflicted: 0
|
|
201
|
+
},
|
|
202
|
+
staged: [],
|
|
203
|
+
unstaged: [],
|
|
204
|
+
untracked: [],
|
|
205
|
+
conflicted: [],
|
|
206
|
+
error: statusResult.error,
|
|
207
|
+
generatedAt: new Date().toISOString()
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const lines = statusResult.stdout.split(/\r?\n/).filter(Boolean);
|
|
212
|
+
const branchInfo = parseBranchSummary(lines[0] || '');
|
|
213
|
+
|
|
214
|
+
const staged = [];
|
|
215
|
+
const unstaged = [];
|
|
216
|
+
const untracked = [];
|
|
217
|
+
const conflicted = [];
|
|
218
|
+
|
|
219
|
+
for (const line of lines.slice(1)) {
|
|
220
|
+
const entry = parseStatusEntry(line, gitRoot);
|
|
221
|
+
if (!entry || entry.relativePath === '') {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (entry.conflicted) {
|
|
225
|
+
conflicted.push(buildBucketItem(entry, 'conflicted'));
|
|
226
|
+
}
|
|
227
|
+
if (entry.staged) {
|
|
228
|
+
staged.push(buildBucketItem(entry, 'staged'));
|
|
229
|
+
}
|
|
230
|
+
if (entry.unstaged) {
|
|
231
|
+
unstaged.push(buildBucketItem(entry, 'unstaged'));
|
|
232
|
+
}
|
|
233
|
+
if (entry.untracked) {
|
|
234
|
+
untracked.push(buildBucketItem(entry, 'untracked'));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const uniqueCount = new Set([
|
|
239
|
+
...staged.map((item) => item.relativePath),
|
|
240
|
+
...unstaged.map((item) => item.relativePath),
|
|
241
|
+
...untracked.map((item) => item.relativePath),
|
|
242
|
+
...conflicted.map((item) => item.relativePath)
|
|
243
|
+
]).size;
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
available: true,
|
|
247
|
+
rootPath: gitRoot,
|
|
248
|
+
branch: branchInfo.branch,
|
|
249
|
+
upstream: branchInfo.upstream,
|
|
250
|
+
ahead: branchInfo.ahead,
|
|
251
|
+
behind: branchInfo.behind,
|
|
252
|
+
detached: branchInfo.detached,
|
|
253
|
+
clean: uniqueCount === 0,
|
|
254
|
+
counts: {
|
|
255
|
+
total: uniqueCount,
|
|
256
|
+
staged: staged.length,
|
|
257
|
+
unstaged: unstaged.length,
|
|
258
|
+
untracked: untracked.length,
|
|
259
|
+
conflicted: conflicted.length
|
|
260
|
+
},
|
|
261
|
+
staged,
|
|
262
|
+
unstaged,
|
|
263
|
+
untracked,
|
|
264
|
+
conflicted,
|
|
265
|
+
generatedAt: new Date().toISOString()
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function readUntrackedFileDiff(repoRoot, absolutePath) {
|
|
270
|
+
const exists = typeof absolutePath === 'string' && absolutePath !== '' && fs.existsSync(absolutePath);
|
|
271
|
+
if (!exists) {
|
|
272
|
+
return '';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const result = runGit(
|
|
276
|
+
['-c', 'color.ui=false', '--no-pager', 'diff', '--no-index', '--', '/dev/null', absolutePath],
|
|
277
|
+
repoRoot,
|
|
278
|
+
{ acceptedStatuses: [0, 1] }
|
|
279
|
+
);
|
|
280
|
+
if (!result.ok) {
|
|
281
|
+
return '';
|
|
282
|
+
}
|
|
283
|
+
return result.stdout;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function loadGitDiffPreview(changeEntry) {
|
|
287
|
+
const bucket = typeof changeEntry?.bucket === 'string' ? changeEntry.bucket : 'unstaged';
|
|
288
|
+
const repoRoot = typeof changeEntry?.repoRoot === 'string'
|
|
289
|
+
? changeEntry.repoRoot
|
|
290
|
+
: (typeof changeEntry?.workspacePath === 'string' ? findGitRoot(changeEntry.workspacePath) : null);
|
|
291
|
+
const relativePath = typeof changeEntry?.relativePath === 'string' ? changeEntry.relativePath : '';
|
|
292
|
+
const absolutePath = typeof changeEntry?.path === 'string' ? changeEntry.path : '';
|
|
293
|
+
|
|
294
|
+
if (!repoRoot) {
|
|
295
|
+
return '[git] repository is unavailable for preview.';
|
|
296
|
+
}
|
|
297
|
+
if (relativePath === '') {
|
|
298
|
+
return '[git] no file selected.';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (bucket === 'untracked') {
|
|
302
|
+
const rawDiff = readUntrackedFileDiff(repoRoot, absolutePath);
|
|
303
|
+
if (rawDiff.trim() !== '') {
|
|
304
|
+
return rawDiff;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const args = ['-c', 'color.ui=false', '--no-pager', 'diff'];
|
|
309
|
+
if (bucket === 'staged') {
|
|
310
|
+
args.push('--cached');
|
|
311
|
+
}
|
|
312
|
+
args.push('--', relativePath);
|
|
313
|
+
|
|
314
|
+
const result = runGit(args, repoRoot);
|
|
315
|
+
if (!result.ok) {
|
|
316
|
+
return `[git] unable to load diff: ${result.error}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const output = result.stdout.trim();
|
|
320
|
+
if (output === '') {
|
|
321
|
+
return '[git] no diff output for this file.';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return result.stdout;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function loadGitCommitPreview(changeEntry) {
|
|
328
|
+
const repoRoot = typeof changeEntry?.repoRoot === 'string'
|
|
329
|
+
? changeEntry.repoRoot
|
|
330
|
+
: (typeof changeEntry?.workspacePath === 'string' ? findGitRoot(changeEntry.workspacePath) : null);
|
|
331
|
+
const commitHash = typeof changeEntry?.commitHash === 'string'
|
|
332
|
+
? changeEntry.commitHash.trim()
|
|
333
|
+
: '';
|
|
334
|
+
|
|
335
|
+
if (!repoRoot) {
|
|
336
|
+
return '[git] repository is unavailable for commit preview.';
|
|
337
|
+
}
|
|
338
|
+
if (commitHash === '') {
|
|
339
|
+
return '[git] no commit selected.';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const result = runGit(
|
|
343
|
+
['-c', 'color.ui=false', '--no-pager', 'show', '--patch', '--stat', '--no-ext-diff', commitHash],
|
|
344
|
+
repoRoot
|
|
345
|
+
);
|
|
346
|
+
if (!result.ok) {
|
|
347
|
+
return `[git] unable to load commit diff: ${result.error}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const output = result.stdout.trim();
|
|
351
|
+
if (output === '') {
|
|
352
|
+
return '[git] no commit output for this selection.';
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return result.stdout;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
module.exports = {
|
|
359
|
+
listGitChanges,
|
|
360
|
+
loadGitDiffPreview,
|
|
361
|
+
loadGitCommitPreview
|
|
362
|
+
};
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { spawnSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
function normalizePath(value) {
|
|
6
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
try {
|
|
10
|
+
return path.resolve(value.trim());
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseBranchName(refLine) {
|
|
17
|
+
if (typeof refLine !== 'string' || refLine.trim() === '') {
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
const prefix = 'refs/heads/';
|
|
21
|
+
if (refLine.startsWith(prefix)) {
|
|
22
|
+
return refLine.slice(prefix.length);
|
|
23
|
+
}
|
|
24
|
+
return refLine.trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function buildWorktreeId(worktreePath) {
|
|
28
|
+
return String(worktreePath || '')
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/[^a-z0-9/_-]+/g, '-');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseGitWorktreePorcelain(rawOutput, fallbackWorkspacePath = process.cwd()) {
|
|
34
|
+
const text = String(rawOutput || '');
|
|
35
|
+
const blocks = text
|
|
36
|
+
.split(/\n\s*\n/g)
|
|
37
|
+
.map((block) => block.trim())
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
|
|
40
|
+
const worktrees = [];
|
|
41
|
+
for (const block of blocks) {
|
|
42
|
+
const lines = block.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
43
|
+
if (lines.length === 0) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const pathLine = lines.find((line) => line.startsWith('worktree '));
|
|
48
|
+
if (!pathLine) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const worktreePath = normalizePath(pathLine.slice('worktree '.length));
|
|
53
|
+
if (!worktreePath) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const branchRef = (lines.find((line) => line.startsWith('branch ')) || '').slice('branch '.length);
|
|
58
|
+
const head = (lines.find((line) => line.startsWith('HEAD ')) || '').slice('HEAD '.length);
|
|
59
|
+
const detached = lines.includes('detached');
|
|
60
|
+
const prunable = lines.some((line) => line.startsWith('prunable'));
|
|
61
|
+
const locked = lines.some((line) => line.startsWith('locked'));
|
|
62
|
+
const branch = parseBranchName(branchRef);
|
|
63
|
+
const name = path.basename(worktreePath);
|
|
64
|
+
const displayBranch = detached ? `[detached:${head.slice(0, 7) || 'unknown'}]` : (branch || '[unknown]');
|
|
65
|
+
|
|
66
|
+
worktrees.push({
|
|
67
|
+
id: buildWorktreeId(worktreePath),
|
|
68
|
+
path: worktreePath,
|
|
69
|
+
name,
|
|
70
|
+
branch,
|
|
71
|
+
displayBranch,
|
|
72
|
+
head: head || '',
|
|
73
|
+
detached,
|
|
74
|
+
prunable,
|
|
75
|
+
locked,
|
|
76
|
+
isMainBranch: branch === 'main' || branch === 'master',
|
|
77
|
+
isCurrentPath: false
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (worktrees.length === 0) {
|
|
82
|
+
const fallbackPath = normalizePath(fallbackWorkspacePath) || normalizePath(process.cwd()) || process.cwd();
|
|
83
|
+
return [{
|
|
84
|
+
id: buildWorktreeId(fallbackPath),
|
|
85
|
+
path: fallbackPath,
|
|
86
|
+
name: path.basename(fallbackPath),
|
|
87
|
+
branch: '',
|
|
88
|
+
displayBranch: '[non-git]',
|
|
89
|
+
head: '',
|
|
90
|
+
detached: false,
|
|
91
|
+
prunable: false,
|
|
92
|
+
locked: false,
|
|
93
|
+
isMainBranch: false,
|
|
94
|
+
isCurrentPath: true
|
|
95
|
+
}];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return worktrees;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function markCurrentWorktree(worktrees, workspacePath) {
|
|
102
|
+
const currentPath = normalizePath(workspacePath);
|
|
103
|
+
const safeWorktrees = Array.isArray(worktrees) ? worktrees : [];
|
|
104
|
+
const marked = safeWorktrees.map((worktree) => ({
|
|
105
|
+
...worktree,
|
|
106
|
+
isCurrentPath: currentPath != null && normalizePath(worktree.path) === currentPath
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
if (marked.some((worktree) => worktree.isCurrentPath)) {
|
|
110
|
+
return marked;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (currentPath) {
|
|
114
|
+
return marked.map((worktree, index) => ({
|
|
115
|
+
...worktree,
|
|
116
|
+
isCurrentPath: index === 0
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return marked;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sortWorktrees(worktrees) {
|
|
124
|
+
const safeWorktrees = Array.isArray(worktrees) ? [...worktrees] : [];
|
|
125
|
+
return safeWorktrees.sort((a, b) => {
|
|
126
|
+
if (a.isCurrentPath !== b.isCurrentPath) {
|
|
127
|
+
return a.isCurrentPath ? -1 : 1;
|
|
128
|
+
}
|
|
129
|
+
if (a.isMainBranch !== b.isMainBranch) {
|
|
130
|
+
return a.isMainBranch ? -1 : 1;
|
|
131
|
+
}
|
|
132
|
+
return String(a.displayBranch || a.name || '').localeCompare(String(b.displayBranch || b.name || ''));
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isGitWorkspace(workspacePath) {
|
|
137
|
+
const cwd = normalizePath(workspacePath) || process.cwd();
|
|
138
|
+
const result = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
139
|
+
cwd,
|
|
140
|
+
encoding: 'utf8'
|
|
141
|
+
});
|
|
142
|
+
if (result.error || result.status !== 0) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
return String(result.stdout || '').trim() === 'true';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function discoverGitWorktrees(workspacePath) {
|
|
149
|
+
const cwd = normalizePath(workspacePath) || process.cwd();
|
|
150
|
+
if (!isGitWorkspace(cwd)) {
|
|
151
|
+
return {
|
|
152
|
+
worktrees: markCurrentWorktree(parseGitWorktreePorcelain('', cwd), cwd),
|
|
153
|
+
source: 'fallback',
|
|
154
|
+
isGitRepo: false
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const result = spawnSync('git', ['worktree', 'list', '--porcelain'], {
|
|
159
|
+
cwd,
|
|
160
|
+
encoding: 'utf8'
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (result.error || result.status !== 0) {
|
|
164
|
+
return {
|
|
165
|
+
worktrees: markCurrentWorktree(parseGitWorktreePorcelain('', cwd), cwd),
|
|
166
|
+
source: 'fallback',
|
|
167
|
+
isGitRepo: true,
|
|
168
|
+
error: result.error ? result.error.message : String(result.stderr || '').trim()
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const parsed = parseGitWorktreePorcelain(result.stdout, cwd);
|
|
173
|
+
const marked = markCurrentWorktree(parsed, cwd);
|
|
174
|
+
return {
|
|
175
|
+
worktrees: sortWorktrees(marked),
|
|
176
|
+
source: 'git',
|
|
177
|
+
isGitRepo: true
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function pickWorktree(worktrees, selector, workspacePath) {
|
|
182
|
+
const safeWorktrees = Array.isArray(worktrees) ? worktrees : [];
|
|
183
|
+
if (safeWorktrees.length === 0) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const normalizedSelector = String(selector || '').trim();
|
|
188
|
+
const selectorPath = normalizePath(normalizedSelector);
|
|
189
|
+
const currentPath = normalizePath(workspacePath);
|
|
190
|
+
|
|
191
|
+
if (normalizedSelector !== '') {
|
|
192
|
+
const byId = safeWorktrees.find((item) => item.id === normalizedSelector);
|
|
193
|
+
if (byId) {
|
|
194
|
+
return byId;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (selectorPath) {
|
|
198
|
+
const byPath = safeWorktrees.find((item) => normalizePath(item.path) === selectorPath);
|
|
199
|
+
if (byPath) {
|
|
200
|
+
return byPath;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const byBranch = safeWorktrees.find((item) => item.branch === normalizedSelector || item.displayBranch === normalizedSelector);
|
|
205
|
+
if (byBranch) {
|
|
206
|
+
return byBranch;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const byName = safeWorktrees.find((item) => item.name === normalizedSelector);
|
|
210
|
+
if (byName) {
|
|
211
|
+
return byName;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (currentPath) {
|
|
216
|
+
const byCurrentPath = safeWorktrees.find((item) => normalizePath(item.path) === currentPath);
|
|
217
|
+
if (byCurrentPath) {
|
|
218
|
+
return byCurrentPath;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const markedCurrent = safeWorktrees.find((item) => item.isCurrentPath);
|
|
223
|
+
if (markedCurrent) {
|
|
224
|
+
return markedCurrent;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return safeWorktrees[0];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function pathExistsAsDirectory(targetPath) {
|
|
231
|
+
try {
|
|
232
|
+
return fs.statSync(targetPath).isDirectory();
|
|
233
|
+
} catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
module.exports = {
|
|
239
|
+
normalizePath,
|
|
240
|
+
parseBranchName,
|
|
241
|
+
parseGitWorktreePorcelain,
|
|
242
|
+
markCurrentWorktree,
|
|
243
|
+
sortWorktrees,
|
|
244
|
+
isGitWorkspace,
|
|
245
|
+
discoverGitWorktrees,
|
|
246
|
+
pickWorktree,
|
|
247
|
+
pathExistsAsDirectory
|
|
248
|
+
};
|