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,583 @@
1
+ import type { GitforestConfig, ViewMode } from "../types/index.ts";
2
+ import { scanAllDirectories, sortProjects, filterProjects } from "../scanner/index.ts";
3
+ import { initGitInProject } from "../git/operations.ts";
4
+ import { batchPull, batchPush, batchFetch } from "../operations/batch.ts";
5
+ import { defaultGitHubService } from "../services/github.ts";
6
+ import {
7
+ fetchUnifiedRepos,
8
+ filterByViewMode,
9
+ sortUnifiedRepos,
10
+ filterUnifiedRepos,
11
+ cloneGitHubRepo,
12
+ getUnifiedStats,
13
+ } from "../github/unified.ts";
14
+ import {
15
+ login,
16
+ logout,
17
+ getAuthStatus,
18
+ ensureAuthenticated,
19
+ isGhInstalled,
20
+ } from "../github/auth.ts";
21
+ import {
22
+ formatProjectList,
23
+ formatStatusSummary,
24
+ formatStatusSummaryJson,
25
+ formatBatchResult,
26
+ formatProgress,
27
+ formatDirtyRepos,
28
+ formatUnifiedRepoList,
29
+ formatAuthSuccess,
30
+ formatNoToken,
31
+ formatScanning,
32
+ formatWarning,
33
+ formatError,
34
+ formatInfo,
35
+ formatOperationItem,
36
+ formatOperationSummary,
37
+ formatUnifiedStatusJson,
38
+ formatUnifiedStatusDisplay,
39
+ formatCloneItem,
40
+ calculateStatusSummary,
41
+ } from "./formatters.ts";
42
+
43
+ /**
44
+ * CLI command handlers for non-TUI usage
45
+ */
46
+
47
+ export interface CLIOptions {
48
+ config: GitforestConfig;
49
+ filter?: string;
50
+ json?: boolean;
51
+ verbose?: boolean;
52
+ maxDepth?: number;
53
+ }
54
+
55
+ /**
56
+ * List all projects
57
+ */
58
+ export async function listProjects(options: CLIOptions): Promise<void> {
59
+ const { config, filter, json, verbose } = options;
60
+
61
+ console.log(formatScanning());
62
+ const projects = await scanAllDirectories(config);
63
+ let result = sortProjects(projects, config.display.sortBy, config.display.sortDirection);
64
+
65
+ if (filter) {
66
+ result = filterProjects(result, filter);
67
+ }
68
+
69
+ console.log(formatProjectList(result, { json, verbose }));
70
+ }
71
+
72
+ /**
73
+ * Show status summary
74
+ */
75
+ export async function showStatus(options: CLIOptions): Promise<void> {
76
+ const { config, filter, json } = options;
77
+
78
+ console.log(formatScanning());
79
+ const projects = await scanAllDirectories(config);
80
+ let result = sortProjects(projects, "status", "desc");
81
+
82
+ if (filter) {
83
+ result = filterProjects(result, filter);
84
+ }
85
+
86
+ const summary = calculateStatusSummary(result);
87
+
88
+ if (json) {
89
+ console.log(formatStatusSummaryJson(summary));
90
+ return;
91
+ }
92
+
93
+ console.log(formatStatusSummary(summary));
94
+ }
95
+
96
+ /**
97
+ * Pull all repositories
98
+ */
99
+ export async function pullAll(options: CLIOptions): Promise<void> {
100
+ const { config, filter } = options;
101
+
102
+ console.log(formatScanning());
103
+ const projects = await scanAllDirectories(config);
104
+ let gitProjects = projects.filter((p) => p.type === "git" && p.status?.hasRemote);
105
+
106
+ if (filter) {
107
+ gitProjects = filterProjects(gitProjects, filter);
108
+ }
109
+
110
+ if (gitProjects.length === 0) {
111
+ console.log(formatWarning("No repositories to pull."));
112
+ return;
113
+ }
114
+
115
+ console.log(formatInfo(`\nPulling ${gitProjects.length} repositories...\n`));
116
+
117
+ const result = await batchPull(gitProjects, {
118
+ concurrency: config.scan.concurrency, onProgress: (completed, total) => {
119
+ process.stdout.write(formatProgress(completed, total));
120
+ }
121
+ });
122
+
123
+ console.log("\n");
124
+ console.log(formatBatchResult(result, "Pulled"));
125
+ }
126
+
127
+ /**
128
+ * Push repositories with unpushed commits
129
+ */
130
+ export async function pushAll(options: CLIOptions): Promise<void> {
131
+ const { config, filter } = options;
132
+
133
+ console.log(formatScanning());
134
+ const projects = await scanAllDirectories(config);
135
+ let unpushedProjects = projects.filter(
136
+ (p) => p.type === "git" && p.status?.hasRemote && p.status?.isAhead
137
+ );
138
+
139
+ if (filter) {
140
+ unpushedProjects = filterProjects(unpushedProjects, filter);
141
+ }
142
+
143
+ if (unpushedProjects.length === 0) {
144
+ console.log(formatWarning("No repositories with unpushed commits."));
145
+ return;
146
+ }
147
+
148
+ console.log(formatInfo(`\nPushing ${unpushedProjects.length} repositories...\n`));
149
+
150
+ const result = await batchPush(unpushedProjects, {
151
+ concurrency: config.scan.concurrency, onProgress: (completed, total) => {
152
+ process.stdout.write(formatProgress(completed, total));
153
+ }
154
+ });
155
+
156
+ console.log("\n");
157
+ console.log(formatBatchResult(result, "Pushed"));
158
+ }
159
+
160
+ /**
161
+ * Fetch all repositories
162
+ */
163
+ export async function fetchAll(options: CLIOptions): Promise<void> {
164
+ const { config, filter } = options;
165
+
166
+ console.log(formatScanning());
167
+ const projects = await scanAllDirectories(config);
168
+ let gitProjects = projects.filter((p) => p.type === "git" && p.status?.hasRemote);
169
+
170
+ if (filter) {
171
+ gitProjects = filterProjects(gitProjects, filter);
172
+ }
173
+
174
+ if (gitProjects.length === 0) {
175
+ console.log(formatWarning("No repositories to fetch."));
176
+ return;
177
+ }
178
+
179
+ console.log(formatInfo(`\nFetching ${gitProjects.length} repositories...\n`));
180
+
181
+ const result = await batchFetch(gitProjects, {
182
+ concurrency: config.scan.concurrency, onProgress: (completed, total) => {
183
+ process.stdout.write(formatProgress(completed, total));
184
+ }
185
+ });
186
+
187
+ console.log("\n");
188
+ console.log(formatBatchResult(result, "Fetched"));
189
+ }
190
+
191
+ /**
192
+ * Show dirty repositories
193
+ */
194
+ export async function showDirty(options: CLIOptions): Promise<void> {
195
+ const { config, json } = options;
196
+
197
+ console.log(formatScanning());
198
+ const projects = await scanAllDirectories(config);
199
+ const dirty = projects.filter((p) => p.status?.isDirty);
200
+
201
+ console.log(formatDirtyRepos(dirty, json));
202
+ }
203
+
204
+ /**
205
+ * Initialize git in non-git projects
206
+ */
207
+ export async function initNonGit(options: CLIOptions): Promise<void> {
208
+ const { config, filter } = options;
209
+
210
+ console.log(formatScanning());
211
+ const projects = await scanAllDirectories(config);
212
+ let nonGitProjects = projects.filter((p) => p.type === "non-git");
213
+
214
+ if (filter) {
215
+ nonGitProjects = filterProjects(nonGitProjects, filter);
216
+ }
217
+
218
+ if (nonGitProjects.length === 0) {
219
+ console.log(formatWarning("No non-git projects found."));
220
+ return;
221
+ }
222
+
223
+ console.log(formatInfo(`\nInitializing git in ${nonGitProjects.length} projects...\n`));
224
+
225
+ let success = 0;
226
+ for (const project of nonGitProjects) {
227
+ const result = await initGitInProject(project.path);
228
+ console.log(formatOperationItem(project.name, result.success, result.error));
229
+ if (result.success) success++;
230
+ }
231
+
232
+ console.log(`\n${formatOperationSummary("Initialized", success, nonGitProjects.length)}`);
233
+ }
234
+
235
+ /**
236
+ * Create GitHub repos for projects without remotes
237
+ */
238
+ export async function createGitHubRepos(options: CLIOptions & { isPrivate?: boolean }): Promise<void> {
239
+ const { config, filter, isPrivate = true } = options;
240
+
241
+ // Check GitHub auth - auto-login if not set
242
+ const token = await ensureAuthenticated();
243
+ if (!token) {
244
+ console.log(formatError("GitHub authentication required."));
245
+ console.log(formatInfo("Run 'gitforest login' to authenticate."));
246
+ process.exit(1);
247
+ }
248
+
249
+ console.log(formatScanning());
250
+ const projects = await scanAllDirectories(config);
251
+ let noRemoteProjects = projects.filter((p) => p.type === "git" && !p.status?.hasRemote);
252
+
253
+ if (filter) {
254
+ noRemoteProjects = filterProjects(noRemoteProjects, filter);
255
+ }
256
+
257
+ if (noRemoteProjects.length === 0) {
258
+ console.log(formatWarning("No projects without remotes found."));
259
+ return;
260
+ }
261
+
262
+ console.log(formatInfo(`\nCreating ${isPrivate ? "private" : "public"} repos for ${noRemoteProjects.length} projects...\n`));
263
+
264
+ let success = 0;
265
+ for (const project of noRemoteProjects) {
266
+ const result = await defaultGitHubService.createRepo({
267
+ name: project.name,
268
+ isPrivate,
269
+ localPath: project.path,
270
+ });
271
+
272
+ console.log(formatOperationItem(project.name, result.success, result.error));
273
+ if (result.success) success++;
274
+ }
275
+
276
+ console.log(`\n${formatOperationSummary("Created", success, noRemoteProjects.length)}`);
277
+ }
278
+
279
+ /**
280
+ * Archive GitHub repositories
281
+ */
282
+ export async function archiveRepos(options: CLIOptions & { repos: string[] }): Promise<void> {
283
+ const { repos } = options;
284
+
285
+ // Check GitHub auth - auto-login if not set
286
+ const token = await ensureAuthenticated();
287
+ if (!token) {
288
+ console.log(formatError("GitHub authentication required."));
289
+ console.log(formatInfo("Run 'gitforest login' to authenticate."));
290
+ process.exit(1);
291
+ }
292
+
293
+ console.log(formatInfo(`\nArchiving ${repos.length} repositories...\n`));
294
+
295
+ let success = 0;
296
+ for (const repo of repos) {
297
+ const result = await defaultGitHubService.archiveRepo(repo);
298
+ console.log(formatOperationItem(repo, result.success, result.error));
299
+ if (result.success) success++;
300
+ }
301
+
302
+ console.log(`\n${formatOperationSummary("Archived", success, repos.length)}`);
303
+ }
304
+
305
+ /**
306
+ * List GitHub repositories (not cloned locally)
307
+ */
308
+ export async function listGitHubRepos(options: CLIOptions & { view?: ViewMode }): Promise<void> {
309
+ const { config, filter, json, verbose, view = "github" } = options;
310
+
311
+ // Check for GitHub token - auto-login if not set
312
+ const token = await ensureAuthenticated();
313
+ if (!token) {
314
+ console.log(formatError("GitHub authentication required."));
315
+ console.log(formatInfo("Run 'gitforest login' to authenticate."));
316
+ process.exit(1);
317
+ }
318
+
319
+ console.log(formatScanning("Scanning local directories..."));
320
+ const localProjects = await scanAllDirectories(config);
321
+
322
+ console.log(formatScanning("Fetching GitHub repositories..."));
323
+ const { unified, error } = await fetchUnifiedRepos(localProjects);
324
+
325
+ if (error) {
326
+ console.log(formatWarning(error));
327
+ }
328
+
329
+ // Apply view filter
330
+ let result = filterByViewMode(unified, view);
331
+
332
+ // Apply text filter
333
+ if (filter) {
334
+ result = filterUnifiedRepos(result, filter);
335
+ }
336
+
337
+ // Sort
338
+ result = sortUnifiedRepos(result, config.display.sortBy, config.display.sortDirection);
339
+
340
+ const stats = getUnifiedStats(unified);
341
+ console.log(formatUnifiedRepoList(result, stats, view, { json, verbose }));
342
+ }
343
+
344
+ /**
345
+ * Show unified status (local + GitHub)
346
+ */
347
+ export async function showUnifiedStatus(options: CLIOptions): Promise<void> {
348
+ const { config, json } = options;
349
+
350
+ if (!defaultGitHubService.hasToken()) {
351
+ console.log(formatWarning("GITHUB_TOKEN not set - showing local status only"));
352
+ await showStatus(options);
353
+ return;
354
+ }
355
+
356
+ console.log(formatScanning("Scanning local directories..."));
357
+ const localProjects = await scanAllDirectories(config);
358
+
359
+ console.log(formatScanning("Fetching GitHub repositories..."));
360
+ const { unified, error } = await fetchUnifiedRepos(localProjects);
361
+
362
+ if (error) {
363
+ console.log(formatWarning(error));
364
+ }
365
+
366
+ const stats = getUnifiedStats(unified);
367
+ const statusData = {
368
+ stats,
369
+ githubOnly: unified.filter((r) => r.source === "github"),
370
+ localOnly: unified.filter((r) => r.source === "local"),
371
+ dirty: unified.filter((r) => r.local?.status?.isDirty),
372
+ unpushed: unified.filter((r) => r.local?.status?.isAhead),
373
+ unpulled: unified.filter((r) => r.local?.status?.isBehind),
374
+ };
375
+
376
+ if (json) {
377
+ console.log(formatUnifiedStatusJson(statusData));
378
+ return;
379
+ }
380
+
381
+ console.log(formatUnifiedStatusDisplay(statusData));
382
+ }
383
+
384
+ /**
385
+ * Clone GitHub repositories that aren't local
386
+ */
387
+ export async function cloneGitHubRepos(options: CLIOptions & {
388
+ repos?: string[];
389
+ targetDir?: string;
390
+ useHTTPS?: boolean;
391
+ }): Promise<void> {
392
+ const { config, filter, repos: specificRepos, targetDir, useHTTPS = false } = options;
393
+
394
+ // Check GitHub auth - auto-login if not set
395
+ const token = await ensureAuthenticated();
396
+ if (!token) {
397
+ console.log(formatError("GitHub authentication required."));
398
+ console.log(formatInfo("Run 'gitforest login' to authenticate."));
399
+ process.exit(1);
400
+ }
401
+
402
+ // Determine target directory
403
+ const cloneDir = targetDir ?? config.directories[0]?.path ?? process.cwd();
404
+
405
+ console.log(formatScanning("Scanning local directories..."));
406
+ const localProjects = await scanAllDirectories(config);
407
+
408
+ console.log(formatScanning("Fetching GitHub repositories..."));
409
+ const { unified, error } = await fetchUnifiedRepos(localProjects);
410
+
411
+ if (error) {
412
+ console.log(formatWarning(error));
413
+ }
414
+
415
+ // Get repos to clone
416
+ let toClone = unified.filter((r) => r.source === "github");
417
+
418
+ // Filter by specific repos if provided
419
+ if (specificRepos && specificRepos.length > 0) {
420
+ toClone = toClone.filter((r) =>
421
+ specificRepos.some((name) =>
422
+ r.name.toLowerCase() === name.toLowerCase() ||
423
+ r.github?.fullName.toLowerCase() === name.toLowerCase()
424
+ )
425
+ );
426
+ } else if (filter) {
427
+ toClone = filterUnifiedRepos(toClone, filter);
428
+ }
429
+
430
+ if (toClone.length === 0) {
431
+ console.log(formatWarning("No repositories to clone."));
432
+ return;
433
+ }
434
+
435
+ console.log(formatInfo(`\nCloning ${toClone.length} repositories to ${cloneDir}...\n`));
436
+
437
+ let success = 0;
438
+ for (const repo of toClone) {
439
+ const result = await cloneGitHubRepo(repo, cloneDir, !useHTTPS);
440
+ console.log(formatCloneItem(repo.github?.fullName, result.success, result.path, result.error));
441
+ if (result.success) success++;
442
+ }
443
+
444
+ console.log(`\n${formatOperationSummary("Cloned", success, toClone.length)}`);
445
+ }
446
+
447
+ /**
448
+ * Show GitHub authentication status
449
+ */
450
+ export async function showGitHubAuth(): Promise<void> {
451
+ const status = await getAuthStatus();
452
+
453
+ if (!status.authenticated) {
454
+ console.log(formatNoToken());
455
+ console.log(formatInfo("\nRun 'gitforest login' to authenticate with GitHub."));
456
+ return;
457
+ }
458
+
459
+ console.log(formatAuthSuccess(status.user ?? "unknown", undefined));
460
+ console.log(formatInfo(` Auth source: ${status.source === "env" ? "environment variable" : "gh CLI"}`));
461
+ }
462
+
463
+ /**
464
+ * Login to GitHub using gh CLI
465
+ */
466
+ export async function loginGitHub(): Promise<void> {
467
+ // Check if gh is installed
468
+ const ghInstalled = await isGhInstalled();
469
+ if (!ghInstalled) {
470
+ console.log(formatError("GitHub CLI (gh) is not installed."));
471
+ console.log(formatInfo("Install it from: https://cli.github.com"));
472
+ console.log(formatInfo("\nOr set GITHUB_TOKEN environment variable manually."));
473
+ process.exit(1);
474
+ }
475
+
476
+ // Check if already authenticated
477
+ const status = await getAuthStatus();
478
+ if (status.authenticated) {
479
+ console.log(formatInfo(`Already logged in as ${status.user ?? "unknown"}`));
480
+ console.log(formatInfo("Run 'gitforest logout' first to login as a different user."));
481
+ return;
482
+ }
483
+
484
+ const result = await login();
485
+
486
+ if (result.success) {
487
+ console.log(`\n${formatAuthSuccess(result.user ?? "unknown", undefined)}`);
488
+ console.log(formatInfo("You can now use GitHub features."));
489
+ } else {
490
+ console.log(formatError(`Login failed: ${result.error}`));
491
+ process.exit(1);
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Logout from GitHub
497
+ */
498
+ export async function logoutGitHub(): Promise<void> {
499
+ const status = await getAuthStatus();
500
+
501
+ if (!status.authenticated) {
502
+ console.log(formatInfo("Not logged in."));
503
+ return;
504
+ }
505
+
506
+ await logout();
507
+ console.log(formatInfo("Logged out successfully."));
508
+ }
509
+
510
+ /**
511
+ * Setup projects: init git + create GitHub repo + push
512
+ * For non-git projects and git projects without remotes
513
+ */
514
+ export async function setupProjects(options: CLIOptions & { isPrivate?: boolean }): Promise<void> {
515
+ const { config, filter, isPrivate = true } = options;
516
+
517
+ // Check GitHub auth - auto-login if not set
518
+ const token = await ensureAuthenticated();
519
+ if (!token) {
520
+ console.log(formatError("GitHub authentication required."));
521
+ console.log(formatInfo("Run 'gitforest login' to authenticate."));
522
+ process.exit(1);
523
+ }
524
+
525
+ console.log(formatScanning());
526
+ const projects = await scanAllDirectories(config);
527
+
528
+ // Find eligible projects: non-git OR git without remote
529
+ let eligibleProjects = projects.filter(
530
+ (p) => p.type === "non-git" || (p.type === "git" && !p.status?.hasRemote)
531
+ );
532
+
533
+ if (filter) {
534
+ eligibleProjects = filterProjects(eligibleProjects, filter);
535
+ }
536
+
537
+ if (eligibleProjects.length === 0) {
538
+ console.log(formatWarning("No projects need setup (all have remotes)."));
539
+ return;
540
+ }
541
+
542
+ const nonGitCount = eligibleProjects.filter((p) => p.type === "non-git").length;
543
+ const noRemoteCount = eligibleProjects.filter((p) => p.type === "git").length;
544
+
545
+ console.log(formatInfo(`\nSetting up ${eligibleProjects.length} projects:`));
546
+ if (nonGitCount > 0) console.log(formatInfo(` - ${nonGitCount} non-git projects (will init)`));
547
+ if (noRemoteCount > 0) console.log(formatInfo(` - ${noRemoteCount} git projects without remote`));
548
+ console.log(formatInfo(` - Creating ${isPrivate ? "private" : "public"} repos\n`));
549
+
550
+ let initSuccess = 0;
551
+ let createSuccess = 0;
552
+
553
+ for (const project of eligibleProjects) {
554
+ // Step 1: Init git if needed
555
+ if (project.type === "non-git") {
556
+ const initResult = await initGitInProject(project.path);
557
+ if (initResult.success) {
558
+ initSuccess++;
559
+ console.log(formatOperationItem(`${project.name} (init)`, true));
560
+ } else {
561
+ console.log(formatOperationItem(`${project.name} (init)`, false, initResult.error));
562
+ continue; // Skip create if init failed
563
+ }
564
+ }
565
+
566
+ // Step 2: Create GitHub repo (this also adds remote and pushes)
567
+ const createResult = await defaultGitHubService.createRepo({
568
+ name: project.name,
569
+ isPrivate,
570
+ localPath: project.path,
571
+ });
572
+
573
+ console.log(formatOperationItem(`${project.name} (create + push)`, createResult.success, createResult.error));
574
+ if (createResult.success) createSuccess++;
575
+ }
576
+
577
+ console.log(`\n${formatOperationSummary("Set up", createSuccess, eligibleProjects.length)}`);
578
+ if (nonGitCount > 0) {
579
+ console.log(formatInfo(` Git initialized: ${initSuccess}/${nonGitCount}`));
580
+ }
581
+ }
582
+
583
+ export * from "./config.ts";
@@ -0,0 +1,137 @@
1
+ import { Box, Text, useInput } from "ink";
2
+ import type { UnifiedRepo, DirectoryConfig } from "../types/index.ts";
3
+
4
+ export interface CloneDialogProps {
5
+ repos: UnifiedRepo[];
6
+ directories: DirectoryConfig[];
7
+ selectedDirIndex: number;
8
+ useSSH: boolean;
9
+ onConfirm: (targetDir: string, useSSH: boolean) => void;
10
+ onCancel: () => void;
11
+ onSelectDir: (index: number) => void;
12
+ onToggleSSH: () => void;
13
+ }
14
+
15
+ export function CloneDialog({
16
+ repos,
17
+ directories,
18
+ selectedDirIndex,
19
+ useSSH,
20
+ onConfirm,
21
+ onCancel,
22
+ onSelectDir,
23
+ onToggleSSH,
24
+ }: CloneDialogProps) {
25
+ useInput((input, key) => {
26
+ if (key.escape || input === "n" || input === "N") {
27
+ onCancel();
28
+ return;
29
+ }
30
+
31
+ if (key.return || input === "y" || input === "Y") {
32
+ const targetDir = directories[selectedDirIndex]?.path;
33
+ if (targetDir) {
34
+ // Expand ~ to home directory
35
+ const expandedPath = targetDir.replace(/^~/, process.env.HOME || "");
36
+ onConfirm(expandedPath, useSSH);
37
+ }
38
+ return;
39
+ }
40
+
41
+ // Navigate directories
42
+ if (input === "j" || key.downArrow) {
43
+ const nextIndex = Math.min(selectedDirIndex + 1, directories.length - 1);
44
+ onSelectDir(nextIndex);
45
+ return;
46
+ }
47
+
48
+ if (input === "k" || key.upArrow) {
49
+ const prevIndex = Math.max(selectedDirIndex - 1, 0);
50
+ onSelectDir(prevIndex);
51
+ return;
52
+ }
53
+
54
+ // Toggle SSH/HTTPS
55
+ if (input === "p" || input === "P") {
56
+ onToggleSSH();
57
+ return;
58
+ }
59
+ });
60
+
61
+ const maxItems = 5;
62
+ const displayRepos = repos.slice(0, maxItems);
63
+ const remainingCount = repos.length - maxItems;
64
+
65
+ return (
66
+ <Box
67
+ flexDirection="column"
68
+ borderStyle="round"
69
+ borderColor="cyan"
70
+ paddingX={2}
71
+ paddingY={1}
72
+ width={60}
73
+ >
74
+ {/* Title */}
75
+ <Box marginBottom={1}>
76
+ <Text bold color="cyan">
77
+ Clone GitHub {repos.length === 1 ? "Repository" : `Repositories (${repos.length})`}
78
+ </Text>
79
+ </Box>
80
+
81
+ {/* Repos to clone */}
82
+ <Box flexDirection="column" marginBottom={1}>
83
+ <Text>Repositories to clone:</Text>
84
+ {displayRepos.map((repo) => (
85
+ <Text key={repo.id} color="magenta">
86
+ {" ☁ "}
87
+ {repo.github?.fullName || repo.name}
88
+ </Text>
89
+ ))}
90
+ {remainingCount > 0 && (
91
+ <Text dimColor>{" "}...and {remainingCount} more</Text>
92
+ )}
93
+ </Box>
94
+
95
+ {/* Target directory selection */}
96
+ <Box flexDirection="column" marginBottom={1}>
97
+ <Text>Target directory (j/k to select):</Text>
98
+ {directories.map((dir, index) => (
99
+ <Box key={dir.path} gap={1}>
100
+ <Text color={index === selectedDirIndex ? "cyan" : "gray"}>
101
+ {index === selectedDirIndex ? "●" : "○"}
102
+ </Text>
103
+ <Text color={index === selectedDirIndex ? "white" : "gray"}>
104
+ {dir.label || dir.path}
105
+ </Text>
106
+ <Text dimColor>({dir.path})</Text>
107
+ </Box>
108
+ ))}
109
+ </Box>
110
+
111
+ {/* Protocol selection */}
112
+ <Box marginBottom={1} gap={2}>
113
+ <Text>Protocol:</Text>
114
+ <Text color={useSSH ? "green" : "gray"}>
115
+ {useSSH ? "[●]" : "[ ]"} SSH
116
+ </Text>
117
+ <Text color={!useSSH ? "green" : "gray"}>
118
+ {!useSSH ? "[●]" : "[ ]"} HTTPS
119
+ </Text>
120
+ <Text dimColor>(p to toggle)</Text>
121
+ </Box>
122
+
123
+ {/* Actions */}
124
+ <Box gap={2}>
125
+ <Text dimColor>Press </Text>
126
+ <Text color="green" bold>
127
+ Enter/y
128
+ </Text>
129
+ <Text dimColor> to clone, </Text>
130
+ <Text color="red" bold>
131
+ Esc/n
132
+ </Text>
133
+ <Text dimColor> to cancel</Text>
134
+ </Box>
135
+ </Box>
136
+ );
137
+ }