@nemus-cli/nemus 0.2.1

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 (320) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +21 -0
  3. package/README.md +275 -0
  4. package/assets/README.md +53 -0
  5. package/assets/banner.png +0 -0
  6. package/assets/banner.svg +66 -0
  7. package/assets/favicon-32.png +0 -0
  8. package/assets/favicon-64.png +0 -0
  9. package/assets/icon-256.png +0 -0
  10. package/assets/icon.svg +23 -0
  11. package/assets/logo-256.png +0 -0
  12. package/assets/logo-512.png +0 -0
  13. package/assets/logo-wordmark.svg +35 -0
  14. package/assets/logo.svg +32 -0
  15. package/bin/workspace.js +130 -0
  16. package/dist/cli/ai-prompt.js +421 -0
  17. package/dist/cli/dashboard/AgentList.js +61 -0
  18. package/dist/cli/dashboard/DashboardSidebar.js +280 -0
  19. package/dist/cli/dashboard/HelpFooter.js +73 -0
  20. package/dist/cli/dashboard/StatusBadge.js +50 -0
  21. package/dist/cli/dashboard/index.js +11 -0
  22. package/dist/cli/dashboard/types.js +2 -0
  23. package/dist/cli/tui.js +48 -0
  24. package/dist/commands/ai-prompt.js +22 -0
  25. package/dist/commands/analyze-deps.js +138 -0
  26. package/dist/commands/archive.js +113 -0
  27. package/dist/commands/branch/create.js +119 -0
  28. package/dist/commands/branch/index.js +104 -0
  29. package/dist/commands/branch/merge.js +90 -0
  30. package/dist/commands/branch/rebase.js +84 -0
  31. package/dist/commands/branch/switch.js +165 -0
  32. package/dist/commands/cache/index.js +80 -0
  33. package/dist/commands/cache/manager.js +198 -0
  34. package/dist/commands/cleanup.js +153 -0
  35. package/dist/commands/configure-claude.js +55 -0
  36. package/dist/commands/configure.js +231 -0
  37. package/dist/commands/create.js +204 -0
  38. package/dist/commands/dashboard/hook-handler.js +162 -0
  39. package/dist/commands/dashboard/index.js +258 -0
  40. package/dist/commands/dashboard/launcher.js +40 -0
  41. package/dist/commands/dashboard/session-picker.js +106 -0
  42. package/dist/commands/dashboard/sidebar.js +39 -0
  43. package/dist/commands/dashboard/workspace-picker.js +95 -0
  44. package/dist/commands/delete.js +194 -0
  45. package/dist/commands/deprecated-aliases.js +44 -0
  46. package/dist/commands/diff.js +129 -0
  47. package/dist/commands/doctor.js +141 -0
  48. package/dist/commands/generate-docs.js +77 -0
  49. package/dist/commands/ghq-status.js +72 -0
  50. package/dist/commands/go.js +154 -0
  51. package/dist/commands/history.js +124 -0
  52. package/dist/commands/list.js +191 -0
  53. package/dist/commands/mcp/index.js +69 -0
  54. package/dist/commands/migrate.js +296 -0
  55. package/dist/commands/remove-repo.js +217 -0
  56. package/dist/commands/report-bug.js +112 -0
  57. package/dist/commands/run.js +147 -0
  58. package/dist/commands/save-context.js +171 -0
  59. package/dist/commands/sessions.js +129 -0
  60. package/dist/commands/status.js +138 -0
  61. package/dist/commands/suite/create.js +212 -0
  62. package/dist/commands/suite/delete.js +64 -0
  63. package/dist/commands/suite/export.js +126 -0
  64. package/dist/commands/suite/import.js +128 -0
  65. package/dist/commands/suite/index.js +94 -0
  66. package/dist/commands/suite/list.js +49 -0
  67. package/dist/commands/suite/use.js +227 -0
  68. package/dist/commands/sync.js +117 -0
  69. package/dist/commands/update.js +203 -0
  70. package/dist/mcp/install.js +550 -0
  71. package/dist/mcp/server.js +398 -0
  72. package/dist/mcp/tools.js +922 -0
  73. package/dist/pi-extensions/permission-sync.ts.template +41 -0
  74. package/dist/pi-extensions/permissions-gate.ts.template +69 -0
  75. package/dist/pi-extensions/pr-fix-trigger.ts.template +124 -0
  76. package/dist/pi-extensions/review-reminder.ts.template +58 -0
  77. package/dist/pi-extensions/workspace-input-status.ts.template +919 -0
  78. package/dist/program.js +136 -0
  79. package/dist/scripts/workspace-table.py +394 -0
  80. package/dist/types/dashboard.js +11 -0
  81. package/dist/types/index.js +2 -0
  82. package/dist/utils/agent-config.js +263 -0
  83. package/dist/utils/agent-state.js +178 -0
  84. package/dist/utils/banner.js +24 -0
  85. package/dist/utils/branch-operations.js +232 -0
  86. package/dist/utils/bug-report.js +355 -0
  87. package/dist/utils/cache.js +168 -0
  88. package/dist/utils/claude-integration.js +617 -0
  89. package/dist/utils/claude-sessions.js +260 -0
  90. package/dist/utils/cleanup-operations.js +120 -0
  91. package/dist/utils/colors.js +26 -0
  92. package/dist/utils/command-helpers.js +44 -0
  93. package/dist/utils/config.js +150 -0
  94. package/dist/utils/context-file.js +33 -0
  95. package/dist/utils/dashboard-hooks.js +172 -0
  96. package/dist/utils/dependency-analyzer.js +234 -0
  97. package/dist/utils/diff-operations.js +80 -0
  98. package/dist/utils/doc-generator.js +184 -0
  99. package/dist/utils/ghq-integration.js +332 -0
  100. package/dist/utils/git-operations.js +237 -0
  101. package/dist/utils/git-status.js +187 -0
  102. package/dist/utils/github.js +68 -0
  103. package/dist/utils/health-checks.js +345 -0
  104. package/dist/utils/history.js +150 -0
  105. package/dist/utils/hooks.js +100 -0
  106. package/dist/utils/logger.js +40 -0
  107. package/dist/utils/permission-sync.js +681 -0
  108. package/dist/utils/pi-extensions.js +224 -0
  109. package/dist/utils/progress.js +92 -0
  110. package/dist/utils/prompts.js +279 -0
  111. package/dist/utils/repo-resolver.js +260 -0
  112. package/dist/utils/retry.js +65 -0
  113. package/dist/utils/run-operations.js +30 -0
  114. package/dist/utils/suite.js +159 -0
  115. package/dist/utils/sync-operations.js +107 -0
  116. package/dist/utils/tmux-dashboard.js +184 -0
  117. package/dist/utils/validation.js +153 -0
  118. package/dist/utils/version-check.js +121 -0
  119. package/dist/utils/workspace-meta.js +185 -0
  120. package/install-shell-integration.sh +532 -0
  121. package/install.sh +401 -0
  122. package/package.json +90 -0
  123. package/remove-shell-block.awk +44 -0
  124. package/scripts/postinstall.js +85 -0
  125. package/skills/analyze-deps.md +23 -0
  126. package/skills/archive-workspace.md +26 -0
  127. package/skills/branch-create.md +18 -0
  128. package/skills/create-workspace.md +39 -0
  129. package/skills/delete-workspace.md +37 -0
  130. package/skills/list-org-repos.md +26 -0
  131. package/skills/list-suites.md +24 -0
  132. package/skills/list-workspaces.md +27 -0
  133. package/skills/refresh-workspace-docs.md +89 -0
  134. package/skills/remove-repo.md +25 -0
  135. package/skills/run-command.md +23 -0
  136. package/skills/save-context.md +68 -0
  137. package/skills/search-repos.md +31 -0
  138. package/skills/snapshot-list.md +18 -0
  139. package/skills/snapshot-save.md +12 -0
  140. package/skills/switch-branch.md +18 -0
  141. package/skills/update-cache.md +21 -0
  142. package/skills/update-workspace.md +66 -0
  143. package/skills/workspace-cleanup.md +18 -0
  144. package/skills/workspace-diff.md +23 -0
  145. package/skills/workspace-doctor.md +23 -0
  146. package/skills/workspace-info.md +45 -0
  147. package/skills/workspace-manager/SKILL.md +201 -0
  148. package/skills/workspace-manager/references/ai-prompt.md +44 -0
  149. package/skills/workspace-manager/references/analyze-deps.md +45 -0
  150. package/skills/workspace-manager/references/archive-workspace.md +38 -0
  151. package/skills/workspace-manager/references/branch-create.md +31 -0
  152. package/skills/workspace-manager/references/branch-merge.md +22 -0
  153. package/skills/workspace-manager/references/branch-rebase.md +22 -0
  154. package/skills/workspace-manager/references/cache-clear.md +18 -0
  155. package/skills/workspace-manager/references/cache-info.md +22 -0
  156. package/skills/workspace-manager/references/configure-claude.md +31 -0
  157. package/skills/workspace-manager/references/configure.md +35 -0
  158. package/skills/workspace-manager/references/create-workspace.md +45 -0
  159. package/skills/workspace-manager/references/dashboard.md +61 -0
  160. package/skills/workspace-manager/references/delete-workspace.md +33 -0
  161. package/skills/workspace-manager/references/generate-docs.md +36 -0
  162. package/skills/workspace-manager/references/ghq-status.md +28 -0
  163. package/skills/workspace-manager/references/go.md +33 -0
  164. package/skills/workspace-manager/references/history.md +41 -0
  165. package/skills/workspace-manager/references/list-org-repos.md +26 -0
  166. package/skills/workspace-manager/references/list-suites.md +21 -0
  167. package/skills/workspace-manager/references/list-workspaces.md +27 -0
  168. package/skills/workspace-manager/references/mcp.md +49 -0
  169. package/skills/workspace-manager/references/remove-repo.md +26 -0
  170. package/skills/workspace-manager/references/run-command.md +44 -0
  171. package/skills/workspace-manager/references/search-repos.md +24 -0
  172. package/skills/workspace-manager/references/sessions.md +27 -0
  173. package/skills/workspace-manager/references/suite-create.md +22 -0
  174. package/skills/workspace-manager/references/suite-delete.md +21 -0
  175. package/skills/workspace-manager/references/suite-export.md +19 -0
  176. package/skills/workspace-manager/references/suite-import.md +19 -0
  177. package/skills/workspace-manager/references/suite-use.md +27 -0
  178. package/skills/workspace-manager/references/switch-branch.md +25 -0
  179. package/skills/workspace-manager/references/update-cache.md +22 -0
  180. package/skills/workspace-manager/references/update-workspace.md +50 -0
  181. package/skills/workspace-manager/references/workspace-cleanup.md +33 -0
  182. package/skills/workspace-manager/references/workspace-diff.md +27 -0
  183. package/skills/workspace-manager/references/workspace-doctor.md +27 -0
  184. package/skills/workspace-manager/references/workspace-info.md +33 -0
  185. package/skills/workspace-manager/references/workspace-status.md +43 -0
  186. package/skills/workspace-manager/references/workspace-sync.md +26 -0
  187. package/skills/workspace-status.md +26 -0
  188. package/skills/workspace-sync.md +23 -0
  189. package/src/cli/ai-prompt.test.ts +667 -0
  190. package/src/cli/ai-prompt.ts +421 -0
  191. package/src/cli/dashboard/AgentList.tsx +44 -0
  192. package/src/cli/dashboard/DashboardSidebar.tsx +291 -0
  193. package/src/cli/dashboard/HelpFooter.tsx +22 -0
  194. package/src/cli/dashboard/StatusBadge.tsx +23 -0
  195. package/src/cli/dashboard/index.ts +5 -0
  196. package/src/cli/dashboard/types.ts +16 -0
  197. package/src/cli/tui.tsx +51 -0
  198. package/src/cli/workspace-cli.test.ts +30 -0
  199. package/src/commands/ai-prompt.ts +21 -0
  200. package/src/commands/analyze-deps.ts +119 -0
  201. package/src/commands/archive.ts +114 -0
  202. package/src/commands/branch/create.ts +94 -0
  203. package/src/commands/branch/index.ts +74 -0
  204. package/src/commands/branch/merge.ts +61 -0
  205. package/src/commands/branch/rebase.ts +54 -0
  206. package/src/commands/branch/switch.ts +150 -0
  207. package/src/commands/cache/index.ts +51 -0
  208. package/src/commands/cache/manager.ts +168 -0
  209. package/src/commands/cleanup.ts +132 -0
  210. package/src/commands/configure-claude.ts +55 -0
  211. package/src/commands/configure.ts +204 -0
  212. package/src/commands/create.ts +196 -0
  213. package/src/commands/dashboard/hook-handler.ts +138 -0
  214. package/src/commands/dashboard/index.ts +248 -0
  215. package/src/commands/dashboard/launcher.ts +42 -0
  216. package/src/commands/dashboard/session-picker.ts +73 -0
  217. package/src/commands/dashboard/sidebar.js +39 -0
  218. package/src/commands/dashboard/workspace-picker.ts +62 -0
  219. package/src/commands/delete.test.ts +250 -0
  220. package/src/commands/delete.ts +171 -0
  221. package/src/commands/deprecated-aliases.ts +55 -0
  222. package/src/commands/diff.ts +106 -0
  223. package/src/commands/doctor.ts +110 -0
  224. package/src/commands/generate-docs.ts +45 -0
  225. package/src/commands/ghq-status.ts +70 -0
  226. package/src/commands/go.ts +125 -0
  227. package/src/commands/history.ts +136 -0
  228. package/src/commands/list.test.ts +219 -0
  229. package/src/commands/list.ts +176 -0
  230. package/src/commands/mcp/index.ts +39 -0
  231. package/src/commands/migrate.ts +277 -0
  232. package/src/commands/remove-repo.ts +194 -0
  233. package/src/commands/report-bug.ts +88 -0
  234. package/src/commands/run.ts +125 -0
  235. package/src/commands/save-context.test.ts +113 -0
  236. package/src/commands/save-context.ts +141 -0
  237. package/src/commands/sessions.ts +99 -0
  238. package/src/commands/status.ts +111 -0
  239. package/src/commands/suite/create.ts +240 -0
  240. package/src/commands/suite/delete.ts +65 -0
  241. package/src/commands/suite/export.ts +97 -0
  242. package/src/commands/suite/import.ts +100 -0
  243. package/src/commands/suite/index.ts +66 -0
  244. package/src/commands/suite/list.ts +54 -0
  245. package/src/commands/suite/use.ts +232 -0
  246. package/src/commands/sync.ts +97 -0
  247. package/src/commands/update.ts +197 -0
  248. package/src/mcp/install.ts +529 -0
  249. package/src/mcp/server.ts +579 -0
  250. package/src/mcp/tools.test.ts +545 -0
  251. package/src/mcp/tools.ts +1019 -0
  252. package/src/mcp/update-workspace.test.ts +220 -0
  253. package/src/pi-extensions/permission-sync.ts.template +41 -0
  254. package/src/pi-extensions/permissions-gate.ts.template +69 -0
  255. package/src/pi-extensions/pr-fix-trigger.ts.template +124 -0
  256. package/src/pi-extensions/review-reminder.ts.template +58 -0
  257. package/src/pi-extensions/workspace-input-status.ts.template +919 -0
  258. package/src/program.ts +112 -0
  259. package/src/scripts/workspace-table.py +394 -0
  260. package/src/types/dashboard.ts +29 -0
  261. package/src/types/index.ts +109 -0
  262. package/src/types/ink.d.ts +171 -0
  263. package/src/utils/agent-config.test.ts +303 -0
  264. package/src/utils/agent-config.ts +264 -0
  265. package/src/utils/agent-state.test.ts +166 -0
  266. package/src/utils/agent-state.ts +135 -0
  267. package/src/utils/banner.ts +24 -0
  268. package/src/utils/branch-operations.ts +237 -0
  269. package/src/utils/bug-report.test.ts +174 -0
  270. package/src/utils/bug-report.ts +355 -0
  271. package/src/utils/cache.ts +150 -0
  272. package/src/utils/claude-integration.test.ts +381 -0
  273. package/src/utils/claude-integration.ts +649 -0
  274. package/src/utils/claude-sessions.test.ts +262 -0
  275. package/src/utils/claude-sessions.ts +251 -0
  276. package/src/utils/cleanup-operations.ts +94 -0
  277. package/src/utils/colors.ts +25 -0
  278. package/src/utils/command-helpers.ts +46 -0
  279. package/src/utils/config.test.ts +203 -0
  280. package/src/utils/config.ts +133 -0
  281. package/src/utils/context-file.ts +36 -0
  282. package/src/utils/dashboard-hooks.test.ts +168 -0
  283. package/src/utils/dashboard-hooks.ts +163 -0
  284. package/src/utils/dependency-analyzer.ts +243 -0
  285. package/src/utils/diff-operations.ts +59 -0
  286. package/src/utils/doc-generator.ts +187 -0
  287. package/src/utils/ghq-integration.test.ts +195 -0
  288. package/src/utils/ghq-integration.ts +317 -0
  289. package/src/utils/git-operations.test.ts +265 -0
  290. package/src/utils/git-operations.ts +233 -0
  291. package/src/utils/git-status.ts +171 -0
  292. package/src/utils/github.ts +74 -0
  293. package/src/utils/health-checks.ts +359 -0
  294. package/src/utils/history.ts +140 -0
  295. package/src/utils/hooks.ts +92 -0
  296. package/src/utils/logger.ts +37 -0
  297. package/src/utils/permission-sync.test.ts +593 -0
  298. package/src/utils/permission-sync.ts +720 -0
  299. package/src/utils/pi-extensions.test.ts +182 -0
  300. package/src/utils/pi-extensions.ts +226 -0
  301. package/src/utils/progress.ts +73 -0
  302. package/src/utils/prompts.test.ts +169 -0
  303. package/src/utils/prompts.ts +296 -0
  304. package/src/utils/repo-resolver.test.ts +313 -0
  305. package/src/utils/repo-resolver.ts +294 -0
  306. package/src/utils/retry.test.ts +124 -0
  307. package/src/utils/retry.ts +85 -0
  308. package/src/utils/run-operations.ts +38 -0
  309. package/src/utils/suite.test.ts +256 -0
  310. package/src/utils/suite.ts +144 -0
  311. package/src/utils/sync-operations.ts +87 -0
  312. package/src/utils/tmux-dashboard.ts +193 -0
  313. package/src/utils/validation.test.ts +139 -0
  314. package/src/utils/validation.ts +128 -0
  315. package/src/utils/version-check.test.ts +108 -0
  316. package/src/utils/version-check.ts +96 -0
  317. package/src/utils/workspace-meta.ts +173 -0
  318. package/sync-permissions.sh +137 -0
  319. package/tsconfig.json +20 -0
  320. package/uninstall.sh +162 -0
@@ -0,0 +1,265 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { cloneRepositories, cloneSingleRepo } from './git-operations';
3
+ import { GitHubRepo } from '../types';
4
+
5
+ // Mock child_process.exec
6
+ const mockExecAsync = vi.fn();
7
+ const mockExecFile = vi.fn();
8
+ vi.mock('child_process', () => ({
9
+ exec: (...args: unknown[]) => {
10
+ const callback = args[args.length - 1] as (err: Error | null, result: { stdout: string; stderr: string }) => void;
11
+ const command = args[0] as string;
12
+ const opts = args.length === 3 ? args[1] : {};
13
+ // Forward to our mockExecAsync which returns a promise
14
+ mockExecAsync(command, opts)
15
+ .then((result: { stdout: string; stderr: string }) => callback(null, result))
16
+ .catch((err: Error) => callback(err, { stdout: '', stderr: '' }));
17
+ },
18
+ execFile: (...args: unknown[]) => {
19
+ const callback = args[args.length - 1] as (err: Error | null, result: { stdout: string; stderr: string }) => void;
20
+ const file = args[0] as string;
21
+ const fileArgs = Array.isArray(args[1]) ? (args[1] as string[]) : [];
22
+ const opts = typeof args[2] === 'object' && args[2] !== null ? args[2] : {};
23
+ // Record the raw argv so tests can assert no-shell/argv separation.
24
+ mockExecFile(file, fileArgs, opts);
25
+ // Forward as a reconstructed command string so existing expectations match.
26
+ mockExecAsync([file, ...fileArgs].join(' '), opts)
27
+ .then((result: { stdout: string; stderr: string }) => callback(null, result))
28
+ .catch((err: Error) => callback(err, { stdout: '', stderr: '' }));
29
+ },
30
+ }));
31
+
32
+ vi.mock('fs/promises', () => ({
33
+ rm: vi.fn().mockResolvedValue(undefined),
34
+ }));
35
+
36
+ vi.mock('./logger', () => ({
37
+ logSuccess: vi.fn(),
38
+ logError: vi.fn(),
39
+ logInfo: vi.fn(),
40
+ logWarning: vi.fn(),
41
+ }));
42
+
43
+ vi.mock('./colors', () => ({
44
+ colorize: (text: string) => text,
45
+ }));
46
+
47
+ vi.mock('./ghq-integration', () => ({
48
+ isGhqInstalled: vi.fn().mockResolvedValue(false),
49
+ cloneWithGhq: vi.fn(),
50
+ describeCloneError: (e: { message?: string }) => (e && e.message) || 'Unknown error',
51
+ }));
52
+
53
+ vi.mock('./retry', () => ({
54
+ withRetry: vi.fn(async (fn: () => Promise<unknown>) => fn()),
55
+ }));
56
+
57
+ vi.mock('./progress', () => ({
58
+ createSimpleProgressBar: vi.fn(() => ({
59
+ start: vi.fn(),
60
+ update: vi.fn(),
61
+ stop: vi.fn(),
62
+ })),
63
+ }));
64
+
65
+ function makeRepo(name: string, sshUrl?: string): GitHubRepo {
66
+ return {
67
+ name,
68
+ url: `https://github.com/org/${name}`,
69
+ sshUrl: sshUrl ?? `git@github.com:org/${name}.git`,
70
+ owner: { login: 'org' },
71
+ description: `${name} repo`,
72
+ isPrivate: false,
73
+ };
74
+ }
75
+
76
+ describe('cloneRepositories', () => {
77
+ beforeEach(() => {
78
+ vi.clearAllMocks();
79
+ // Default: all exec commands succeed
80
+ mockExecAsync.mockResolvedValue({ stdout: '', stderr: '' });
81
+ });
82
+
83
+ it('clones unique repos normally', async () => {
84
+ const repoA = makeRepo('repo-a');
85
+ const repoB = makeRepo('repo-b');
86
+
87
+ const results = await cloneRepositories(
88
+ [
89
+ { repo: repoA, directoryName: 'repo-a' },
90
+ { repo: repoB, directoryName: 'repo-b' },
91
+ ],
92
+ '/workspace'
93
+ );
94
+
95
+ expect(results).toHaveLength(2);
96
+ expect(results.every(r => r.status === 'success')).toBe(true);
97
+ expect(results[0].directoryName).toBe('repo-a');
98
+ expect(results[1].directoryName).toBe('repo-b');
99
+
100
+ // Should have 2 git clone calls (one per unique repo), no --local calls
101
+ const cloneCalls = mockExecAsync.mock.calls.filter(
102
+ ([cmd]: [string]) => typeof cmd === 'string' && cmd.includes('git clone')
103
+ );
104
+ expect(cloneCalls).toHaveLength(2);
105
+ const localCalls = cloneCalls.filter(([cmd]: [string]) => cmd.includes('--local'));
106
+ expect(localCalls).toHaveLength(0);
107
+ });
108
+
109
+ it('uses --local for duplicate repos', async () => {
110
+ const repo = makeRepo('platform-app');
111
+
112
+ const results = await cloneRepositories(
113
+ [
114
+ { repo, directoryName: 'platform-app-1' },
115
+ { repo, directoryName: 'platform-app-2' },
116
+ { repo, directoryName: 'platform-app-3' },
117
+ ],
118
+ '/workspace'
119
+ );
120
+
121
+ expect(results).toHaveLength(3);
122
+ expect(results.every(r => r.status === 'success')).toBe(true);
123
+
124
+ // First should be a normal clone, 2nd and 3rd via --local
125
+ const cloneCalls = mockExecAsync.mock.calls.filter(
126
+ ([cmd]: [string]) => typeof cmd === 'string' && cmd.includes('git clone')
127
+ );
128
+ const normalClones = cloneCalls.filter(([cmd]: [string]) => !cmd.includes('--local'));
129
+ const localClones = cloneCalls.filter(([cmd]: [string]) => cmd.includes('--local'));
130
+
131
+ expect(normalClones).toHaveLength(1);
132
+ expect(localClones).toHaveLength(2);
133
+
134
+ // --local clones should reference the first instance's path as source
135
+ for (const [cmd] of localClones) {
136
+ expect(cmd).toContain('/workspace/platform-app-1');
137
+ }
138
+ });
139
+
140
+ it('sets correct remote origin on --local clones', async () => {
141
+ const repo = makeRepo('platform-app');
142
+
143
+ await cloneRepositories(
144
+ [
145
+ { repo, directoryName: 'app-1' },
146
+ { repo, directoryName: 'app-2' },
147
+ ],
148
+ '/workspace'
149
+ );
150
+
151
+ // Should have set-url calls for the --local copies
152
+ const setUrlCalls = mockExecAsync.mock.calls.filter(
153
+ ([cmd]: [string]) => typeof cmd === 'string' && cmd.includes('git remote set-url origin')
154
+ );
155
+
156
+ expect(setUrlCalls).toHaveLength(1);
157
+ expect(setUrlCalls[0][0]).toContain(repo.sshUrl);
158
+ expect(setUrlCalls[0][1]).toEqual(expect.objectContaining({ cwd: '/workspace/app-2' }));
159
+ });
160
+
161
+ it('clones via execFile with separated argv (no shell), preserving spaces in the path', async () => {
162
+ const repo = makeRepo('platform-app');
163
+ mockExecAsync.mockResolvedValue({ stdout: '', stderr: '' });
164
+
165
+ // Directory name with a space would break unquoted shell interpolation.
166
+ const result = await cloneSingleRepo(repo, '/work space', 'dir with space', false);
167
+
168
+ expect(result.status).toBe('success');
169
+ const cloneCall = mockExecFile.mock.calls.find(c => c[1]?.[0] === 'clone');
170
+ expect(cloneCall).toBeDefined();
171
+ expect(cloneCall![0]).toBe('git');
172
+ expect(cloneCall![1]).toEqual(['clone', repo.sshUrl, '/work space/dir with space']);
173
+ // Argv separation means no arg is a joined shell string.
174
+ expect(cloneCall![1].some((a: string) => a.includes(' clone '))).toBe(false);
175
+ });
176
+
177
+ it('marks remaining instances as failed when primary clone fails', async () => {
178
+ const repo = makeRepo('broken-repo');
179
+
180
+ // Make git clone fail
181
+ mockExecAsync.mockRejectedValue(new Error('clone failed'));
182
+
183
+ const results = await cloneRepositories(
184
+ [
185
+ { repo, directoryName: 'broken-1' },
186
+ { repo, directoryName: 'broken-2' },
187
+ { repo, directoryName: 'broken-3' },
188
+ ],
189
+ '/workspace'
190
+ );
191
+
192
+ expect(results).toHaveLength(3);
193
+ expect(results.every(r => r.status === 'failed')).toBe(true);
194
+ expect(results[0].error).toBe('clone failed');
195
+ expect(results[1].error).toBe('Primary clone failed');
196
+ expect(results[2].error).toBe('Primary clone failed');
197
+ });
198
+
199
+ it('handles mix of unique and duplicate repos', async () => {
200
+ const repoA = makeRepo('repo-a');
201
+ const repoB = makeRepo('repo-b');
202
+
203
+ const results = await cloneRepositories(
204
+ [
205
+ { repo: repoA, directoryName: 'repo-a-1' },
206
+ { repo: repoA, directoryName: 'repo-a-2' },
207
+ { repo: repoB, directoryName: 'repo-b-1' },
208
+ ],
209
+ '/workspace'
210
+ );
211
+
212
+ expect(results).toHaveLength(3);
213
+ expect(results.every(r => r.status === 'success')).toBe(true);
214
+
215
+ const cloneCalls = mockExecAsync.mock.calls.filter(
216
+ ([cmd]: [string]) => typeof cmd === 'string' && cmd.includes('git clone')
217
+ );
218
+ const normalClones = cloneCalls.filter(([cmd]: [string]) => !cmd.includes('--local'));
219
+ const localClones = cloneCalls.filter(([cmd]: [string]) => cmd.includes('--local'));
220
+
221
+ // 2 normal clones (one per unique repo), 1 --local clone for the duplicate
222
+ expect(normalClones).toHaveLength(2);
223
+ expect(localClones).toHaveLength(1);
224
+ expect(localClones[0][0]).toContain('/workspace/repo-a-1');
225
+ });
226
+
227
+ it('handles --local clone failure independently', async () => {
228
+ const repo = makeRepo('platform-app');
229
+
230
+ // First clone succeeds, --local clone fails
231
+ mockExecAsync
232
+ .mockResolvedValueOnce({ stdout: '', stderr: '' }) // git clone (normal)
233
+ .mockRejectedValueOnce(new Error('local clone failed')); // git clone --local
234
+
235
+ const results = await cloneRepositories(
236
+ [
237
+ { repo, directoryName: 'app-1' },
238
+ { repo, directoryName: 'app-2' },
239
+ ],
240
+ '/workspace'
241
+ );
242
+
243
+ expect(results).toHaveLength(2);
244
+ expect(results[0].status).toBe('success');
245
+ expect(results[0].directoryName).toBe('app-1');
246
+ expect(results[1].status).toBe('failed');
247
+ expect(results[1].directoryName).toBe('app-2');
248
+ expect(results[1].error).toBe('local clone failed');
249
+ });
250
+
251
+ it('returns correct clonedAt timestamp on success', async () => {
252
+ const repo = makeRepo('repo-a');
253
+
254
+ const before = new Date().toISOString();
255
+ const results = await cloneRepositories(
256
+ [{ repo, directoryName: 'repo-a' }],
257
+ '/workspace'
258
+ );
259
+ const after = new Date().toISOString();
260
+
261
+ expect(results[0].clonedAt).toBeDefined();
262
+ expect(results[0].clonedAt! >= before).toBe(true);
263
+ expect(results[0].clonedAt! <= after).toBe(true);
264
+ });
265
+ });
@@ -0,0 +1,233 @@
1
+ import { exec, execFile } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import * as path from 'path';
4
+ import * as fs from 'fs/promises';
5
+ import { GitHubRepo, CloneResult } from '../types';
6
+ import { logSuccess, logError, logInfo, logWarning } from './logger';
7
+ import { colorize } from './colors';
8
+ import { cloneWithGhq, isGhqInstalled, describeCloneError } from './ghq-integration';
9
+ import { withRetry } from './retry';
10
+ import { createSimpleProgressBar } from './progress';
11
+ import { getCloneUrl, CLONE_TIMEOUT_MS, CLONE_MAX_BUFFER } from './config';
12
+
13
+ const execAsync = promisify(exec);
14
+ const execFileAsync = promisify(execFile);
15
+
16
+ const CONCURRENCY_LIMIT = 3;
17
+ const CLONE_TIMEOUT = CLONE_TIMEOUT_MS;
18
+
19
+ export const cloneSingleRepo = async (
20
+ repo: GitHubRepo,
21
+ workspacePath: string,
22
+ directoryName: string,
23
+ useGhq: boolean = true,
24
+ silent: boolean = false
25
+ ): Promise<CloneResult> => {
26
+ const repoPath = path.join(workspacePath, directoryName);
27
+ const cloneUrl = getCloneUrl(repo);
28
+ const displayName = directoryName !== repo.name
29
+ ? `${colorize(directoryName, 'cyan')} (${repo.name})`
30
+ : colorize(repo.name, 'cyan');
31
+
32
+ try {
33
+ if (!silent) logInfo(`Cloning ${displayName}...`);
34
+
35
+ await withRetry(async () => {
36
+ // Clean up partial clone before retry
37
+ try {
38
+ await fs.rm(repoPath, { recursive: true, force: true });
39
+ } catch {
40
+ // Ignore cleanup errors
41
+ }
42
+
43
+ if (useGhq) {
44
+ const result = await cloneWithGhq(cloneUrl, repoPath);
45
+ if (!result.success) {
46
+ throw new Error(result.error || 'Clone failed');
47
+ }
48
+ if (!silent) {
49
+ if (result.usedGhq) {
50
+ logSuccess(`Cloned ${displayName} ${colorize('(via ghq)', 'gray')}`);
51
+ } else {
52
+ logSuccess(`Cloned ${displayName}`);
53
+ }
54
+ }
55
+ } else {
56
+ try {
57
+ // execFile (no shell) so repo URLs / paths with spaces or shell
58
+ // metacharacters can't break or inject into the command.
59
+ await execFileAsync('git', ['clone', cloneUrl, repoPath], {
60
+ timeout: CLONE_TIMEOUT,
61
+ maxBuffer: CLONE_MAX_BUFFER,
62
+ });
63
+ } catch (err) {
64
+ // execFile loses the reason on timeout/buffer kill — explain it.
65
+ throw new Error(describeCloneError(err, CLONE_TIMEOUT));
66
+ }
67
+ if (!silent) logSuccess(`Cloned ${displayName}`);
68
+ }
69
+ }, undefined, `Clone ${repo.name}`);
70
+
71
+ return {
72
+ repo,
73
+ directoryName,
74
+ status: 'success',
75
+ clonedAt: new Date().toISOString(),
76
+ };
77
+ } catch (error) {
78
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
79
+ logError(`Failed to clone ${displayName}: ${errorMessage}`);
80
+
81
+ return {
82
+ repo,
83
+ directoryName,
84
+ status: 'failed',
85
+ error: errorMessage,
86
+ };
87
+ }
88
+ };
89
+
90
+ async function cloneLocal(
91
+ repo: GitHubRepo,
92
+ sourcePath: string,
93
+ workspacePath: string,
94
+ directoryName: string
95
+ ): Promise<CloneResult> {
96
+ const targetPath = path.join(workspacePath, directoryName);
97
+ try {
98
+ // git clone --local creates hardlinks for .git/objects — nearly instant
99
+ await execAsync(`git clone --local "${sourcePath}" "${targetPath}"`, {
100
+ timeout: CLONE_TIMEOUT,
101
+ maxBuffer: CLONE_MAX_BUFFER,
102
+ });
103
+ // Reset the remote to point to the original repo (not the local source)
104
+ const remoteUrl = getCloneUrl(repo);
105
+ await execAsync(`git remote set-url origin "${remoteUrl}"`, {
106
+ cwd: targetPath,
107
+ });
108
+ return {
109
+ repo,
110
+ directoryName,
111
+ status: 'success',
112
+ clonedAt: new Date().toISOString(),
113
+ };
114
+ } catch (error) {
115
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
116
+ return {
117
+ repo,
118
+ directoryName,
119
+ status: 'failed',
120
+ error: errorMessage,
121
+ };
122
+ }
123
+ }
124
+
125
+ export const cloneRepositories = async (
126
+ repoEntries: Array<{ repo: GitHubRepo; directoryName: string }>,
127
+ workspacePath: string
128
+ ): Promise<CloneResult[]> => {
129
+ // Check if ghq is available
130
+ const hasGhq = await isGhqInstalled();
131
+
132
+ console.log('\n' + '='.repeat(60));
133
+ console.log(`Cloning ${repoEntries.length} repositories (${CONCURRENCY_LIMIT} concurrent clones)`);
134
+ if (hasGhq) {
135
+ console.log(colorize('Using ghq for efficient repository management', 'cyan'));
136
+ }
137
+ console.log('='.repeat(60) + '\n');
138
+
139
+ const bar = createSimpleProgressBar(repoEntries.length, 'Cloning');
140
+ bar.start(repoEntries.length, 0);
141
+
142
+ // Group entries by repo SSH URL to avoid duplicate clones
143
+ const repoGroups = new Map<string, Array<{ repo: GitHubRepo; directoryName: string }>>();
144
+ for (const entry of repoEntries) {
145
+ const key = entry.repo.sshUrl;
146
+ if (!repoGroups.has(key)) {
147
+ repoGroups.set(key, []);
148
+ }
149
+ repoGroups.get(key)!.push(entry);
150
+ }
151
+
152
+ const allResults: CloneResult[] = [];
153
+ let completed = 0;
154
+
155
+ // Process unique repo groups with concurrency (different repos clone in parallel)
156
+ const groups = Array.from(repoGroups.values());
157
+ for (let i = 0; i < groups.length; i += CONCURRENCY_LIMIT) {
158
+ const chunk = groups.slice(i, i + CONCURRENCY_LIMIT);
159
+ const chunkPromises = chunk.map(async (entries) => {
160
+ const groupResults: CloneResult[] = [];
161
+
162
+ // Clone first instance normally
163
+ const first = entries[0];
164
+ const firstResult = await cloneSingleRepo(
165
+ first.repo, workspacePath, first.directoryName, hasGhq, true
166
+ );
167
+ groupResults.push(firstResult);
168
+ completed++;
169
+ bar.update(completed);
170
+
171
+ // Clone remaining instances via --local from the first
172
+ if (firstResult.status === 'success' && entries.length > 1) {
173
+ const sourcePath = path.join(workspacePath, first.directoryName);
174
+ for (const entry of entries.slice(1)) {
175
+ const result = await cloneLocal(
176
+ entry.repo, sourcePath, workspacePath, entry.directoryName
177
+ );
178
+ groupResults.push(result);
179
+ completed++;
180
+ bar.update(completed);
181
+ }
182
+ } else if (firstResult.status === 'failed' && entries.length > 1) {
183
+ // Mark remaining as failed too
184
+ for (const entry of entries.slice(1)) {
185
+ groupResults.push({
186
+ repo: entry.repo,
187
+ directoryName: entry.directoryName,
188
+ status: 'failed',
189
+ error: 'Primary clone failed',
190
+ });
191
+ completed++;
192
+ bar.update(completed);
193
+ }
194
+ }
195
+
196
+ return groupResults;
197
+ });
198
+
199
+ const chunkResults = await Promise.all(chunkPromises);
200
+ for (const groupResults of chunkResults) {
201
+ allResults.push(...groupResults);
202
+ }
203
+ }
204
+
205
+ bar.stop();
206
+ console.log('');
207
+
208
+ return allResults;
209
+ };
210
+
211
+ export const reportCloneResults = (results: CloneResult[]): void => {
212
+ const successful = results.filter(r => r.status === 'success');
213
+ const failed = results.filter(r => r.status === 'failed');
214
+
215
+ console.log('\n' + '='.repeat(60));
216
+ console.log('Clone Results Summary');
217
+ console.log('='.repeat(60));
218
+ console.log(`${colorize('✓', 'green')} Successful: ${successful.length}`);
219
+ console.log(`${colorize('✗', 'red')} Failed: ${failed.length}`);
220
+ console.log('='.repeat(60));
221
+
222
+ if (failed.length > 0) {
223
+ console.log('\nFailed repositories:');
224
+ failed.forEach(result => {
225
+ const displayName = result.directoryName !== result.repo.name
226
+ ? `${result.directoryName} (${result.repo.name})`
227
+ : result.repo.name;
228
+ console.log(` ${colorize('✗', 'red')} ${displayName}: ${result.error}`);
229
+ });
230
+ }
231
+
232
+ console.log('');
233
+ };
@@ -0,0 +1,171 @@
1
+ import { exec } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import * as path from 'path';
4
+ import { GitStatus } from '../types';
5
+
6
+ const execAsync = promisify(exec);
7
+ const GIT_TIMEOUT = 10000; // 10 seconds per git operation
8
+
9
+ export const getGitStatus = async (repoPath: string, repoName: string): Promise<GitStatus> => {
10
+ try {
11
+ const results = await Promise.allSettled([
12
+ getCurrentBranch(repoPath),
13
+ getUncommittedChanges(repoPath),
14
+ getAheadBehindCounts(repoPath),
15
+ ]);
16
+
17
+ const branch = results[0].status === 'fulfilled' ? results[0].value : 'unknown';
18
+ const changes = results[1].status === 'fulfilled' ? results[1].value : { clean: false, modifiedFiles: 0, untrackedFiles: 0 };
19
+ const tracking = results[2].status === 'fulfilled' ? results[2].value : { ahead: 0, behind: 0, hasRemote: false };
20
+
21
+ return {
22
+ repo: repoName,
23
+ branch,
24
+ clean: changes.clean,
25
+ ahead: tracking.ahead,
26
+ behind: tracking.behind,
27
+ modifiedFiles: changes.modifiedFiles,
28
+ untrackedFiles: changes.untrackedFiles,
29
+ hasRemote: tracking.hasRemote,
30
+ detachedHead: branch.startsWith('HEAD detached'),
31
+ };
32
+ } catch (error) {
33
+ // Return error state
34
+ return {
35
+ repo: repoName,
36
+ branch: 'unknown',
37
+ clean: false,
38
+ ahead: 0,
39
+ behind: 0,
40
+ modifiedFiles: 0,
41
+ untrackedFiles: 0,
42
+ hasRemote: false,
43
+ detachedHead: false,
44
+ };
45
+ }
46
+ };
47
+
48
+ export const getCurrentBranch = async (repoPath: string): Promise<string> => {
49
+ try {
50
+ const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
51
+ cwd: repoPath,
52
+ timeout: GIT_TIMEOUT,
53
+ });
54
+ const branch = stdout.trim();
55
+
56
+ // Check for detached HEAD
57
+ if (branch === 'HEAD') {
58
+ const { stdout: commitHash } = await execAsync('git rev-parse --short HEAD', {
59
+ cwd: repoPath,
60
+ timeout: GIT_TIMEOUT,
61
+ });
62
+ return `HEAD detached at ${commitHash.trim()}`;
63
+ }
64
+
65
+ return branch;
66
+ } catch (error) {
67
+ return 'unknown';
68
+ }
69
+ };
70
+
71
+ export const getUncommittedChanges = async (
72
+ repoPath: string
73
+ ): Promise<{ clean: boolean; modifiedFiles: number; untrackedFiles: number }> => {
74
+ try {
75
+ const { stdout } = await execAsync('git status --porcelain', {
76
+ cwd: repoPath,
77
+ timeout: GIT_TIMEOUT,
78
+ });
79
+
80
+ if (!stdout.trim()) {
81
+ return { clean: true, modifiedFiles: 0, untrackedFiles: 0 };
82
+ }
83
+
84
+ const lines = stdout.trim().split('\n');
85
+ let modifiedFiles = 0;
86
+ let untrackedFiles = 0;
87
+
88
+ for (const line of lines) {
89
+ const status = line.substring(0, 2);
90
+ if (status.includes('?')) {
91
+ untrackedFiles++;
92
+ } else {
93
+ modifiedFiles++;
94
+ }
95
+ }
96
+
97
+ return { clean: false, modifiedFiles, untrackedFiles };
98
+ } catch (error) {
99
+ return { clean: false, modifiedFiles: 0, untrackedFiles: 0 };
100
+ }
101
+ };
102
+
103
+ export const getAheadBehindCounts = async (
104
+ repoPath: string
105
+ ): Promise<{ ahead: number; behind: number; hasRemote: boolean }> => {
106
+ try {
107
+ // First check if there's a remote tracking branch
108
+ const { stdout: upstreamBranch } = await execAsync('git rev-parse --abbrev-ref @{upstream}', {
109
+ cwd: repoPath,
110
+ timeout: GIT_TIMEOUT,
111
+ });
112
+
113
+ if (!upstreamBranch.trim()) {
114
+ return { ahead: 0, behind: 0, hasRemote: false };
115
+ }
116
+
117
+ // Get ahead/behind counts
118
+ const { stdout } = await execAsync('git rev-list --count --left-right @{upstream}...HEAD', {
119
+ cwd: repoPath,
120
+ timeout: GIT_TIMEOUT,
121
+ });
122
+
123
+ const counts = stdout.trim().split('\t');
124
+ const behind = parseInt(counts[0], 10) || 0;
125
+ const ahead = parseInt(counts[1], 10) || 0;
126
+
127
+ return { ahead, behind, hasRemote: true };
128
+ } catch (error) {
129
+ // No remote tracking branch
130
+ return { ahead: 0, behind: 0, hasRemote: false };
131
+ }
132
+ };
133
+
134
+ export const getAllReposStatus = async (
135
+ workspacePath: string,
136
+ repoNames: string[],
137
+ concurrency: number = 3
138
+ ): Promise<GitStatus[]> => {
139
+ const results: GitStatus[] = [];
140
+
141
+ // Process in chunks for controlled parallelism
142
+ for (let i = 0; i < repoNames.length; i += concurrency) {
143
+ const chunk = repoNames.slice(i, i + concurrency);
144
+ const chunkResults = await Promise.all(
145
+ chunk.map(repoName => {
146
+ const repoPath = path.join(workspacePath, repoName);
147
+ return getGitStatus(repoPath, repoName);
148
+ })
149
+ );
150
+ results.push(...chunkResults);
151
+ }
152
+
153
+ return results;
154
+ };
155
+
156
+ export const hasUncommittedChanges = async (repoPath: string): Promise<boolean> => {
157
+ const { clean } = await getUncommittedChanges(repoPath);
158
+ return !clean;
159
+ };
160
+
161
+ export const isGitRepository = async (repoPath: string): Promise<boolean> => {
162
+ try {
163
+ await execAsync('git rev-parse --git-dir', {
164
+ cwd: repoPath,
165
+ timeout: GIT_TIMEOUT,
166
+ });
167
+ return true;
168
+ } catch (error) {
169
+ return false;
170
+ }
171
+ };