specsmd 0.1.46 → 0.1.47
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/bin/cli.js +1 -0
- package/lib/dashboard/git/worktrees.js +248 -0
- package/lib/dashboard/index.js +473 -7
- package/lib/dashboard/runtime/watch-runtime.js +18 -9
- package/lib/dashboard/tui/app.js +423 -29
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -24,6 +24,7 @@ program
|
|
|
24
24
|
.description('Live terminal dashboard for flow state (FIRE first)')
|
|
25
25
|
.option('--flow <flow>', 'Flow to inspect (fire|aidlc|simple), default auto-detect')
|
|
26
26
|
.option('--path <dir>', 'Workspace path', process.cwd())
|
|
27
|
+
.option('--worktree <nameOrPath>', 'Initial git worktree (branch name, worktree name, id, or absolute path)')
|
|
27
28
|
.option('--refresh-ms <n>', 'Fallback refresh interval in milliseconds (default: 1000)', '1000')
|
|
28
29
|
.option('--no-watch', 'Render once and exit')
|
|
29
30
|
.action((options) => dashboard.run(options));
|
|
@@ -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
|
+
};
|