@matthugh1/conductor-cli 0.1.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.
@@ -0,0 +1,605 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ runGit
4
+ } from "./chunk-FAZ7FCZQ.js";
5
+ import {
6
+ query
7
+ } from "./chunk-PANC6BTV.js";
8
+
9
+ // ../../src/core/branch-overview-match.ts
10
+ function slugifyTitle(title) {
11
+ return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
12
+ }
13
+ function normalizeBranchRef(raw) {
14
+ let s = raw.trim();
15
+ if (s.startsWith("refs/heads/")) {
16
+ s = s.slice("refs/heads/".length);
17
+ }
18
+ return s.toLowerCase();
19
+ }
20
+ function branchMatchesDeliverable(branchName, slug) {
21
+ if (slug.length === 0) {
22
+ return false;
23
+ }
24
+ const lower = branchName.toLowerCase();
25
+ return lower === slug || lower.endsWith(`/${slug}`) || lower.includes(`/${slug}/`) || lower.includes(slug);
26
+ }
27
+ function matchDeliverableFromSlug(branchName, hints) {
28
+ for (const h of hints) {
29
+ if (branchMatchesDeliverable(branchName, h.slug)) {
30
+ return { id: h.id, title: h.title, status: h.status };
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+ function resolveDeliverableLink(branchName, hints, sessionByBranch) {
36
+ const key = normalizeBranchRef(branchName);
37
+ const fromSession = sessionByBranch.get(key) ?? null;
38
+ const fromSlug = matchDeliverableFromSlug(branchName, hints);
39
+ if (fromSession !== null && fromSlug !== null) {
40
+ if (fromSession.id === fromSlug.id) {
41
+ return {
42
+ id: fromSession.id,
43
+ title: fromSession.title,
44
+ status: fromSession.status,
45
+ source: "both"
46
+ };
47
+ }
48
+ return {
49
+ id: fromSession.id,
50
+ title: fromSession.title,
51
+ status: fromSession.status,
52
+ source: "session"
53
+ };
54
+ }
55
+ if (fromSession !== null) {
56
+ return {
57
+ id: fromSession.id,
58
+ title: fromSession.title,
59
+ status: fromSession.status,
60
+ source: "session"
61
+ };
62
+ }
63
+ if (fromSlug !== null) {
64
+ return {
65
+ id: fromSlug.id,
66
+ title: fromSlug.title,
67
+ status: fromSlug.status,
68
+ source: "name_match"
69
+ };
70
+ }
71
+ return null;
72
+ }
73
+
74
+ // ../../src/core/branch-overview.ts
75
+ var STALE_MS = 7 * 24 * 60 * 60 * 1e3;
76
+ var MAX_COMMITS = 20;
77
+ var MAX_FILES = 40;
78
+ var REPO_OPS_CONFLICT_TITLE_PREFIX = "Resolve merge conflicts: ";
79
+ async function loadRepoOpsConflictDeliverableByBranch(projectId) {
80
+ const rows = await query(
81
+ `
82
+ SELECT d.id, d.title
83
+ FROM deliverables d
84
+ INNER JOIN outcomes o ON o.id = d.outcome_id
85
+ INNER JOIN initiatives i ON i.id = o.initiative_id
86
+ WHERE i.project_id = $1
87
+ AND d.assigned_role = 'repo-ops'
88
+ AND d.status IN ('todo', 'in_progress')
89
+ AND d.title LIKE $2
90
+ `,
91
+ [projectId, `${REPO_OPS_CONFLICT_TITLE_PREFIX}%`]
92
+ );
93
+ const map = /* @__PURE__ */ new Map();
94
+ for (const r of rows) {
95
+ if (!r.title.startsWith(REPO_OPS_CONFLICT_TITLE_PREFIX)) {
96
+ continue;
97
+ }
98
+ const branch = r.title.slice(REPO_OPS_CONFLICT_TITLE_PREFIX.length);
99
+ if (branch.length > 0) {
100
+ map.set(branch, r.id);
101
+ }
102
+ }
103
+ return map;
104
+ }
105
+ async function loadDeliverableHints(projectId) {
106
+ const rows = await query(
107
+ `
108
+ SELECT d.id, d.title, d.status AS deliverable_status
109
+ FROM deliverables d
110
+ INNER JOIN outcomes o ON o.id = d.outcome_id
111
+ INNER JOIN initiatives i ON i.id = o.initiative_id
112
+ WHERE i.project_id = $1
113
+ `,
114
+ [projectId]
115
+ );
116
+ return rows.map((r) => ({
117
+ id: r.id,
118
+ title: r.title,
119
+ slug: slugifyTitle(r.title),
120
+ status: r.deliverable_status
121
+ }));
122
+ }
123
+ async function loadSessionBranchDeliverables(projectId) {
124
+ const rows = await query(
125
+ `
126
+ SELECT DISTINCT ON (lower(trim(s.branch)))
127
+ trim(s.branch) AS branch_raw,
128
+ d.id AS deliverable_id,
129
+ d.title AS deliverable_title,
130
+ d.status AS deliverable_status
131
+ FROM sessions s
132
+ LEFT JOIN deliverables d ON d.id = s.deliverable_id
133
+ WHERE s.project_id = $1
134
+ AND s.branch IS NOT NULL
135
+ AND trim(s.branch) <> ''
136
+ ORDER BY lower(trim(s.branch)), s.started_at DESC
137
+ `,
138
+ [projectId]
139
+ );
140
+ const map = /* @__PURE__ */ new Map();
141
+ for (const r of rows) {
142
+ if (r.deliverable_id === null || r.deliverable_title === null) {
143
+ continue;
144
+ }
145
+ const key = normalizeBranchRef(r.branch_raw);
146
+ map.set(key, {
147
+ id: r.deliverable_id,
148
+ title: r.deliverable_title,
149
+ status: r.deliverable_status ?? ""
150
+ });
151
+ }
152
+ return map;
153
+ }
154
+ async function resolveDefaultBranchName(projectRoot, branchNames) {
155
+ if (branchNames.length === 0) {
156
+ return null;
157
+ }
158
+ if (branchNames.includes("main")) {
159
+ return "main";
160
+ }
161
+ if (branchNames.includes("master")) {
162
+ return "master";
163
+ }
164
+ return branchNames[0] ?? null;
165
+ }
166
+ async function listLocalBranchNames(projectRoot) {
167
+ const out = await runGit(projectRoot, [
168
+ "branch",
169
+ "--format=%(refname:short)"
170
+ ]);
171
+ return out.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
172
+ }
173
+ async function listUnmergedBranchNames(projectRoot, mainBranch) {
174
+ try {
175
+ const out = await runGit(projectRoot, [
176
+ "branch",
177
+ "--no-merged",
178
+ mainBranch,
179
+ "--format=%(refname:short)"
180
+ ]);
181
+ return out.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+ function formatAge(iso) {
187
+ const t = Date.parse(iso);
188
+ if (Number.isNaN(t)) {
189
+ return "unknown age";
190
+ }
191
+ const diff = Date.now() - t;
192
+ const mins = Math.floor(diff / 6e4);
193
+ if (mins < 90) {
194
+ return `${mins} min ago`;
195
+ }
196
+ const hours = Math.floor(diff / 36e5);
197
+ if (hours < 36) {
198
+ return `${hours} hr ago`;
199
+ }
200
+ const days = Math.floor(diff / 864e5);
201
+ if (days < 14) {
202
+ return `${days} days ago`;
203
+ }
204
+ const weeks = Math.floor(days / 7);
205
+ return `${weeks} wk ago`;
206
+ }
207
+ async function detectMergeConflicts(projectRoot, mainBranch, featureBranch) {
208
+ if (mainBranch === featureBranch) {
209
+ return { hasConflicts: false, files: [] };
210
+ }
211
+ try {
212
+ const base = (await runGit(projectRoot, ["merge-base", mainBranch, featureBranch])).trim();
213
+ if (base.length === 0) {
214
+ return { hasConflicts: true, files: [] };
215
+ }
216
+ const treeOut = await runGit(projectRoot, [
217
+ "merge-tree",
218
+ base,
219
+ mainBranch,
220
+ featureBranch
221
+ ]);
222
+ const files = /* @__PURE__ */ new Set();
223
+ if (treeOut.includes("<<<<<<<") || treeOut.includes("CONFLICT")) {
224
+ for (const line of treeOut.split("\n")) {
225
+ if (line.includes("CONFLICT") && line.includes("content")) {
226
+ const m = line.match(/in\s+(.+)$/);
227
+ if (m !== null && m[1] !== void 0) {
228
+ files.add(m[1].trim());
229
+ }
230
+ }
231
+ }
232
+ return { hasConflicts: true, files: [...files].slice(0, MAX_FILES) };
233
+ }
234
+ return { hasConflicts: false, files: [] };
235
+ } catch {
236
+ return { hasConflicts: false, files: [] };
237
+ }
238
+ }
239
+ async function getAheadBehind(projectRoot, mainBranch, featureBranch) {
240
+ const out = (await runGit(projectRoot, [
241
+ "rev-list",
242
+ "--left-right",
243
+ "--count",
244
+ `${mainBranch}...${featureBranch}`
245
+ ])).trim();
246
+ const parts = out.split(/\s+/);
247
+ if (parts.length < 2) {
248
+ return { ahead: 0, behind: 0 };
249
+ }
250
+ const behind = Number.parseInt(parts[0] ?? "0", 10);
251
+ const ahead = Number.parseInt(parts[1] ?? "0", 10);
252
+ return {
253
+ ahead: Number.isNaN(ahead) ? 0 : ahead,
254
+ behind: Number.isNaN(behind) ? 0 : behind
255
+ };
256
+ }
257
+ async function isBranchFullyOnMain(projectRoot, mainBranch, branchName) {
258
+ try {
259
+ await runGit(projectRoot, [
260
+ "merge-base",
261
+ "--is-ancestor",
262
+ branchName,
263
+ mainBranch
264
+ ]);
265
+ return true;
266
+ } catch {
267
+ return false;
268
+ }
269
+ }
270
+ async function loadCommitsAheadFixed(projectRoot, mainBranch, featureBranch) {
271
+ const range = `${mainBranch}..${featureBranch}`;
272
+ const out = await runGit(projectRoot, [
273
+ "log",
274
+ "--oneline",
275
+ range,
276
+ "-n",
277
+ String(MAX_COMMITS)
278
+ ]);
279
+ const rows = [];
280
+ for (const line of out.split("\n")) {
281
+ if (line.trim().length === 0) {
282
+ continue;
283
+ }
284
+ const space = line.indexOf(" ");
285
+ if (space === -1) {
286
+ rows.push({ hash: line.trim(), message: "" });
287
+ } else {
288
+ rows.push({
289
+ hash: line.slice(0, space),
290
+ message: line.slice(space + 1).trim()
291
+ });
292
+ }
293
+ }
294
+ return rows;
295
+ }
296
+ async function loadChangedFiles(projectRoot, mainBranch, featureBranch) {
297
+ const out = await runGit(projectRoot, [
298
+ "diff",
299
+ "--name-only",
300
+ `${mainBranch}...${featureBranch}`
301
+ ]);
302
+ const lines = out.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
303
+ return lines.slice(0, MAX_FILES);
304
+ }
305
+ async function getConflictBranchesForWorkQueue(projectRoot, options) {
306
+ let branchNames = [];
307
+ try {
308
+ branchNames = await listLocalBranchNames(projectRoot);
309
+ } catch {
310
+ return { defaultBranch: "main", conflicts: [] };
311
+ }
312
+ const defaultBranch = await resolveDefaultBranchName(projectRoot, branchNames);
313
+ if (defaultBranch === null) {
314
+ return { defaultBranch: "main", conflicts: [] };
315
+ }
316
+ const unmergedOrNull = await listUnmergedBranchNames(
317
+ projectRoot,
318
+ defaultBranch
319
+ );
320
+ const assumeTipsNotInMain = unmergedOrNull !== null && unmergedOrNull.length > 0;
321
+ if (unmergedOrNull !== null && unmergedOrNull.length === 0) {
322
+ return { defaultBranch, conflicts: [] };
323
+ }
324
+ const namesToScan = unmergedOrNull !== null ? unmergedOrNull : branchNames;
325
+ let hints = [];
326
+ let sessionByBranch = /* @__PURE__ */ new Map();
327
+ if (options.projectId !== null && options.projectId.length > 0) {
328
+ try {
329
+ hints = await loadDeliverableHints(options.projectId);
330
+ } catch {
331
+ hints = [];
332
+ }
333
+ try {
334
+ sessionByBranch = await loadSessionBranchDeliverables(
335
+ options.projectId
336
+ );
337
+ } catch {
338
+ sessionByBranch = /* @__PURE__ */ new Map();
339
+ }
340
+ }
341
+ const conflicts = [];
342
+ for (const name of namesToScan) {
343
+ if (name === defaultBranch) {
344
+ continue;
345
+ }
346
+ let lastIso = "";
347
+ try {
348
+ const raw = (await runGit(projectRoot, [
349
+ "log",
350
+ "-1",
351
+ "--format=%cI%x00%s",
352
+ name
353
+ ])).trim();
354
+ const z = raw.indexOf("\0");
355
+ if (z !== -1) {
356
+ lastIso = raw.slice(0, z);
357
+ }
358
+ } catch {
359
+ lastIso = (/* @__PURE__ */ new Date(0)).toISOString();
360
+ }
361
+ const mergedIntoMain = assumeTipsNotInMain ? false : await isBranchFullyOnMain(projectRoot, defaultBranch, name);
362
+ const { ahead } = await getAheadBehind(
363
+ projectRoot,
364
+ defaultBranch,
365
+ name
366
+ );
367
+ let hasMergeConflicts = false;
368
+ let conflictFiles = [];
369
+ if (!mergedIntoMain && ahead > 0) {
370
+ const det = await detectMergeConflicts(
371
+ projectRoot,
372
+ defaultBranch,
373
+ name
374
+ );
375
+ hasMergeConflicts = det.hasConflicts;
376
+ conflictFiles = det.files;
377
+ }
378
+ const lastMs = Date.parse(lastIso);
379
+ const stale = !mergedIntoMain && name !== defaultBranch && !Number.isNaN(lastMs) && Date.now() - lastMs > STALE_MS;
380
+ const safety = name.startsWith("conductor-safety-");
381
+ if (!hasMergeConflicts || stale || safety) {
382
+ continue;
383
+ }
384
+ conflicts.push({
385
+ name,
386
+ conflictFiles,
387
+ deliverable: resolveDeliverableLink(name, hints, sessionByBranch)
388
+ });
389
+ }
390
+ conflicts.sort((a, b) => a.name.localeCompare(b.name));
391
+ return { defaultBranch, conflicts };
392
+ }
393
+ async function getBranchOverview(projectRoot, options) {
394
+ let branchNames = [];
395
+ try {
396
+ branchNames = await listLocalBranchNames(projectRoot);
397
+ } catch {
398
+ return {
399
+ defaultBranch: "main",
400
+ currentBranch: options.currentBranch,
401
+ hideOverview: true,
402
+ branches: []
403
+ };
404
+ }
405
+ const defaultBranch = await resolveDefaultBranchName(projectRoot, branchNames);
406
+ if (defaultBranch === null) {
407
+ return {
408
+ defaultBranch: "main",
409
+ currentBranch: options.currentBranch,
410
+ hideOverview: true,
411
+ branches: []
412
+ };
413
+ }
414
+ const hideOverview = branchNames.length < 2;
415
+ let hints = [];
416
+ let sessionByBranch = /* @__PURE__ */ new Map();
417
+ if (options.projectId !== null && options.projectId.length > 0) {
418
+ try {
419
+ hints = await loadDeliverableHints(options.projectId);
420
+ } catch {
421
+ hints = [];
422
+ }
423
+ try {
424
+ sessionByBranch = await loadSessionBranchDeliverables(
425
+ options.projectId
426
+ );
427
+ } catch {
428
+ sessionByBranch = /* @__PURE__ */ new Map();
429
+ }
430
+ }
431
+ let repoOpsConflictByBranch = /* @__PURE__ */ new Map();
432
+ if (options.projectId !== null && options.projectId.length > 0) {
433
+ try {
434
+ repoOpsConflictByBranch = await loadRepoOpsConflictDeliverableByBranch(
435
+ options.projectId
436
+ );
437
+ } catch {
438
+ repoOpsConflictByBranch = /* @__PURE__ */ new Map();
439
+ }
440
+ }
441
+ const rows = [];
442
+ for (const name of branchNames) {
443
+ let lastIso = "";
444
+ let lastMsg = "";
445
+ try {
446
+ const raw = (await runGit(projectRoot, [
447
+ "log",
448
+ "-1",
449
+ "--format=%cI%x00%s",
450
+ name
451
+ ])).trim();
452
+ const z = raw.indexOf("\0");
453
+ if (z !== -1) {
454
+ lastIso = raw.slice(0, z);
455
+ lastMsg = raw.slice(z + 1);
456
+ }
457
+ } catch {
458
+ lastIso = (/* @__PURE__ */ new Date(0)).toISOString();
459
+ lastMsg = "";
460
+ }
461
+ const mergedIntoMain = await isBranchFullyOnMain(
462
+ projectRoot,
463
+ defaultBranch,
464
+ name
465
+ );
466
+ const { ahead, behind } = await getAheadBehind(
467
+ projectRoot,
468
+ defaultBranch,
469
+ name
470
+ );
471
+ let hasMergeConflicts = false;
472
+ let conflictFiles = [];
473
+ if (name !== defaultBranch && !mergedIntoMain && ahead > 0) {
474
+ const det = await detectMergeConflicts(
475
+ projectRoot,
476
+ defaultBranch,
477
+ name
478
+ );
479
+ hasMergeConflicts = det.hasConflicts;
480
+ conflictFiles = det.files;
481
+ }
482
+ const lastMs = Date.parse(lastIso);
483
+ const stale = !mergedIntoMain && name !== defaultBranch && !Number.isNaN(lastMs) && Date.now() - lastMs > STALE_MS;
484
+ const isCurrent = options.currentBranch === name;
485
+ const safety = name.startsWith("conductor-safety-");
486
+ let group;
487
+ let badgeLabel;
488
+ let explanation;
489
+ if (name === defaultBranch) {
490
+ group = "base_branch";
491
+ badgeLabel = "Base branch";
492
+ explanation = "This is your main line of work. Other branches are compared to it.";
493
+ } else if (safety) {
494
+ group = "safety_snapshot";
495
+ badgeLabel = "Safety snapshot";
496
+ explanation = "Conductor created this branch before a risky command. You can usually clean it up once you are sure you do not need it.";
497
+ } else if (mergedIntoMain && ahead === 0) {
498
+ group = "merged_cleanup";
499
+ badgeLabel = "Merged";
500
+ explanation = "Everything on this branch is already on your main branch. It is safe to remove the branch name when you no longer need the label.";
501
+ } else if (stale) {
502
+ group = "stale";
503
+ badgeLabel = "Stale";
504
+ explanation = "This branch has been quiet for a while and is not fully merged. Review or delete it so names do not pile up.";
505
+ } else if (hasMergeConflicts && ahead > 0) {
506
+ group = "needs_review";
507
+ badgeLabel = "Needs review";
508
+ explanation = "This branch has changes that overlap with main. The agent will resolve these automatically when it merges.";
509
+ } else if (behind > 0 && ahead > 0) {
510
+ group = "needs_review";
511
+ badgeLabel = "Needs review";
512
+ explanation = "This branch and main have both moved. Update or merge carefully so you do not lose work.";
513
+ } else if (ahead > 0 && behind === 0) {
514
+ group = "ready_to_merge";
515
+ badgeLabel = "Ready to merge";
516
+ explanation = "This branch is ahead of main with no extra commits on main waiting to come in. Good candidate to fold into main when you are ready.";
517
+ } else {
518
+ group = "keep";
519
+ badgeLabel = "Keep";
520
+ explanation = "No urgent action \u2014 check back when you have finished work on this line.";
521
+ }
522
+ const commitsOnBranch = await loadCommitsAheadFixed(
523
+ projectRoot,
524
+ defaultBranch,
525
+ name
526
+ );
527
+ const changedFiles = await loadChangedFiles(
528
+ projectRoot,
529
+ defaultBranch,
530
+ name
531
+ );
532
+ rows.push({
533
+ name,
534
+ isCurrentBranch: isCurrent,
535
+ lastCommitIso: lastIso || (/* @__PURE__ */ new Date(0)).toISOString(),
536
+ lastCommitMessage: lastMsg,
537
+ ageShort: formatAge(lastIso || (/* @__PURE__ */ new Date(0)).toISOString()),
538
+ ahead,
539
+ behind,
540
+ mergedIntoMain,
541
+ hasMergeConflicts,
542
+ conflictFiles,
543
+ group,
544
+ badgeLabel,
545
+ explanation,
546
+ deliverable: resolveDeliverableLink(name, hints, sessionByBranch),
547
+ commitsOnBranch,
548
+ changedFiles,
549
+ repoOpsConflictDeliverableId: repoOpsConflictByBranch.get(name) ?? null
550
+ });
551
+ }
552
+ rows.sort((a, b) => a.name.localeCompare(b.name));
553
+ return {
554
+ defaultBranch,
555
+ currentBranch: options.currentBranch,
556
+ hideOverview,
557
+ branches: rows
558
+ };
559
+ }
560
+ async function assertCanQueueMerge(projectRoot, defaultBranch, featureBranch) {
561
+ if (featureBranch === defaultBranch) {
562
+ throw new Error("Pick a branch other than your main branch to merge.");
563
+ }
564
+ const merged = await isBranchFullyOnMain(projectRoot, defaultBranch, featureBranch);
565
+ if (merged) {
566
+ throw new Error("That branch is already merged into main \u2014 there is nothing to merge.");
567
+ }
568
+ const { ahead } = await getAheadBehind(projectRoot, defaultBranch, featureBranch);
569
+ if (ahead === 0) {
570
+ throw new Error("That branch does not have commits to bring into main.");
571
+ }
572
+ const det = await detectMergeConflicts(projectRoot, defaultBranch, featureBranch);
573
+ if (det.hasConflicts) {
574
+ const fileHint = det.files.length > 0 ? ` Conflicting files include: ${det.files.slice(0, 5).join(", ")}.` : "";
575
+ throw new Error(
576
+ `This branch has overlapping changes with main.${fileHint} The agent will resolve these when merging.`
577
+ );
578
+ }
579
+ }
580
+ async function getMergeStats(projectRoot, defaultBranch, featureBranch) {
581
+ let commitCount = 0;
582
+ try {
583
+ const raw = (await runGit(projectRoot, [
584
+ "rev-list",
585
+ "--count",
586
+ `${defaultBranch}..${featureBranch}`
587
+ ])).trim();
588
+ commitCount = Number.parseInt(raw, 10);
589
+ if (Number.isNaN(commitCount)) {
590
+ commitCount = 0;
591
+ }
592
+ } catch {
593
+ commitCount = 0;
594
+ }
595
+ const files = await loadChangedFiles(projectRoot, defaultBranch, featureBranch);
596
+ return { commitCount, fileCount: files.length };
597
+ }
598
+
599
+ export {
600
+ isBranchFullyOnMain,
601
+ getConflictBranchesForWorkQueue,
602
+ getBranchOverview,
603
+ assertCanQueueMerge,
604
+ getMergeStats
605
+ };