@mrpatronz/nexusflow 0.2.18 → 0.2.19

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.
Files changed (73) hide show
  1. package/dist/commands/sync.d.ts.map +1 -1
  2. package/dist/commands/sync.js +37 -50
  3. package/dist/commands/sync.js.map +1 -1
  4. package/dist/core/sync.d.ts +44 -0
  5. package/dist/core/sync.d.ts.map +1 -0
  6. package/dist/core/sync.js +82 -0
  7. package/dist/core/sync.js.map +1 -0
  8. package/dist/core/workspace-state.d.ts +51 -0
  9. package/dist/core/workspace-state.d.ts.map +1 -0
  10. package/dist/core/workspace-state.js +108 -0
  11. package/dist/core/workspace-state.js.map +1 -0
  12. package/dist/core/workspace-state.test.d.ts +2 -0
  13. package/dist/core/workspace-state.test.d.ts.map +1 -0
  14. package/dist/core/workspace-state.test.js +84 -0
  15. package/dist/core/workspace-state.test.js.map +1 -0
  16. package/dist/gui/assets/index-Bdbld6yY.css +1 -0
  17. package/dist/gui/assets/index-D8iJPvnk.js +26 -0
  18. package/dist/gui/index.html +2 -2
  19. package/dist/mcp/server.d.ts.map +1 -1
  20. package/dist/mcp/server.js +42 -0
  21. package/dist/mcp/server.js.map +1 -1
  22. package/dist/server.d.ts.map +1 -1
  23. package/dist/server.js +94 -13
  24. package/dist/server.js.map +1 -1
  25. package/dist/types.d.ts +31 -0
  26. package/dist/types.d.ts.map +1 -1
  27. package/dist/utils/multi-git.d.ts +33 -8
  28. package/dist/utils/multi-git.d.ts.map +1 -1
  29. package/dist/utils/multi-git.js +91 -19
  30. package/dist/utils/multi-git.js.map +1 -1
  31. package/dist/utils/multi-git.test.d.ts +2 -0
  32. package/dist/utils/multi-git.test.d.ts.map +1 -0
  33. package/dist/utils/multi-git.test.js +125 -0
  34. package/dist/utils/multi-git.test.js.map +1 -0
  35. package/gui/e2e/dashboard.spec.ts +61 -0
  36. package/gui/e2e/wizard.spec.ts +2 -2
  37. package/gui/package-lock.json +62 -4
  38. package/gui/package.json +2 -1
  39. package/gui/src/App.tsx +168 -227
  40. package/gui/src/app/AppSidebar.tsx +69 -0
  41. package/gui/src/components/AddRepoPicker.tsx +81 -0
  42. package/gui/src/components/ui/Button.tsx +40 -0
  43. package/gui/src/components/ui/Card.tsx +6 -0
  44. package/gui/src/components/ui/EmptyState.tsx +22 -0
  45. package/gui/src/components/ui/Input.tsx +21 -0
  46. package/gui/src/components/ui/Menu.tsx +71 -0
  47. package/gui/src/components/ui/Modal.tsx +39 -0
  48. package/gui/src/components/ui/PageHeader.tsx +21 -0
  49. package/gui/src/components/ui/RepoStatusStrip.tsx +33 -0
  50. package/gui/src/components/ui/Skeleton.tsx +5 -0
  51. package/gui/src/components/ui/StatusPill.tsx +36 -0
  52. package/gui/src/components/ui/Tabs.tsx +40 -0
  53. package/gui/src/components/ui/cn.ts +4 -0
  54. package/gui/src/components/ui/index.ts +16 -0
  55. package/gui/src/features/services/ServiceConsole.tsx +4 -39
  56. package/gui/src/index.css +140 -125
  57. package/gui/src/lib/status.ts +24 -0
  58. package/gui/src/pages/DashboardPage.tsx +129 -0
  59. package/gui/src/pages/WorkspacesPage.tsx +352 -0
  60. package/gui/src/types.ts +19 -0
  61. package/package.json +1 -1
  62. package/src/commands/sync.ts +36 -58
  63. package/src/core/sync.ts +121 -0
  64. package/src/core/workspace-state.test.ts +108 -0
  65. package/src/core/workspace-state.ts +130 -0
  66. package/src/mcp/server.ts +44 -0
  67. package/src/server.ts +103 -15
  68. package/src/types.ts +36 -0
  69. package/src/utils/multi-git.test.ts +158 -0
  70. package/src/utils/multi-git.ts +117 -25
  71. package/dist/gui/assets/index-CB-jWded.css +0 -1
  72. package/dist/gui/assets/index-DDXpZZu3.js +0 -25
  73. package/gui/src/features/workspace/WorkspaceList.tsx +0 -666
@@ -0,0 +1,352 @@
1
+ import { useMemo, useState, type ComponentProps } from 'react';
2
+ import { RefreshCw, Play, MoreVertical, ExternalLink, Sparkles, Trash2, Search, FolderGit2 } from 'lucide-react';
3
+ import type { Feature, WorkspaceStatus, RepoInfo } from '../types.js';
4
+ import { Button, Card, EmptyState, Menu, PageHeader, StatusPill, Tabs, Input, Skeleton, cn } from '../components/ui/index.js';
5
+ import type { TabItem } from '../components/ui/index.js';
6
+ import { AddRepoPicker } from '../components/AddRepoPicker.js';
7
+ import { syncMeta, repoName } from '../lib/status.js';
8
+ import { SessionHistory } from '../features/sessions/SessionHistory.js';
9
+ import { ServiceConsole } from '../features/services/ServiceConsole.js';
10
+ import { ChangesViewer } from '../features/changes/ChangesViewer.js';
11
+ import { KnowledgeBase } from '../features/knowledge/KnowledgeBase.js';
12
+ import { ImplementationPlan } from '../features/plan/ImplementationPlan.js';
13
+
14
+ type SubTab = 'overview' | 'sessions' | 'services' | 'changes' | 'knowledge' | 'plan';
15
+
16
+ const TABS: TabItem[] = [
17
+ { value: 'overview', label: 'Overview' },
18
+ { value: 'changes', label: 'Changes' },
19
+ { value: 'services', label: 'Services' },
20
+ { value: 'sessions', label: 'Sessions' },
21
+ { value: 'knowledge', label: 'Knowledge' },
22
+ { value: 'plan', label: 'Plan' },
23
+ ];
24
+
25
+ const FILTERS = ['all', 'changes', 'running'] as const;
26
+ type Filter = (typeof FILTERS)[number];
27
+
28
+ interface WorkspacesPageProps {
29
+ workspaces: Feature[];
30
+ workspaceStatuses: Record<string, WorkspaceStatus>;
31
+ statusesLoading: boolean;
32
+ workspacesLoading: boolean;
33
+ fetchWorkspaces: () => Promise<void>;
34
+ selectedId: string | null;
35
+ subTab: SubTab;
36
+ onSelect: (id: string) => void;
37
+ onSelectTab: (id: string, tab: SubTab) => void;
38
+ resumingWs: string | null;
39
+ handleResumeSession: (ws: Feature, sessionId?: string, assistant?: string) => Promise<void>;
40
+ handleCopyPrompt: (ws: Feature) => void;
41
+ handleOpenInEditor: (workspacePath: string) => Promise<void>;
42
+ handleDeleteWorkspace: (wsName: string) => Promise<void>;
43
+ deleteWsLoading: string | null;
44
+ repos: RepoInfo[];
45
+ addRepoLoading: boolean;
46
+ handleAddRepo: (wsName: string, repoPath: string) => Promise<void>;
47
+ sessionProps: Omit<ComponentProps<typeof SessionHistory>, 'ws'>;
48
+ serviceProps: Omit<ComponentProps<typeof ServiceConsole>, 'ws'>;
49
+ changesProps: Omit<ComponentProps<typeof ChangesViewer>, 'ws'>;
50
+ knowledgeProps: Omit<ComponentProps<typeof KnowledgeBase>, 'ws'>;
51
+ planProps: ComponentProps<typeof ImplementationPlan>;
52
+ }
53
+
54
+ export function WorkspacesPage(props: WorkspacesPageProps) {
55
+ const {
56
+ workspaces,
57
+ workspaceStatuses,
58
+ workspacesLoading,
59
+ fetchWorkspaces,
60
+ selectedId,
61
+ subTab,
62
+ onSelect,
63
+ onSelectTab,
64
+ resumingWs,
65
+ handleResumeSession,
66
+ handleCopyPrompt,
67
+ handleOpenInEditor,
68
+ handleDeleteWorkspace,
69
+ deleteWsLoading,
70
+ repos,
71
+ addRepoLoading,
72
+ handleAddRepo,
73
+ sessionProps,
74
+ serviceProps,
75
+ changesProps,
76
+ knowledgeProps,
77
+ planProps,
78
+ } = props;
79
+
80
+ const [query, setQuery] = useState('');
81
+ const [filter, setFilter] = useState<Filter>('all');
82
+
83
+ const selected = workspaces.find((w) => w.branchName === selectedId) ?? null;
84
+
85
+ const filtered = useMemo(() => {
86
+ const q = query.trim().toLowerCase();
87
+ return workspaces.filter((w) => {
88
+ const st = workspaceStatuses[w.branchName];
89
+ if (q && !`${w.branchName} ${w.description}`.toLowerCase().includes(q)) return false;
90
+ if (filter === 'changes' && !(st && st.changedFiles > 0)) return false;
91
+ if (filter === 'running' && !(st && st.runningServices > 0)) return false;
92
+ return true;
93
+ });
94
+ }, [workspaces, workspaceStatuses, query, filter]);
95
+
96
+ const repoRows = selected
97
+ ? selected.repos.map((rp) => {
98
+ const name = repoName(rp);
99
+ const change = changesProps.gitChanges?.find((c: { repoName: string; files?: unknown[] }) => c.repoName === name);
100
+ const changedCount: number | null = change ? change.files?.length ?? 0 : null;
101
+ const svcs = (serviceProps.services ?? []).filter((s) => (s.cwd ?? '').split(/[\\/]/).includes(name));
102
+ const ports = Array.from(new Set(svcs.map((s) => s.port).filter((p): p is number => typeof p === 'number')));
103
+ return { name, changedCount, ports, serviceCount: svcs.length };
104
+ })
105
+ : [];
106
+
107
+ const availableRepos = selected ? repos.filter((r) => !selected.repos.includes(r.path)) : [];
108
+
109
+ return (
110
+ <div className="mx-auto max-w-7xl animate-fade-in">
111
+ <PageHeader
112
+ title="Workspaces"
113
+ subtitle="Select a workspace to inspect its changes, services, sessions and context."
114
+ actions={
115
+ <Button
116
+ variant="secondary"
117
+ icon={<RefreshCw size={14} className={workspacesLoading ? 'animate-spin text-accent' : ''} />}
118
+ onClick={fetchWorkspaces}
119
+ disabled={workspacesLoading}
120
+ >
121
+ Refresh
122
+ </Button>
123
+ }
124
+ />
125
+
126
+ <div className="flex flex-col gap-5 lg:flex-row">
127
+ {/* ── List pane ─────────────────────────────────────────── */}
128
+ <div className="lg:w-80 lg:shrink-0">
129
+ <div className="relative mb-3">
130
+ <Search size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
131
+ <Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search workspaces" className="pl-8" />
132
+ </div>
133
+ <div className="mb-3 flex items-center gap-1">
134
+ {FILTERS.map((f) => (
135
+ <button
136
+ key={f}
137
+ onClick={() => setFilter(f)}
138
+ className={cn(
139
+ 'rounded-md px-2.5 py-1 text-xs font-medium capitalize transition-colors cursor-pointer',
140
+ filter === f ? 'bg-accent-soft text-accent' : 'text-content-faint hover:text-content',
141
+ )}
142
+ >
143
+ {f}
144
+ </button>
145
+ ))}
146
+ </div>
147
+
148
+ {workspacesLoading ? (
149
+ <div className="flex flex-col gap-1.5">
150
+ {[0, 1, 2, 3].map((i) => (
151
+ <Skeleton key={i} className="h-14" />
152
+ ))}
153
+ </div>
154
+ ) : filtered.length === 0 ? (
155
+ <Card className="p-6 text-center text-sm text-content-muted">No workspaces match.</Card>
156
+ ) : (
157
+ <div className="flex flex-col gap-1.5">
158
+ {filtered.map((w) => {
159
+ const st = workspaceStatuses[w.branchName];
160
+ const active = w.branchName === selectedId;
161
+ const sync = st ? syncMeta(st.syncStatus) : null;
162
+ return (
163
+ <button
164
+ key={w.id}
165
+ onClick={() => onSelect(w.branchName)}
166
+ className={cn(
167
+ 'rounded-lg border p-3 text-left transition-colors cursor-pointer',
168
+ active ? 'border-accent/50 bg-accent-soft' : 'border-hairline bg-surface hover:border-hairline-strong hover:bg-raised',
169
+ )}
170
+ >
171
+ <div className="flex items-center justify-between gap-2">
172
+ <span className="truncate font-mono text-sm font-semibold text-content">{w.branchName}</span>
173
+ <span className="shrink-0 text-[11px] text-content-faint">{w.repos.length} repos</span>
174
+ </div>
175
+ <div className="mt-1.5 flex items-center gap-2">
176
+ <span
177
+ className={cn('h-1.5 w-1.5 rounded-full', st?.changedFiles ? 'bg-warning' : 'bg-success')}
178
+ title={st?.changedFiles ? `${st.changedFiles} uncommitted` : 'Clean'}
179
+ />
180
+ {st?.runningServices ? <span className="h-1.5 w-1.5 rounded-full bg-running" title="Running services" /> : null}
181
+ {sync && <span className="text-[11px] text-content-faint">{sync.label}</span>}
182
+ </div>
183
+ </button>
184
+ );
185
+ })}
186
+ </div>
187
+ )}
188
+ </div>
189
+
190
+ {/* ── Detail pane ───────────────────────────────────────── */}
191
+ <div className="min-w-0 flex-1">
192
+ {!selected ? (
193
+ <Card>
194
+ <EmptyState
195
+ icon={<FolderGit2 size={40} />}
196
+ title="No workspace selected"
197
+ description="Pick a workspace from the list to see its status, repositories, changes, services and sessions."
198
+ />
199
+ </Card>
200
+ ) : (
201
+ <div>
202
+ <Card className="mb-4 p-5">
203
+ <div className="flex items-start justify-between gap-4">
204
+ <div className="min-w-0">
205
+ <h2 className="font-display text-xl font-bold text-content">{selected.branchName}</h2>
206
+ <div className="mt-1 flex items-center gap-2 text-xs text-content-faint">
207
+ <span>Created {new Date(selected.createdAt).toLocaleDateString()}</span>
208
+ <span>·</span>
209
+ <span>{selected.repos.length} repos</span>
210
+ </div>
211
+ </div>
212
+ <div className="flex shrink-0 items-center gap-2">
213
+ <Button
214
+ variant="primary"
215
+ icon={<Play size={13} className={resumingWs === selected.branchName ? 'animate-spin' : ''} />}
216
+ disabled={resumingWs === selected.branchName}
217
+ onClick={() => handleResumeSession(selected)}
218
+ >
219
+ {resumingWs === selected.branchName ? 'Resuming…' : 'Resume in Editor'}
220
+ </Button>
221
+ <Menu
222
+ label="More actions"
223
+ trigger={
224
+ <span className="grid h-9 w-9 place-items-center rounded-md border border-hairline bg-surface text-content-muted hover:bg-raised hover:text-content">
225
+ <MoreVertical size={16} />
226
+ </span>
227
+ }
228
+ items={[
229
+ { label: 'Open Folder', icon: <ExternalLink size={14} />, onClick: () => void handleOpenInEditor(selected.workspacePath) },
230
+ { label: 'Copy AI Context', icon: <Sparkles size={14} className="text-accent" />, onClick: () => handleCopyPrompt(selected) },
231
+ {
232
+ label: deleteWsLoading === selected.branchName ? 'Deleting…' : 'Delete Workspace',
233
+ icon: <Trash2 size={14} />,
234
+ danger: true,
235
+ disabled: deleteWsLoading === selected.branchName,
236
+ onClick: () => void handleDeleteWorkspace(selected.branchName),
237
+ },
238
+ ]}
239
+ />
240
+ </div>
241
+ </div>
242
+
243
+ <div className="mt-3 flex flex-wrap items-center gap-2">
244
+ {(() => {
245
+ const st = workspaceStatuses[selected.branchName];
246
+ if (!st) return null;
247
+ const sync = syncMeta(st.syncStatus);
248
+ return (
249
+ <>
250
+ {st.changedFiles > 0 ? (
251
+ <StatusPill tone="warning" dot>
252
+ {st.changedFiles} uncommitted
253
+ </StatusPill>
254
+ ) : (
255
+ <StatusPill tone="idle" dot>
256
+ Clean
257
+ </StatusPill>
258
+ )}
259
+ {st.runningServices > 0 ? (
260
+ <StatusPill tone="running" dot>
261
+ {st.runningServices} running
262
+ </StatusPill>
263
+ ) : (
264
+ <StatusPill tone="idle" dot>
265
+ No services
266
+ </StatusPill>
267
+ )}
268
+ <StatusPill tone={sync.tone}>
269
+ <RefreshCw size={11} /> {sync.label}
270
+ </StatusPill>
271
+ {st.pendingValidation && <StatusPill tone="warning">Needs validation</StatusPill>}
272
+ </>
273
+ );
274
+ })()}
275
+ </div>
276
+ </Card>
277
+
278
+ <Tabs items={TABS} value={subTab} onChange={(v) => onSelectTab(selected.branchName, v as SubTab)} className="mb-4" />
279
+
280
+ <div className="animate-fade-in">
281
+ {subTab === 'overview' && (
282
+ <div className="flex flex-col gap-4">
283
+ <Card className="p-5">
284
+ <h3 className="mb-2 font-display text-sm font-semibold text-content">Description</h3>
285
+ {selected.description ? (
286
+ <p className="whitespace-pre-line text-sm leading-relaxed text-content-muted">{selected.description}</p>
287
+ ) : (
288
+ <p className="text-sm text-content-faint">No description provided.</p>
289
+ )}
290
+ </Card>
291
+ <Card className="p-5">
292
+ <div className="mb-4 flex items-center justify-between gap-3">
293
+ <h3 className="font-display text-sm font-semibold text-content">
294
+ Repositories <span className="text-content-faint">({selected.repos.length})</span>
295
+ </h3>
296
+ {availableRepos.length > 0 && (
297
+ <AddRepoPicker
298
+ repos={availableRepos}
299
+ disabled={addRepoLoading}
300
+ onAdd={(path) => {
301
+ if (
302
+ window.confirm(
303
+ `Add repository "${repoName(path)}" to this workspace?\nThis creates a new git worktree and re-runs analysis.`,
304
+ )
305
+ ) {
306
+ void handleAddRepo(selected.branchName, path);
307
+ }
308
+ }}
309
+ />
310
+ )}
311
+ </div>
312
+ <div className="divide-y divide-hairline">
313
+ {repoRows.map((r) => (
314
+ <div key={r.name} className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
315
+ <span
316
+ className={cn(
317
+ 'h-2 w-2 shrink-0 rounded-full',
318
+ r.changedCount === null ? 'bg-idle' : r.changedCount > 0 ? 'bg-warning' : 'bg-success',
319
+ )}
320
+ title={r.changedCount === null ? 'Status unknown' : r.changedCount > 0 ? 'Uncommitted changes' : 'Clean'}
321
+ />
322
+ <span className="min-w-0 flex-1 truncate font-mono text-sm text-content">{r.name}</span>
323
+ <div className="flex shrink-0 items-center gap-2">
324
+ {r.ports.map((p) => (
325
+ <span key={p} className="rounded border border-hairline bg-base px-1.5 py-0.5 font-mono text-[11px] text-running">
326
+ :{p}
327
+ </span>
328
+ ))}
329
+ <span className="w-24 text-right text-xs text-content-faint">
330
+ {r.changedCount === null ? '—' : r.changedCount > 0 ? `${r.changedCount} changed` : 'clean'}
331
+ </span>
332
+ </div>
333
+ </div>
334
+ ))}
335
+ </div>
336
+ <p className="mt-3 text-[11px] text-content-faint">Per-repo git state and detected service ports.</p>
337
+ </Card>
338
+ </div>
339
+ )}
340
+ {subTab === 'sessions' && <SessionHistory ws={selected} {...sessionProps} />}
341
+ {subTab === 'services' && <ServiceConsole ws={selected} {...serviceProps} />}
342
+ {subTab === 'changes' && <ChangesViewer ws={selected} {...changesProps} />}
343
+ {subTab === 'knowledge' && <KnowledgeBase ws={selected} {...knowledgeProps} />}
344
+ {subTab === 'plan' && <ImplementationPlan {...planProps} />}
345
+ </div>
346
+ </div>
347
+ )}
348
+ </div>
349
+ </div>
350
+ </div>
351
+ );
352
+ }
package/gui/src/types.ts CHANGED
@@ -38,6 +38,25 @@ export interface Feature {
38
38
  createdAt: string;
39
39
  }
40
40
 
41
+ /** Classified outcome of a sync/rebase attempt for a repo (mirrors src/types.ts). */
42
+ export type SyncStatus = 'up-to-date' | 'rebased' | 'conflict' | 'stash-conflict' | 'error';
43
+
44
+ /** At-a-glance status for one workspace, from GET /api/workspaces/status. */
45
+ export interface WorkspaceStatus {
46
+ id: string;
47
+ branchName: string;
48
+ /** Total uncommitted files across all repo worktrees. */
49
+ changedFiles: number;
50
+ /** Number of repos with uncommitted changes. */
51
+ dirtyRepos: number;
52
+ /** Number of currently running orchestrated services. */
53
+ runningServices: number;
54
+ /** Worst-case sync classification across repos, or 'unknown' if never synced. */
55
+ syncStatus: SyncStatus | 'unknown';
56
+ /** True when any repo pulled in new commits and awaits re-validation. */
57
+ pendingValidation: boolean;
58
+ }
59
+
41
60
  export interface ServiceConfig {
42
61
  name: string;
43
62
  cwd: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrpatronz/nexusflow",
3
- "version": "0.2.18",
3
+ "version": "0.2.19",
4
4
  "description": "Combine multiple repos into a workspace with rich AI assistant context",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -4,16 +4,13 @@
4
4
  */
5
5
 
6
6
  import chalk from 'chalk';
7
- import { select, search } from '@inquirer/prompts';
7
+ import { search } from '@inquirer/prompts';
8
8
  import * as path from 'node:path';
9
9
  import * as fs from 'node:fs/promises';
10
10
 
11
11
  import { loadConfig } from '../core/config.js';
12
12
  import { listWorkspaces, loadFeatureConfig } from '../core/workspace.js';
13
- import { getWorkspaceRepos, rebaseRepo } from '../utils/multi-git.js';
14
- import { analyzeAllRepos } from '../analyzers/index.js';
15
- import { generateContextFiles } from '../generators/index.js';
16
- import type { WorkspaceContext } from '../types.js';
13
+ import { syncWorkspace, type RepoSyncReport } from '../core/sync.js';
17
14
 
18
15
  /**
19
16
  * Executes the sync command.
@@ -35,69 +32,50 @@ export async function syncCommand(workspaceArg?: string): Promise<void> {
35
32
  console.log(chalk.bold(`Syncing workspace: ${chalk.cyan(feature.branchName)}`));
36
33
  console.log(chalk.dim(`Path: ${workspacePath}\n`));
37
34
 
38
- let repos;
35
+ let report;
39
36
  try {
40
- repos = await getWorkspaceRepos(workspacePath);
37
+ report = await syncWorkspace(workspacePath);
41
38
  } catch (error) {
42
- console.error(chalk.red(`✖ Failed to retrieve repos: ${error instanceof Error ? error.message : String(error)}`));
39
+ console.error(chalk.red(`✖ Failed to sync: ${error instanceof Error ? error.message : String(error)}`));
43
40
  return;
44
41
  }
45
42
 
46
- let syncedCount = 0;
47
- let conflictCount = 0;
48
-
49
- for (const repo of repos) {
43
+ for (const repo of report.repos) {
50
44
  console.log(`Repository: ${chalk.bold(repo.name)}`);
51
- const defaultBranch = repo.defaultBranch || 'main';
52
-
53
- const spinner = chalk.dim(' Rebasing...');
54
- process.stdout.write(spinner);
55
-
56
- const result = await rebaseRepo(repo.path, defaultBranch);
57
-
58
- // Clear rebase message line
59
- process.stdout.write('\r' + ' '.repeat(spinner.length) + '\r');
60
-
61
- if (result.success) {
62
- console.log(` ${chalk.green('✅')} Synced (${result.message})`);
63
- syncedCount++;
64
- } else {
65
- console.log(` ${chalk.red('⚠️')} Conflict: ${result.message}`);
66
- if (result.conflict) {
67
- console.log(chalk.dim(result.conflict.split('\n').map(l => ` ${l}`).slice(0, 5).join('\n')));
68
- }
69
- conflictCount++;
70
- }
45
+ renderRepoResult(repo);
71
46
  }
72
47
 
73
- console.log(`\n📊 ${chalk.bold('Summary:')} ${syncedCount} synced, ${conflictCount} conflict(s)\n`);
74
-
75
- if (syncedCount > 0) {
76
- console.log(chalk.cyan('Regenerating architecture maps and context...'));
77
- try {
78
- const allRepos = await Promise.all(feature.repos.map(r => {
79
- const repoName = path.basename(r);
80
- return {
81
- name: repoName,
82
- path: r,
83
- defaultBranch: 'main',
84
- };
85
- }));
86
-
87
- const analysis = await analyzeAllRepos(allRepos);
88
- const ctx: WorkspaceContext = {
89
- feature,
90
- repos: allRepos,
91
- analysis,
92
- };
93
-
94
- await generateContextFiles(ctx, feature.assistants, workspacePath);
48
+ const parts = [`${report.syncedCount} synced`];
49
+ if (report.conflictCount > 0) parts.push(`${report.conflictCount} conflict(s)`);
50
+ if (report.errorCount > 0) parts.push(`${report.errorCount} error(s)`);
51
+ console.log(`\n📊 ${chalk.bold('Summary:')} ${parts.join(', ')}\n`);
95
52
 
53
+ if (report.syncedCount > 0) {
54
+ console.log(chalk.green('✅ Workspace maps and contexts updated.\n'));
55
+ }
56
+ }
96
57
 
97
- console.log(chalk.green('✅ Workspace maps and contexts successfully updated.\n'));
98
- } catch (error) {
99
- console.error(chalk.red(`✖ Failed to regenerate maps: ${error instanceof Error ? error.message : String(error)}\n`));
100
- }
58
+ /**
59
+ * Prints a single repo's sync outcome with status-appropriate styling.
60
+ */
61
+ function renderRepoResult(repo: RepoSyncReport): void {
62
+ switch (repo.status) {
63
+ case 'up-to-date':
64
+ case 'rebased':
65
+ console.log(` ${chalk.green('✅')} Synced (${repo.message})`);
66
+ break;
67
+ case 'stash-conflict':
68
+ console.log(` ${chalk.yellow('⚠️')} ${repo.message}`);
69
+ break;
70
+ case 'conflict':
71
+ console.log(` ${chalk.red('❌')} Conflict: ${repo.message}`);
72
+ if (repo.conflict) {
73
+ console.log(chalk.dim(repo.conflict.split('\n').map(l => ` ${l}`).slice(0, 5).join('\n')));
74
+ }
75
+ break;
76
+ case 'error':
77
+ console.log(` ${chalk.red('🔌')} ${repo.message}`);
78
+ break;
101
79
  }
102
80
  }
103
81
 
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @module core/sync
3
+ * Headless workspace sync — rebases every repo in a workspace onto its base
4
+ * branch, records the per-repo outcome to workspace state, and regenerates
5
+ * context files. Produces no console output, so it can back the CLI, the HTTP
6
+ * API, and the MCP tool alike.
7
+ */
8
+
9
+ import * as path from 'node:path';
10
+
11
+ import { loadFeatureConfig } from './workspace.js';
12
+ import { recordRepoSync } from './workspace-state.js';
13
+ import { getWorkspaceRepos, rebaseRepo } from '../utils/multi-git.js';
14
+ import { analyzeAllRepos } from '../analyzers/index.js';
15
+ import { generateContextFiles } from '../generators/index.js';
16
+ import type { SyncStatus, WorkspaceContext } from '../types.js';
17
+
18
+ /** Sync outcome for a single repo. */
19
+ export interface RepoSyncReport {
20
+ /** Directory name of the repo. */
21
+ name: string;
22
+ /** Classified outcome. */
23
+ status: SyncStatus;
24
+ /** Human-readable message. */
25
+ message: string;
26
+ /** Conflict stderr — populated only for `status === 'conflict'`. */
27
+ conflict?: string;
28
+ }
29
+
30
+ /** Aggregated result of syncing an entire workspace. */
31
+ export interface SyncReport {
32
+ /** Absolute path to the workspace. */
33
+ workspacePath: string;
34
+ /** Feature branch name being synced. */
35
+ branchName: string;
36
+ /** Per-repo outcomes, in workspace order. */
37
+ repos: RepoSyncReport[];
38
+ /** Count of repos that landed cleanly (up-to-date, rebased, or stash-conflict). */
39
+ syncedCount: number;
40
+ /** Count of repos with a genuine merge conflict. */
41
+ conflictCount: number;
42
+ /** Count of repos that failed for infrastructure reasons (fetch/auth, etc.). */
43
+ errorCount: number;
44
+ }
45
+
46
+ /** A sync status counts as "synced" when the rebase itself landed. */
47
+ function isSynced(status: SyncStatus): boolean {
48
+ return status === 'up-to-date' || status === 'rebased' || status === 'stash-conflict';
49
+ }
50
+
51
+ /**
52
+ * Syncs all repos in a workspace. Throws if the workspace configuration cannot
53
+ * be loaded; individual repo failures are captured in the returned report
54
+ * rather than thrown.
55
+ *
56
+ * @param workspacePath - Absolute path to the workspace root.
57
+ * @returns A structured report of every repo's outcome.
58
+ */
59
+ export async function syncWorkspace(workspacePath: string): Promise<SyncReport> {
60
+ const feature = await loadFeatureConfig(workspacePath);
61
+ if (!feature) {
62
+ throw new Error(
63
+ `Failed to load workspace configuration. Ensure nexusflow.json exists at ${workspacePath}.`,
64
+ );
65
+ }
66
+
67
+ const repos = await getWorkspaceRepos(workspacePath);
68
+
69
+ const repoReports: RepoSyncReport[] = [];
70
+ let syncedCount = 0;
71
+ let conflictCount = 0;
72
+ let errorCount = 0;
73
+
74
+ for (const repo of repos) {
75
+ const defaultBranch = repo.defaultBranch || 'main';
76
+ const result = await rebaseRepo(repo.path, defaultBranch);
77
+
78
+ await recordRepoSync(workspacePath, repo.name, {
79
+ status: result.status,
80
+ message: result.message,
81
+ });
82
+
83
+ repoReports.push({
84
+ name: repo.name,
85
+ status: result.status,
86
+ message: result.message,
87
+ conflict: result.conflict,
88
+ });
89
+
90
+ if (isSynced(result.status)) syncedCount++;
91
+ else if (result.status === 'conflict') conflictCount++;
92
+ else errorCount++;
93
+ }
94
+
95
+ // Regenerate maps/context when anything synced. A regen failure must never
96
+ // fail the sync itself — the rebases already happened.
97
+ if (syncedCount > 0) {
98
+ try {
99
+ const allRepos = feature.repos.map((r) => ({
100
+ name: path.basename(r),
101
+ path: r,
102
+ defaultBranch: 'main',
103
+ }));
104
+
105
+ const analysis = await analyzeAllRepos(allRepos);
106
+ const ctx: WorkspaceContext = { feature, repos: allRepos, analysis };
107
+ await generateContextFiles(ctx, feature.assistants, workspacePath);
108
+ } catch {
109
+ // Best-effort regeneration; ignore failures.
110
+ }
111
+ }
112
+
113
+ return {
114
+ workspacePath,
115
+ branchName: feature.branchName,
116
+ repos: repoReports,
117
+ syncedCount,
118
+ conflictCount,
119
+ errorCount,
120
+ };
121
+ }