gitforest 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.
Files changed (256) hide show
  1. package/.bunignore +7 -0
  2. package/.github/workflows/ci.yml +73 -0
  3. package/CLAUDE.md +111 -0
  4. package/CONTRIBUTING.md +145 -0
  5. package/README.md +168 -0
  6. package/bun.lock +267 -0
  7. package/bunfig.toml +15 -0
  8. package/cli +0 -0
  9. package/config/gitforest.example.yaml +94 -0
  10. package/docs/ai/IMPROVEMENT_PLAN.md +341 -0
  11. package/docs/ai/VERIFICATION_REPORT.md +87 -0
  12. package/docs/ai/architecture.md +169 -0
  13. package/docs/ai/checks/check-2025-12-02-tests.md +40 -0
  14. package/docs/ai/checks/check-2025-12-02.md +55 -0
  15. package/docs/ai/checks/test-verification-report.md +85 -0
  16. package/docs/ai/implementation-guide.md +776 -0
  17. package/docs/ai/research/gitty-codebase-analysis.md +221 -0
  18. package/docs/ai/tickets/GENERAL-sitrep.md +30 -0
  19. package/docs/ai/tickets/TASK-database-tests-sitrep.md +25 -0
  20. package/docs/ai/tickets/TASK-deprecated-functions-sitrep.md +28 -0
  21. package/docs/ai/tickets/TASK-detail-modal-sitrep.md +28 -0
  22. package/docs/ai/tickets/TASK-filter-overlay-sitrep.md +24 -0
  23. package/docs/ai/tickets/TASK-github-service-sitrep.md +32 -0
  24. package/docs/ai/tickets/TASK-github-token-sitrep.md +51 -0
  25. package/docs/ai/tickets/TASK-hascommits-sitrep.md +35 -0
  26. package/docs/ai/tickets/TASK-keybindings-sitrep.md +26 -0
  27. package/docs/ai/tickets/TASK-layout-sitrep.md +25 -0
  28. package/docs/ai/tickets/TASK-markdown-sitrep.md +28 -0
  29. package/docs/ai/tickets/TASK-project-item-sitrep.md +79 -0
  30. package/docs/ai/tickets/TASK-sitrep.md +28 -0
  31. package/docs/ai/tickets/TASK-state-sitrep.md +26 -0
  32. package/docs/ai/tickets/TASK-types-sitrep.md +25 -0
  33. package/docs/ai/tickets/TASK-unified-item-fix-sitrep.md +26 -0
  34. package/docs/ai/tickets/TKT-001-sitrep.md +24 -0
  35. package/docs/ai/tickets/TKT-002-sitrep.md +25 -0
  36. package/docs/ai/tickets/TKT-003-git-service-refactoring-complete.md +46 -0
  37. package/docs/ai/tickets/TKT-003-git-service-refactoring-plan.md +135 -0
  38. package/docs/ai/tickets/TKT-003-sitrep.md +26 -0
  39. package/docs/ai/tickets/TKT-004-sitrep.md +27 -0
  40. package/docs/ai/tickets/TKT-005-sitrep.md +25 -0
  41. package/docs/ai/tickets/TKT-006-sitrep.md +26 -0
  42. package/docs/ai/tickets/TKT-007-sitrep.md +30 -0
  43. package/docs/ai/tickets/TKT-008-sitrep.md +32 -0
  44. package/docs/ai/tickets/TKT-009-sitrep.md +27 -0
  45. package/docs/ai/tickets/TKT-010-sitrep.md +27 -0
  46. package/docs/ai/tickets/TKT-011-sitrep.md +26 -0
  47. package/docs/ai/tickets/TKT-012-sitrep.md +25 -0
  48. package/docs/ai/tickets/sitreps/TASK-actions-sitrep.md +28 -0
  49. package/docs/ai/tickets/sitreps/TASK-actions-test-sitrep.md +25 -0
  50. package/docs/ai/tickets/sitreps/TASK-app-integration-sitrep.md +25 -0
  51. package/docs/ai/tickets/sitreps/TASK-background-fetch-sitrep.md +24 -0
  52. package/docs/ai/tickets/sitreps/TASK-background-fetch-test-sitrep.md +29 -0
  53. package/docs/ai/tickets/sitreps/TASK-batch-tests-sitrep.md +29 -0
  54. package/docs/ai/tickets/sitreps/TASK-bun-test-sitrep.md +26 -0
  55. package/docs/ai/tickets/sitreps/TASK-cache-tests-sitrep.md +30 -0
  56. package/docs/ai/tickets/sitreps/TASK-cli-tests-sitrep.md +28 -0
  57. package/docs/ai/tickets/sitreps/TASK-clone-error-handling-sitrep.md +26 -0
  58. package/docs/ai/tickets/sitreps/TASK-commands-tests-sitrep.md +25 -0
  59. package/docs/ai/tickets/sitreps/TASK-component-tests-1-sitrep.md +30 -0
  60. package/docs/ai/tickets/sitreps/TASK-configloader-tests-sitrep.md +25 -0
  61. package/docs/ai/tickets/sitreps/TASK-confirm-dialog-test-sitrep.md +29 -0
  62. package/docs/ai/tickets/sitreps/TASK-coverage-sitrep.md +95 -0
  63. package/docs/ai/tickets/sitreps/TASK-database-tests-summary.md +61 -0
  64. package/docs/ai/tickets/sitreps/TASK-error-boundary-sitrep.md +30 -0
  65. package/docs/ai/tickets/sitreps/TASK-error-tests-sitrep.md +27 -0
  66. package/docs/ai/tickets/sitreps/TASK-errors-tests-sitrep.md +25 -0
  67. package/docs/ai/tickets/sitreps/TASK-extract-reducer-sitrep.md +27 -0
  68. package/docs/ai/tickets/sitreps/TASK-filter-overlay-test-sitrep.md +25 -0
  69. package/docs/ai/tickets/sitreps/TASK-final-verification-sitrep.md +28 -0
  70. package/docs/ai/tickets/sitreps/TASK-fix-all-tests-sitrep.md +25 -0
  71. package/docs/ai/tickets/sitreps/TASK-fix-hooks-sitrep.md +26 -0
  72. package/docs/ai/tickets/sitreps/TASK-fix-remaining-tests-sitrep.md +25 -0
  73. package/docs/ai/tickets/sitreps/TASK-fix-test-failures-sitrep.md +26 -0
  74. package/docs/ai/tickets/sitreps/TASK-fix-tests-sitrep.md +24 -0
  75. package/docs/ai/tickets/sitreps/TASK-formatters-tests-sitrep.md +25 -0
  76. package/docs/ai/tickets/sitreps/TASK-git-timeouts-sitrep.md +29 -0
  77. package/docs/ai/tickets/sitreps/TASK-github-cache-test-sitrep.md +25 -0
  78. package/docs/ai/tickets/sitreps/TASK-githubcli-tests-sitrep.md +24 -0
  79. package/docs/ai/tickets/sitreps/TASK-gitstatus-tests-sitrep.md +24 -0
  80. package/docs/ai/tickets/sitreps/TASK-hooks-isolation-sitrep.md +27 -0
  81. package/docs/ai/tickets/sitreps/TASK-keybindings-tests-sitrep.md +25 -0
  82. package/docs/ai/tickets/sitreps/TASK-layout-tests-sitrep.md +25 -0
  83. package/docs/ai/tickets/sitreps/TASK-mock-factories-sitrep.md +27 -0
  84. package/docs/ai/tickets/sitreps/TASK-modal-tests-sitrep.md +32 -0
  85. package/docs/ai/tickets/sitreps/TASK-processbatch-fix-sitrep.md +27 -0
  86. package/docs/ai/tickets/sitreps/TASK-projectlist-tests-sitrep.md +30 -0
  87. package/docs/ai/tickets/sitreps/TASK-projectutils-tests-sitrep.md +25 -0
  88. package/docs/ai/tickets/sitreps/TASK-scanner-tests-sitrep.md +29 -0
  89. package/docs/ai/tickets/sitreps/TASK-select-all-sitrep.md +25 -0
  90. package/docs/ai/tickets/sitreps/TASK-shell-error-handling-sitrep.md +27 -0
  91. package/docs/ai/tickets/sitreps/TASK-store-tests-sitrep.md +25 -0
  92. package/docs/ai/tickets/sitreps/TASK-test-fixes-sitrep.md +26 -0
  93. package/docs/ai/tickets/sitreps/TASK-test-summary-sitrep.md +25 -0
  94. package/docs/ai/tickets/sitreps/TASK-test-verification-sitrep.md +27 -0
  95. package/docs/ai/tickets/sitreps/TASK-testsuite-sitrep.md +75 -0
  96. package/docs/ai/tickets/sitreps/TASK-unified-reducer-tests-sitrep.md +29 -0
  97. package/docs/ai/tickets/sitreps/TASK-unified-repos-test-sitrep.md +29 -0
  98. package/docs/ai/tickets/sitreps/TASK-unified-tests-sitrep.md +25 -0
  99. package/docs/ai/tickets/sitreps/TASK-useprojects-tests-sitrep.md +25 -0
  100. package/docs/ai/tickets/sitreps/TASK-utility-tests-sitrep.md +32 -0
  101. package/docs/ai/tickets/sitreps/TKT-003-git-service-refactoring-sitrep.md +64 -0
  102. package/docs/ai/tkt-001-fix-database-error.md +217 -0
  103. package/docs/ai/ui-enhancement-plan.md +562 -0
  104. package/package.json +50 -0
  105. package/src/app.tsx +43 -0
  106. package/src/cli/config.ts +94 -0
  107. package/src/cli/formatters.ts +632 -0
  108. package/src/cli/index.ts +583 -0
  109. package/src/components/CloneDialog.tsx +137 -0
  110. package/src/components/ColumnHeader.tsx +128 -0
  111. package/src/components/CommandPalette.tsx +120 -0
  112. package/src/components/ConfirmDialog.tsx +105 -0
  113. package/src/components/ErrorBoundary.tsx +128 -0
  114. package/src/components/FilterBar.tsx +71 -0
  115. package/src/components/FilterOptionsOverlay.tsx +131 -0
  116. package/src/components/HelpOverlay.tsx +120 -0
  117. package/src/components/Layout.tsx +379 -0
  118. package/src/components/MarkdownRenderer.tsx +127 -0
  119. package/src/components/ProgressBar.tsx +53 -0
  120. package/src/components/ProjectItem.tsx +143 -0
  121. package/src/components/ProjectList.tsx +90 -0
  122. package/src/components/RepoDetailModal.tsx +367 -0
  123. package/src/components/StatusBar.tsx +188 -0
  124. package/src/components/UnifiedProjectItem.tsx +436 -0
  125. package/src/components/ViewModeIndicator.tsx +37 -0
  126. package/src/components/onboarding/CompleteStep.tsx +82 -0
  127. package/src/components/onboarding/DirectoriesStep.test.tsx +52 -0
  128. package/src/components/onboarding/DirectoriesStep.tsx +847 -0
  129. package/src/components/onboarding/DirectoriesStep.unit.test.ts +345 -0
  130. package/src/components/onboarding/GitHubAuthStep.tsx +268 -0
  131. package/src/components/onboarding/OnboardingWizard.tsx +130 -0
  132. package/src/components/onboarding/WelcomeStep.tsx +69 -0
  133. package/src/config/loader.ts +263 -0
  134. package/src/config/onboarding.ts +67 -0
  135. package/src/constants.ts +96 -0
  136. package/src/db/index.ts +147 -0
  137. package/src/db/schema.ts +70 -0
  138. package/src/git/commands.ts +283 -0
  139. package/src/git/index.ts +2 -0
  140. package/src/git/operations.ts +93 -0
  141. package/src/git/service.ts +539 -0
  142. package/src/git/status.ts +84 -0
  143. package/src/git/types.ts +5 -0
  144. package/src/github/auth.ts +311 -0
  145. package/src/github/cache.ts +231 -0
  146. package/src/github/cli.ts +22 -0
  147. package/src/github/unified.ts +415 -0
  148. package/src/hooks/useBackgroundFetch.ts +76 -0
  149. package/src/hooks/useConfirmDialogActions.ts +120 -0
  150. package/src/hooks/useKeyBindings.ts +656 -0
  151. package/src/hooks/useProjects.ts +47 -0
  152. package/src/hooks/useUnifiedRepos.ts +317 -0
  153. package/src/index.tsx +494 -0
  154. package/src/operations/batch.ts +280 -0
  155. package/src/operations/commands.ts +140 -0
  156. package/src/operations/index.ts +37 -0
  157. package/src/scanner/index.ts +424 -0
  158. package/src/scanner/markers.ts +43 -0
  159. package/src/scanner/submodules.ts +61 -0
  160. package/src/services/git.ts +484 -0
  161. package/src/services/github.ts +676 -0
  162. package/src/services/index.ts +28 -0
  163. package/src/services/types.ts +99 -0
  164. package/src/state/actions.ts +175 -0
  165. package/src/state/reducer.ts +294 -0
  166. package/src/state/store.tsx +216 -0
  167. package/src/state/types.ts +8 -0
  168. package/src/types/index.ts +383 -0
  169. package/src/ui/theme.ts +44 -0
  170. package/src/utils/array.ts +14 -0
  171. package/src/utils/debug.ts +38 -0
  172. package/src/utils/errors.ts +17 -0
  173. package/src/utils/index.ts +8 -0
  174. package/src/utils/markdown.ts +230 -0
  175. package/src/utils/project-utils.ts +129 -0
  176. package/src/utils/rate-limiter.ts +134 -0
  177. package/src/utils/retry.ts +147 -0
  178. package/src/utils/timeout.ts +56 -0
  179. package/test/integration/app.isolated.tsx +240 -0
  180. package/test/integration/cli-commands.test.ts +287 -0
  181. package/test/integration/cli-validation.test.ts +264 -0
  182. package/test/integration/git-operations.test.ts +218 -0
  183. package/test/integration/scanner.test.ts +228 -0
  184. package/test/preload.ts +18 -0
  185. package/test/unit/cli/commands.test.ts +13 -0
  186. package/test/unit/cli/formatters.test.ts +1116 -0
  187. package/test/unit/cli/github-commands.test.ts +12 -0
  188. package/test/unit/components/CloneDialog.test.tsx +240 -0
  189. package/test/unit/components/ColumnHeader.test.tsx +128 -0
  190. package/test/unit/components/CommandPalette.test.tsx +355 -0
  191. package/test/unit/components/ConfirmDialog.test.tsx +111 -0
  192. package/test/unit/components/ErrorBoundary.test.tsx +139 -0
  193. package/test/unit/components/FilterBar.test.tsx +43 -0
  194. package/test/unit/components/FilterOptionsOverlay.test.tsx +197 -0
  195. package/test/unit/components/HelpOverlay.test.tsx +90 -0
  196. package/test/unit/components/Layout.test.tsx +328 -0
  197. package/test/unit/components/MarkdownRenderer.test.tsx +45 -0
  198. package/test/unit/components/ProgressBar.test.tsx +138 -0
  199. package/test/unit/components/ProjectItem.test.tsx +182 -0
  200. package/test/unit/components/ProjectList.test.tsx +311 -0
  201. package/test/unit/components/RepoDetailModal.test.tsx +445 -0
  202. package/test/unit/components/StatusBar.test.tsx +112 -0
  203. package/test/unit/components/UnifiedProjectItem.test.tsx +618 -0
  204. package/test/unit/components/ViewModeIndicator.test.tsx +137 -0
  205. package/test/unit/components/test-utils.tsx +63 -0
  206. package/test/unit/config/loader.test.ts +692 -0
  207. package/test/unit/db/database.test.ts +978 -0
  208. package/test/unit/db/index.test.ts +314 -0
  209. package/test/unit/fixtures/setup.ts +186 -0
  210. package/test/unit/git/commands-untested.test.ts +205 -0
  211. package/test/unit/git/commands.test.ts +269 -0
  212. package/test/unit/git/operations.test.ts +322 -0
  213. package/test/unit/git/status.test.ts +219 -0
  214. package/test/unit/github/auth.test.ts +317 -0
  215. package/test/unit/github/cache.test.ts +1028 -0
  216. package/test/unit/github/cli.test.ts +135 -0
  217. package/test/unit/github/unified.test.ts +1201 -0
  218. package/test/unit/graceful-shutdown.test.ts +83 -0
  219. package/test/unit/hooks/useBackgroundFetch.test.tsx +239 -0
  220. package/test/unit/hooks/useConfirmDialogActions.test.tsx +81 -0
  221. package/test/unit/hooks/useKeyBindings.isolated.ts +715 -0
  222. package/test/unit/hooks/useProjects.test.tsx +186 -0
  223. package/test/unit/hooks/useUnifiedRepos-simple.test.tsx +115 -0
  224. package/test/unit/hooks/useUnifiedRepos.test.tsx +177 -0
  225. package/test/unit/mocks/config.ts +109 -0
  226. package/test/unit/mocks/git-service.ts +274 -0
  227. package/test/unit/mocks/github-service.ts +250 -0
  228. package/test/unit/mocks/index.ts +72 -0
  229. package/test/unit/mocks/project.ts +148 -0
  230. package/test/unit/mocks/state-mocks.ts +187 -0
  231. package/test/unit/mocks/unified.ts +169 -0
  232. package/test/unit/operations/batch.test.ts +216 -0
  233. package/test/unit/operations/commands.test.ts +550 -0
  234. package/test/unit/scanner/errors.test.ts +297 -0
  235. package/test/unit/scanner/index.test.ts +1011 -0
  236. package/test/unit/scanner/markers.test.ts +150 -0
  237. package/test/unit/scanner/submodules.test.ts +99 -0
  238. package/test/unit/services/git-errors.test.ts +190 -0
  239. package/test/unit/services/git.test.ts +442 -0
  240. package/test/unit/services/github-errors.test.ts +293 -0
  241. package/test/unit/services/github.test.ts +200 -0
  242. package/test/unit/state/actions.test.ts +217 -0
  243. package/test/unit/state/reducer.test.ts +745 -0
  244. package/test/unit/state/store.test.tsx +711 -0
  245. package/test/unit/types/commands.test.ts +220 -0
  246. package/test/unit/types/schema.test.ts +179 -0
  247. package/test/unit/utils/array.test.ts +73 -0
  248. package/test/unit/utils/debug.test.ts +23 -0
  249. package/test/unit/utils/errors.test.ts +295 -0
  250. package/test/unit/utils/markdown.test.ts +163 -0
  251. package/test/unit/utils/project-utils.test.ts +756 -0
  252. package/test/unit/utils/rate-limiter.test.ts +256 -0
  253. package/test/unit/utils/retry.test.ts +165 -0
  254. package/test/unit/utils/strip-ansi.ts +13 -0
  255. package/test/unit/utils/timeout.test.ts +93 -0
  256. package/tsconfig.json +29 -0
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Batch operations orchestrator
3
+ * Uses service abstractions for testability
4
+ */
5
+
6
+ import type { Project, BatchResult, OperationResult } from "../types/index.ts";
7
+ import type { GitService } from "../services/git.ts";
8
+ import { bunGitService } from "../services/git.ts";
9
+ import { chunk } from "../utils/array.ts";
10
+ import { processBatch } from "../utils/rate-limiter.ts";
11
+ import { GIT } from "../constants.ts";
12
+
13
+ export type ProgressCallback = (completed: number, total: number) => void;
14
+
15
+ export interface BatchOptions {
16
+ concurrency?: number;
17
+ minDelay?: number;
18
+ onProgress?: ProgressCallback;
19
+ gitService?: GitService;
20
+ }
21
+
22
+ /**
23
+ * Filter projects that can be pulled (git with remote)
24
+ */
25
+ function getPullableProjects(projects: Project[]): Project[] {
26
+ return projects.filter((p) => p.type === "git" && p.status?.hasRemote);
27
+ }
28
+
29
+ /**
30
+ * Filter projects that need pushing (git with remote and ahead)
31
+ */
32
+ function getPushableProjects(projects: Project[]): Project[] {
33
+ return projects.filter(
34
+ (p) => p.type === "git" && p.status?.hasRemote && p.status?.isAhead
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Filter projects that can be fetched (git with remote)
40
+ */
41
+ function getFetchableProjects(projects: Project[]): Project[] {
42
+ return projects.filter((p) => p.type === "git" && p.status?.hasRemote);
43
+ }
44
+
45
+ /**
46
+ * Pull changes for multiple projects with rate limiting
47
+ *
48
+ * Filters projects to only those that are git repos with remotes,
49
+ * then pulls changes in parallel with configurable concurrency.
50
+ *
51
+ * @param projects - Array of projects to pull
52
+ * @param options - Batch operation options
53
+ * @param options.concurrency - Maximum concurrent operations (default: 5)
54
+ * @param options.onProgress - Progress callback (completed, total)
55
+ * @param options.gitService - Git service implementation (for testing)
56
+ * @returns Promise resolving to batch result with success/failure counts
57
+ *
58
+ * @example
59
+ * ```typescript
60
+ * const result = await batchPull(projects, {
61
+ * concurrency: 3,
62
+ * onProgress: (done, total) => console.log(`${done}/${total}`)
63
+ * });
64
+ * console.log(`Pulled ${result.successful}/${result.total} repos`);
65
+ * ```
66
+ */
67
+ export async function batchPull(
68
+ projects: Project[],
69
+ options: BatchOptions = {}
70
+ ): Promise<BatchResult> {
71
+ const { concurrency = GIT.DEFAULT_CONCURRENCY, minDelay = 0, onProgress, gitService = bunGitService } = options;
72
+ const start = Date.now();
73
+ const pullable = getPullableProjects(projects);
74
+
75
+ const batchResults = await processBatch(
76
+ pullable,
77
+ (p) => gitService.pull(p.path),
78
+ {
79
+ concurrency,
80
+ minDelay,
81
+ onProgress,
82
+ }
83
+ );
84
+
85
+ // Transform BatchItemResult<OperationResult>[] to OperationResult[]
86
+ const results: OperationResult[] = batchResults.map((batchResult, index) => {
87
+ if (batchResult.success) {
88
+ return batchResult.result!;
89
+ } else {
90
+ // Create a failed OperationResult for this project
91
+ const project = pullable[index]!;
92
+ return {
93
+ success: false,
94
+ projectPath: project.path,
95
+ operation: "pull",
96
+ error: batchResult.error?.message,
97
+ duration: 0,
98
+ };
99
+ }
100
+ });
101
+
102
+ const successful = results.filter((r) => r.success).length;
103
+
104
+ return {
105
+ total: pullable.length,
106
+ successful,
107
+ failed: pullable.length - successful,
108
+ results,
109
+ duration: Date.now() - start,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Push changes for multiple projects with rate limiting
115
+ *
116
+ * Filters projects to only those that are git repos with remotes and have unpushed commits,
117
+ * then pushes changes in parallel with configurable concurrency.
118
+ *
119
+ * @param projects - Array of projects to push
120
+ * @param options - Batch operation options
121
+ * @param options.concurrency - Maximum concurrent operations (default: 5)
122
+ * @param options.onProgress - Progress callback (completed, total)
123
+ * @param options.gitService - Git service implementation (for testing)
124
+ * @returns Promise resolving to batch result with success/failure counts
125
+ *
126
+ * @example
127
+ * ```typescript
128
+ * const result = await batchPush(projects, {
129
+ * concurrency: 2,
130
+ * onProgress: (done, total) => console.log(`${done}/${total}`)
131
+ * });
132
+ * console.log(`Pushed ${result.successful}/${result.total} repos`);
133
+ * ```
134
+ */
135
+ export async function batchPush(
136
+ projects: Project[],
137
+ options: BatchOptions = {}
138
+ ): Promise<BatchResult> {
139
+ const { concurrency = GIT.DEFAULT_CONCURRENCY, minDelay = 0, onProgress, gitService = bunGitService } = options;
140
+ const start = Date.now();
141
+ const pushable = getPushableProjects(projects);
142
+
143
+ const batchResults = await processBatch(
144
+ pushable,
145
+ (p) => gitService.push(p.path),
146
+ {
147
+ concurrency,
148
+ minDelay,
149
+ onProgress,
150
+ }
151
+ );
152
+
153
+ // Transform BatchItemResult<OperationResult>[] to OperationResult[]
154
+ const results: OperationResult[] = batchResults.map((batchResult, index) => {
155
+ if (batchResult.success) {
156
+ return batchResult.result!;
157
+ } else {
158
+ // Create a failed OperationResult for this project
159
+ const project = pushable[index]!;
160
+ return {
161
+ success: false,
162
+ projectPath: project.path,
163
+ operation: "push",
164
+ error: batchResult.error?.message,
165
+ duration: 0,
166
+ };
167
+ }
168
+ });
169
+
170
+ const successful = results.filter((r) => r.success).length;
171
+
172
+ return {
173
+ total: pushable.length,
174
+ successful,
175
+ failed: pushable.length - successful,
176
+ results,
177
+ duration: Date.now() - start,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Fetch all remotes for multiple projects with rate limiting
183
+ *
184
+ * Filters projects to only those that are git repos with remotes,
185
+ * then fetches updates in parallel with configurable concurrency.
186
+ *
187
+ * @param projects - Array of projects to fetch
188
+ * @param options - Batch operation options
189
+ * @param options.concurrency - Maximum concurrent operations (default: 5)
190
+ * @param options.onProgress - Progress callback (completed, total)
191
+ * @param options.gitService - Git service implementation (for testing)
192
+ * @returns Promise resolving to batch result with success/failure counts
193
+ *
194
+ * @example
195
+ * ```typescript
196
+ * const result = await batchFetch(projects, {
197
+ * concurrency: 4,
198
+ * onProgress: (done, total) => console.log(`${done}/${total}`)
199
+ * });
200
+ * console.log(`Fetched ${result.successful}/${result.total} repos`);
201
+ * ```
202
+ */
203
+ export async function batchFetch(
204
+ projects: Project[],
205
+ options: BatchOptions = {}
206
+ ): Promise<BatchResult> {
207
+ const { concurrency = GIT.DEFAULT_CONCURRENCY, minDelay = 0, onProgress, gitService = bunGitService } = options;
208
+ const start = Date.now();
209
+ const fetchable = getFetchableProjects(projects);
210
+
211
+ const batchResults = await processBatch(
212
+ fetchable,
213
+ (p) => gitService.fetchAll(p.path),
214
+ {
215
+ concurrency,
216
+ minDelay,
217
+ onProgress,
218
+ }
219
+ );
220
+
221
+ // Transform BatchItemResult<OperationResult>[] to OperationResult[]
222
+ const results: OperationResult[] = batchResults.map((batchResult, index) => {
223
+ if (batchResult.success) {
224
+ return batchResult.result!;
225
+ } else {
226
+ // Create a failed OperationResult for this project
227
+ const project = fetchable[index]!;
228
+ return {
229
+ success: false,
230
+ projectPath: project.path,
231
+ operation: "fetch",
232
+ error: batchResult.error?.message,
233
+ duration: 0,
234
+ };
235
+ }
236
+ });
237
+
238
+ const successful = results.filter((r) => r.success).length;
239
+
240
+ return {
241
+ total: fetchable.length,
242
+ successful,
243
+ failed: fetchable.length - successful,
244
+ results,
245
+ duration: Date.now() - start,
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Refresh git status for multiple projects
251
+ */
252
+ export async function batchRefreshStatus(
253
+ projects: Project[],
254
+ options: BatchOptions = {}
255
+ ): Promise<Map<string, Project>> {
256
+ const { concurrency = GIT.STATUS_REFRESH_CONCURRENCY, gitService = bunGitService } = options;
257
+ const gitProjects = projects.filter((p) => p.type !== "non-git");
258
+ const results = new Map<string, Project>();
259
+
260
+ const batches = chunk(gitProjects, concurrency);
261
+
262
+ for (const batch of batches) {
263
+ const updates = await Promise.all(
264
+ batch.map(async (p) => {
265
+ try {
266
+ const status = await gitService.getStatus(p.path);
267
+ return { ...p, status, lastScanned: new Date() };
268
+ } catch {
269
+ return p;
270
+ }
271
+ })
272
+ );
273
+
274
+ for (const p of updates) {
275
+ results.set(p.id, p);
276
+ }
277
+ }
278
+
279
+ return results;
280
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Custom command execution module
3
+ *
4
+ * Executes user-configured commands in project directories
5
+ */
6
+
7
+ import type { CommandConfig } from "../types/index.ts";
8
+
9
+ export interface CommandResult {
10
+ success: boolean;
11
+ command: string;
12
+ projectPath: string;
13
+ output: string;
14
+ error?: string;
15
+ duration: number;
16
+ }
17
+
18
+ /**
19
+ * Execute a custom command in a project directory
20
+ */
21
+ export async function executeCommand(
22
+ command: CommandConfig,
23
+ projectPath: string,
24
+ options: { background?: boolean } = {}
25
+ ): Promise<CommandResult> {
26
+ const startTime = Date.now();
27
+ const { background = command.background } = options;
28
+
29
+ try {
30
+ // Expand environment variables in the command
31
+ const expandedCommand = expandEnvVars(command.command, projectPath);
32
+
33
+ if (background) {
34
+ // Run in background - don't wait for completion
35
+ Bun.spawn(["sh", "-c", expandedCommand], {
36
+ cwd: projectPath,
37
+ stdout: "ignore",
38
+ stderr: "ignore",
39
+ });
40
+
41
+ return {
42
+ success: true,
43
+ command: command.name,
44
+ projectPath,
45
+ output: "Started in background",
46
+ duration: Date.now() - startTime,
47
+ };
48
+ }
49
+
50
+ // Run and wait for completion
51
+ const result = await Bun.$`sh -c ${expandedCommand}`.cwd(projectPath).quiet().nothrow();
52
+
53
+ const output = result.stdout.toString().trim();
54
+ const stderr = result.stderr.toString().trim();
55
+
56
+ if (result.exitCode !== 0) {
57
+ return {
58
+ success: false,
59
+ command: command.name,
60
+ projectPath,
61
+ output,
62
+ error: stderr || `Command exited with code ${result.exitCode}`,
63
+ duration: Date.now() - startTime,
64
+ };
65
+ }
66
+
67
+ return {
68
+ success: true,
69
+ command: command.name,
70
+ projectPath,
71
+ output,
72
+ duration: Date.now() - startTime,
73
+ };
74
+ } catch (error) {
75
+ return {
76
+ success: false,
77
+ command: command.name,
78
+ projectPath,
79
+ output: "",
80
+ error: error instanceof Error ? error.message : String(error),
81
+ duration: Date.now() - startTime,
82
+ };
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Expand environment variables in a command string
88
+ * Supports $VAR and ${VAR} syntax
89
+ * Special handling for $PWD which gets replaced with projectPath
90
+ */
91
+ function expandEnvVars(command: string, projectPath: string): string {
92
+ return command.replace(/\$\{?(\w+)\}?/g, (match, varName) => {
93
+ // Special case: $PWD should be the project path, not the current process's PWD
94
+ if (varName === "PWD") {
95
+ return projectPath;
96
+ }
97
+ // For other env vars, use process.env or leave unchanged if not found
98
+ return process.env[varName] ?? match;
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Find a command by its key from the config
104
+ */
105
+ export function findCommandByKey(
106
+ commands: CommandConfig[],
107
+ key: string
108
+ ): CommandConfig | undefined {
109
+ if (!commands || !Array.isArray(commands)) {
110
+ return undefined;
111
+ }
112
+ return commands.find((cmd) => cmd.key === key);
113
+ }
114
+
115
+ /**
116
+ * Execute commands on multiple projects
117
+ */
118
+ export async function batchExecuteCommand(
119
+ command: CommandConfig,
120
+ projectPaths: string[],
121
+ options: {
122
+ concurrency?: number;
123
+ onProgress?: (current: number, total: number) => void;
124
+ } = {}
125
+ ): Promise<CommandResult[]> {
126
+ const { concurrency = 5, onProgress } = options;
127
+ const results: CommandResult[] = [];
128
+
129
+ // Process in batches
130
+ for (let i = 0; i < projectPaths.length; i += concurrency) {
131
+ const batch = projectPaths.slice(i, i + concurrency);
132
+ const batchResults = await Promise.all(
133
+ batch.map((path) => executeCommand(command, path))
134
+ );
135
+ results.push(...batchResults);
136
+ onProgress?.(Math.min(i + concurrency, projectPaths.length), projectPaths.length);
137
+ }
138
+
139
+ return results;
140
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Operations module - orchestrators for git and batch operations
3
+ *
4
+ * This module provides both low-level operations (single project)
5
+ * and batch orchestrators (multiple projects with progress).
6
+ *
7
+ * For testability, batch operations accept an optional GitService
8
+ * that can be mocked in tests.
9
+ */
10
+
11
+ // Re-export single-project operations from git module
12
+ export {
13
+ initGitInProject,
14
+ pullProject,
15
+ pushProject,
16
+ fetchProject,
17
+ addRemoteToProject,
18
+ refreshProjectStatuses,
19
+ } from "../git/operations.ts";
20
+
21
+ // Export new service-based batch operations
22
+ export {
23
+ batchPull,
24
+ batchPush,
25
+ batchFetch,
26
+ batchRefreshStatus,
27
+ type BatchOptions,
28
+ type ProgressCallback,
29
+ } from "./batch.ts";
30
+
31
+ // Export custom command operations
32
+ export {
33
+ executeCommand,
34
+ findCommandByKey,
35
+ batchExecuteCommand,
36
+ type CommandResult,
37
+ } from "./commands.ts";