feinai 0.5.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/CHANGELOG.md +41 -0
- package/README.md +230 -0
- package/package.json +60 -0
- package/skills/feinai-dispatch/SKILL.md +233 -0
- package/skills/feinai-implement/SKILL.md +133 -0
- package/skills/feinai-sdd/SKILL.md +291 -0
- package/skills/feinai-write-spec/SKILL.md +178 -0
- package/skills/feinai-write-tasks/SKILL.md +183 -0
- package/src/agents-status.ts +26 -0
- package/src/cli.ts +885 -0
- package/src/dashboard.html +1701 -0
- package/src/dashboard.ts +3 -0
- package/src/db.ts +221 -0
- package/src/format.ts +166 -0
- package/src/opengit.sh +117 -0
- package/src/server.ts +749 -0
- package/src/specs.ts +289 -0
- package/src/sqlite-adapter.ts +130 -0
- package/src/tasks.ts +415 -0
- package/src/worktree-status.ts +97 -0
package/src/tasks.ts
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import type { DbInstance } from "./db";
|
|
2
|
+
import { recordEvent } from "./db";
|
|
3
|
+
import { getSpec, doneSpec, startSpec } from "./specs";
|
|
4
|
+
|
|
5
|
+
export type TaskStatus = "pending" | "in_progress" | "completed" | "failed" | "deleted";
|
|
6
|
+
|
|
7
|
+
export interface Task {
|
|
8
|
+
id: string;
|
|
9
|
+
spec_id: string | null;
|
|
10
|
+
subject: string;
|
|
11
|
+
description: string | null;
|
|
12
|
+
status: TaskStatus;
|
|
13
|
+
owner: string | null;
|
|
14
|
+
worktree: string | null;
|
|
15
|
+
blocked_by: string[];
|
|
16
|
+
packages: string[];
|
|
17
|
+
quality_gates: string[];
|
|
18
|
+
result: string | null;
|
|
19
|
+
error: string | null;
|
|
20
|
+
taken_at: string | null;
|
|
21
|
+
completed_at: string | null;
|
|
22
|
+
created_at: string;
|
|
23
|
+
updated_at: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface TaskRow {
|
|
27
|
+
id: string;
|
|
28
|
+
spec_id: string | null;
|
|
29
|
+
subject: string;
|
|
30
|
+
description: string | null;
|
|
31
|
+
status: TaskStatus;
|
|
32
|
+
owner: string | null;
|
|
33
|
+
worktree: string | null;
|
|
34
|
+
blocked_by: string;
|
|
35
|
+
packages: string;
|
|
36
|
+
quality_gates: string;
|
|
37
|
+
result: string | null;
|
|
38
|
+
error: string | null;
|
|
39
|
+
taken_at: string | null;
|
|
40
|
+
completed_at: string | null;
|
|
41
|
+
created_at: string;
|
|
42
|
+
updated_at: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function rowToTask(row: TaskRow): Task {
|
|
46
|
+
return {
|
|
47
|
+
...row,
|
|
48
|
+
blocked_by: JSON.parse(row.blocked_by),
|
|
49
|
+
packages: JSON.parse(row.packages),
|
|
50
|
+
quality_gates: JSON.parse(row.quality_gates),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ListFilter {
|
|
55
|
+
status?: TaskStatus;
|
|
56
|
+
spec_id?: string;
|
|
57
|
+
owner?: string;
|
|
58
|
+
pending?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function listTasks(db: DbInstance, filter: ListFilter = {}): Task[] {
|
|
62
|
+
const where: string[] = [];
|
|
63
|
+
const args: (string | number)[] = [];
|
|
64
|
+
|
|
65
|
+
if (filter.status) {
|
|
66
|
+
where.push("status = ?");
|
|
67
|
+
args.push(filter.status);
|
|
68
|
+
}
|
|
69
|
+
if (filter.pending) {
|
|
70
|
+
where.push("status = ?");
|
|
71
|
+
args.push("pending");
|
|
72
|
+
}
|
|
73
|
+
if (filter.spec_id) {
|
|
74
|
+
where.push("spec_id = ?");
|
|
75
|
+
args.push(filter.spec_id);
|
|
76
|
+
}
|
|
77
|
+
if (filter.owner) {
|
|
78
|
+
where.push("owner = ?");
|
|
79
|
+
args.push(filter.owner);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Exclude tasks from archived specs so archived work leaves the active task list
|
|
83
|
+
where.push("(spec_id IS NULL OR spec_id NOT IN (SELECT id FROM specs WHERE status = 'archivada'))");
|
|
84
|
+
|
|
85
|
+
const sql =
|
|
86
|
+
`SELECT * FROM tasks` +
|
|
87
|
+
(where.length ? ` WHERE ${where.join(" AND ")}` : "") +
|
|
88
|
+
` ORDER BY id ASC`;
|
|
89
|
+
|
|
90
|
+
const rows = db.prepare(sql).all(...args) as TaskRow[];
|
|
91
|
+
return rows.map(rowToTask);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getTask(db: DbInstance, id: string): Task | null {
|
|
95
|
+
const row = db
|
|
96
|
+
.prepare("SELECT * FROM tasks WHERE id = ?")
|
|
97
|
+
.get(id) as TaskRow | null;
|
|
98
|
+
return row ? rowToTask(row) : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface AddTaskInput {
|
|
102
|
+
id: string;
|
|
103
|
+
subject: string;
|
|
104
|
+
description?: string;
|
|
105
|
+
spec_id?: string;
|
|
106
|
+
packages?: string[];
|
|
107
|
+
quality_gates?: string[];
|
|
108
|
+
blocked_by?: string[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function addTask(db: DbInstance, input: AddTaskInput): Task {
|
|
112
|
+
db.prepare(
|
|
113
|
+
`INSERT INTO tasks (id, spec_id, subject, description, packages, quality_gates, blocked_by)
|
|
114
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
115
|
+
).run(
|
|
116
|
+
input.id,
|
|
117
|
+
input.spec_id ?? null,
|
|
118
|
+
input.subject,
|
|
119
|
+
input.description ?? null,
|
|
120
|
+
JSON.stringify(input.packages ?? []),
|
|
121
|
+
JSON.stringify(input.quality_gates ?? []),
|
|
122
|
+
JSON.stringify(input.blocked_by ?? []),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
recordEvent(db, "task", input.id, "created", { subject: input.subject });
|
|
126
|
+
|
|
127
|
+
const task = getTask(db, input.id);
|
|
128
|
+
if (!task) throw new Error(`Failed to retrieve newly created task ${input.id}`);
|
|
129
|
+
return task;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Take a task atomically: update status only if currently 'pending'.
|
|
134
|
+
* Returns the task with all info needed for execution.
|
|
135
|
+
* Throws if task doesn't exist or is not pending.
|
|
136
|
+
*/
|
|
137
|
+
export function takeTask(
|
|
138
|
+
db: DbInstance,
|
|
139
|
+
id: string,
|
|
140
|
+
owner: string,
|
|
141
|
+
): Task {
|
|
142
|
+
const result = db
|
|
143
|
+
.prepare(
|
|
144
|
+
`UPDATE tasks
|
|
145
|
+
SET status = 'in_progress',
|
|
146
|
+
owner = ?,
|
|
147
|
+
taken_at = datetime('now'),
|
|
148
|
+
updated_at = datetime('now')
|
|
149
|
+
WHERE id = ? AND status = 'pending'`,
|
|
150
|
+
)
|
|
151
|
+
.run(owner, id);
|
|
152
|
+
|
|
153
|
+
if (result.changes === 0) {
|
|
154
|
+
const existing = getTask(db, id);
|
|
155
|
+
if (!existing) throw new Error(`Task ${id} not found`);
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Task ${id} cannot be taken (status: ${existing.status}, owner: ${existing.owner ?? "none"})`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const task = getTask(db, id);
|
|
162
|
+
if (!task) throw new Error(`Task ${id} disappeared after take`);
|
|
163
|
+
|
|
164
|
+
if (task.spec_id) {
|
|
165
|
+
const spec = getSpec(db, task.spec_id);
|
|
166
|
+
if (spec && spec.status === 'lista') {
|
|
167
|
+
startSpec(db, task.spec_id, owner);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
recordEvent(db, "task", id, "taken", { owner }, owner);
|
|
172
|
+
return task;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function doneTask(
|
|
176
|
+
db: DbInstance,
|
|
177
|
+
id: string,
|
|
178
|
+
result: string,
|
|
179
|
+
actor: string | null = null,
|
|
180
|
+
): Task {
|
|
181
|
+
const upd = db
|
|
182
|
+
.prepare(
|
|
183
|
+
`UPDATE tasks
|
|
184
|
+
SET status = 'completed',
|
|
185
|
+
result = ?,
|
|
186
|
+
worktree = NULL,
|
|
187
|
+
completed_at = datetime('now'),
|
|
188
|
+
updated_at = datetime('now')
|
|
189
|
+
WHERE id = ? AND status = 'in_progress'`,
|
|
190
|
+
)
|
|
191
|
+
.run(result, id);
|
|
192
|
+
|
|
193
|
+
if (upd.changes === 0) {
|
|
194
|
+
const existing = getTask(db, id);
|
|
195
|
+
if (!existing) throw new Error(`Task ${id} not found`);
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Task ${id} cannot be marked done (status: ${existing.status})`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const task = getTask(db, id);
|
|
202
|
+
if (!task) throw new Error(`Task ${id} disappeared after done`);
|
|
203
|
+
|
|
204
|
+
if (task.spec_id) {
|
|
205
|
+
const remaining = db
|
|
206
|
+
.prepare(
|
|
207
|
+
`SELECT COUNT(*) AS count FROM tasks
|
|
208
|
+
WHERE spec_id = ? AND status IN ('pending', 'in_progress')`,
|
|
209
|
+
)
|
|
210
|
+
.get(task.spec_id) as { count: number };
|
|
211
|
+
|
|
212
|
+
if (remaining.count === 0) {
|
|
213
|
+
doneSpec(db, task.spec_id, {}, actor);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
recordEvent(db, "task", id, "completed", { result }, actor);
|
|
218
|
+
return task;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function failTask(
|
|
222
|
+
db: DbInstance,
|
|
223
|
+
id: string,
|
|
224
|
+
error: string,
|
|
225
|
+
actor: string | null = null,
|
|
226
|
+
): Task {
|
|
227
|
+
const upd = db
|
|
228
|
+
.prepare(
|
|
229
|
+
`UPDATE tasks
|
|
230
|
+
SET status = 'failed',
|
|
231
|
+
error = ?,
|
|
232
|
+
updated_at = datetime('now')
|
|
233
|
+
WHERE id = ? AND status = 'in_progress'`,
|
|
234
|
+
)
|
|
235
|
+
.run(error, id);
|
|
236
|
+
|
|
237
|
+
if (upd.changes === 0) {
|
|
238
|
+
const existing = getTask(db, id);
|
|
239
|
+
if (!existing) throw new Error(`Task ${id} not found`);
|
|
240
|
+
throw new Error(`Task ${id} cannot be failed (status: ${existing.status})`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
recordEvent(db, "task", id, "failed", { error }, actor);
|
|
244
|
+
|
|
245
|
+
const task = getTask(db, id);
|
|
246
|
+
if (!task) throw new Error(`Task ${id} disappeared after fail`);
|
|
247
|
+
return task;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function releaseTask(
|
|
251
|
+
db: DbInstance,
|
|
252
|
+
id: string,
|
|
253
|
+
actor: string | null = null,
|
|
254
|
+
): Task {
|
|
255
|
+
const upd = db
|
|
256
|
+
.prepare(
|
|
257
|
+
`UPDATE tasks
|
|
258
|
+
SET status = 'pending',
|
|
259
|
+
owner = NULL,
|
|
260
|
+
worktree = NULL,
|
|
261
|
+
taken_at = NULL,
|
|
262
|
+
updated_at = datetime('now')
|
|
263
|
+
WHERE id = ? AND status = 'in_progress'`,
|
|
264
|
+
)
|
|
265
|
+
.run(id);
|
|
266
|
+
|
|
267
|
+
if (upd.changes === 0) {
|
|
268
|
+
const existing = getTask(db, id);
|
|
269
|
+
if (!existing) throw new Error(`Task ${id} not found`);
|
|
270
|
+
throw new Error(`Task ${id} cannot be released (status: ${existing.status})`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
recordEvent(db, "task", id, "released", {}, actor);
|
|
274
|
+
|
|
275
|
+
const task = getTask(db, id);
|
|
276
|
+
if (!task) throw new Error(`Task ${id} disappeared after release`);
|
|
277
|
+
return task;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function reopenTask(
|
|
281
|
+
db: DbInstance,
|
|
282
|
+
id: string,
|
|
283
|
+
actor: string | null = null,
|
|
284
|
+
): Task {
|
|
285
|
+
const upd = db
|
|
286
|
+
.prepare(
|
|
287
|
+
`UPDATE tasks
|
|
288
|
+
SET status = 'pending',
|
|
289
|
+
updated_at = datetime('now')
|
|
290
|
+
WHERE id = ? AND status IN ('completed', 'failed')`,
|
|
291
|
+
)
|
|
292
|
+
.run(id);
|
|
293
|
+
|
|
294
|
+
if (upd.changes === 0) {
|
|
295
|
+
const existing = getTask(db, id);
|
|
296
|
+
if (!existing) throw new Error(`Task ${id} not found`);
|
|
297
|
+
throw new Error(`Task ${id} cannot be reopened (status: ${existing.status})`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
recordEvent(db, "task", id, "reopened", {}, actor);
|
|
301
|
+
|
|
302
|
+
const task = getTask(db, id);
|
|
303
|
+
if (!task) throw new Error(`Task ${id} disappeared after reopen`);
|
|
304
|
+
return task;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export interface EditTaskInput {
|
|
308
|
+
subject?: string;
|
|
309
|
+
description?: string;
|
|
310
|
+
packages?: string[];
|
|
311
|
+
quality_gates?: string[];
|
|
312
|
+
worktree?: string | null;
|
|
313
|
+
blocked_by?: string[];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function editTask(
|
|
317
|
+
db: DbInstance,
|
|
318
|
+
id: string,
|
|
319
|
+
input: EditTaskInput,
|
|
320
|
+
actor: string | null = null,
|
|
321
|
+
): Task {
|
|
322
|
+
if (
|
|
323
|
+
input.subject === undefined &&
|
|
324
|
+
input.description === undefined &&
|
|
325
|
+
input.packages === undefined &&
|
|
326
|
+
input.quality_gates === undefined &&
|
|
327
|
+
input.blocked_by === undefined
|
|
328
|
+
) {
|
|
329
|
+
throw new Error('editTask: provide at least one field to edit');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const task = getTask(db, id);
|
|
333
|
+
if (!task) throw new Error(`Task ${id} not found`);
|
|
334
|
+
|
|
335
|
+
const sets: string[] = ["updated_at = datetime('now')"];
|
|
336
|
+
const args: (string | null)[] = [];
|
|
337
|
+
const changed: Record<string, unknown> = {};
|
|
338
|
+
|
|
339
|
+
if (input.subject !== undefined) {
|
|
340
|
+
sets.push('subject = ?');
|
|
341
|
+
args.push(input.subject);
|
|
342
|
+
changed.subject = input.subject;
|
|
343
|
+
}
|
|
344
|
+
if (input.description !== undefined) {
|
|
345
|
+
sets.push('description = ?');
|
|
346
|
+
args.push(input.description);
|
|
347
|
+
changed.description = '(updated)'; // no loguear el body completo en events
|
|
348
|
+
}
|
|
349
|
+
if (input.packages !== undefined) {
|
|
350
|
+
sets.push('packages = ?');
|
|
351
|
+
args.push(JSON.stringify(input.packages));
|
|
352
|
+
changed.packages = input.packages;
|
|
353
|
+
}
|
|
354
|
+
if (input.quality_gates !== undefined) {
|
|
355
|
+
sets.push('quality_gates = ?');
|
|
356
|
+
args.push(JSON.stringify(input.quality_gates));
|
|
357
|
+
changed.quality_gates = input.quality_gates;
|
|
358
|
+
}
|
|
359
|
+
if ('worktree' in input) {
|
|
360
|
+
sets.push('worktree = ?');
|
|
361
|
+
args.push(input.worktree ?? null);
|
|
362
|
+
changed.worktree = input.worktree ?? null;
|
|
363
|
+
}
|
|
364
|
+
if (input.blocked_by !== undefined) {
|
|
365
|
+
sets.push('blocked_by = ?');
|
|
366
|
+
args.push(JSON.stringify(input.blocked_by));
|
|
367
|
+
changed.blocked_by = input.blocked_by;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
args.push(id);
|
|
371
|
+
db.prepare(`UPDATE tasks SET ${sets.join(', ')} WHERE id = ?`).run(...args);
|
|
372
|
+
recordEvent(db, 'task', id, 'edited', changed, actor);
|
|
373
|
+
|
|
374
|
+
return getTask(db, id) as Task;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function blockTask(
|
|
378
|
+
db: DbInstance,
|
|
379
|
+
id: string,
|
|
380
|
+
blockedBy: string,
|
|
381
|
+
): Task {
|
|
382
|
+
const task = getTask(db, id);
|
|
383
|
+
if (!task) throw new Error(`Task ${id} not found`);
|
|
384
|
+
|
|
385
|
+
const blockedSet = new Set(task.blocked_by);
|
|
386
|
+
blockedSet.add(blockedBy);
|
|
387
|
+
const blockedArr = Array.from(blockedSet);
|
|
388
|
+
|
|
389
|
+
db.prepare(
|
|
390
|
+
`UPDATE tasks SET blocked_by = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
391
|
+
).run(JSON.stringify(blockedArr), id);
|
|
392
|
+
|
|
393
|
+
recordEvent(db, "task", id, "blocked", { blocked_by: blockedBy });
|
|
394
|
+
|
|
395
|
+
return getTask(db, id) as Task;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function unblockTask(
|
|
399
|
+
db: DbInstance,
|
|
400
|
+
id: string,
|
|
401
|
+
depId: string,
|
|
402
|
+
): Task {
|
|
403
|
+
const task = getTask(db, id);
|
|
404
|
+
if (!task) throw new Error(`Task ${id} not found`);
|
|
405
|
+
|
|
406
|
+
const newDeps = task.blocked_by.filter((bid) => bid !== depId);
|
|
407
|
+
|
|
408
|
+
db.prepare(
|
|
409
|
+
`UPDATE tasks SET blocked_by = ?, updated_at = datetime('now') WHERE id = ?`,
|
|
410
|
+
).run(JSON.stringify(newDeps), id);
|
|
411
|
+
|
|
412
|
+
recordEvent(db, "task", id, "unblocked", { removed: depId });
|
|
413
|
+
|
|
414
|
+
return getTask(db, id) as Task;
|
|
415
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface WorktreeStatus {
|
|
5
|
+
exists: boolean;
|
|
6
|
+
merged: boolean;
|
|
7
|
+
gitClean: boolean;
|
|
8
|
+
files: string[];
|
|
9
|
+
lastCommit: string | null;
|
|
10
|
+
branch: string | null;
|
|
11
|
+
error: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function inspectWorktree(worktreePath: string | null): Promise<WorktreeStatus> {
|
|
15
|
+
if (!worktreePath) {
|
|
16
|
+
return {
|
|
17
|
+
exists: false,
|
|
18
|
+
merged: false,
|
|
19
|
+
gitClean: false,
|
|
20
|
+
files: [],
|
|
21
|
+
lastCommit: null,
|
|
22
|
+
branch: null,
|
|
23
|
+
error: null,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const resolved = resolve(worktreePath);
|
|
28
|
+
if (!existsSync(resolved)) {
|
|
29
|
+
return {
|
|
30
|
+
exists: false,
|
|
31
|
+
merged: false,
|
|
32
|
+
gitClean: false,
|
|
33
|
+
files: [],
|
|
34
|
+
lastCommit: null,
|
|
35
|
+
branch: null,
|
|
36
|
+
error: null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
// git status --short
|
|
42
|
+
const statusResult = await Bun.$`git status --short`.cwd(resolved).quiet();
|
|
43
|
+
const statusText = statusResult.text().trim();
|
|
44
|
+
const files = statusText ? statusText.split("\n") : [];
|
|
45
|
+
const gitClean = files.length === 0;
|
|
46
|
+
|
|
47
|
+
// git log -1 --oneline
|
|
48
|
+
let lastCommit: string | null = null;
|
|
49
|
+
try {
|
|
50
|
+
const logResult = await Bun.$`git log -1 --oneline`.cwd(resolved).quiet();
|
|
51
|
+
lastCommit = logResult.text().trim() || null;
|
|
52
|
+
} catch {
|
|
53
|
+
lastCommit = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// git rev-parse --abbrev-ref HEAD
|
|
57
|
+
let branch: string | null = null;
|
|
58
|
+
try {
|
|
59
|
+
const branchResult = await Bun.$`git rev-parse --abbrev-ref HEAD`.cwd(resolved).quiet();
|
|
60
|
+
branch = branchResult.text().trim() || null;
|
|
61
|
+
} catch {
|
|
62
|
+
branch = null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Detect merged: check if branch is in main's merge history
|
|
66
|
+
let merged = false;
|
|
67
|
+
if (branch && branch !== "HEAD" && branch !== "main") {
|
|
68
|
+
try {
|
|
69
|
+
const mergedResult = await Bun.$`git branch --merged main`.cwd(resolved).quiet();
|
|
70
|
+
const mergedBranches = mergedResult.text().trim().split("\n").map((b) => b.replace(/^\*\s*/, "").trim());
|
|
71
|
+
merged = mergedBranches.includes(branch);
|
|
72
|
+
} catch {
|
|
73
|
+
merged = false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
exists: true,
|
|
79
|
+
merged,
|
|
80
|
+
gitClean,
|
|
81
|
+
files,
|
|
82
|
+
lastCommit,
|
|
83
|
+
branch,
|
|
84
|
+
error: null,
|
|
85
|
+
};
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return {
|
|
88
|
+
exists: true,
|
|
89
|
+
merged: false,
|
|
90
|
+
gitClean: false,
|
|
91
|
+
files: [],
|
|
92
|
+
lastCommit: null,
|
|
93
|
+
branch: null,
|
|
94
|
+
error: err instanceof Error ? err.message : String(err),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|