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,632 @@
1
+ /**
2
+ * Pure formatting functions for CLI output
3
+ * These functions are side-effect free and return strings for display
4
+ */
5
+
6
+ import chalk from "chalk";
7
+ import type { Project, UnifiedRepo, BatchResult, ViewMode } from "../types/index.ts";
8
+
9
+ // ============================================================================
10
+ // Types
11
+ // ============================================================================
12
+
13
+ export interface FormatOptions {
14
+ verbose?: boolean;
15
+ json?: boolean;
16
+ }
17
+
18
+ export interface ProjectStats {
19
+ total: number;
20
+ gitCount: number;
21
+ submoduleCount: number;
22
+ nonGitCount: number;
23
+ dirtyCount: number;
24
+ unpushedCount: number;
25
+ unpulledCount: number;
26
+ }
27
+
28
+ export interface StatusSummary {
29
+ total: number;
30
+ dirty: Project[];
31
+ unpushed: Project[];
32
+ unpulled: Project[];
33
+ noRemote: Project[];
34
+ nonGit: Project[];
35
+ }
36
+
37
+ export interface UnifiedStats {
38
+ total: number;
39
+ both: number;
40
+ localOnly: number;
41
+ githubOnly: number;
42
+ dirty: number;
43
+ unpushed: number;
44
+ unpulled: number;
45
+ }
46
+
47
+ // ============================================================================
48
+ // Project Formatting
49
+ // ============================================================================
50
+
51
+ /**
52
+ * Get the type icon for a project
53
+ */
54
+ export function getProjectTypeIcon(type: Project["type"]): string {
55
+ const icons = {
56
+ git: chalk.green("●"),
57
+ "git-submodule": chalk.magenta("○"),
58
+ "non-git": chalk.gray("-"),
59
+ };
60
+ return icons[type];
61
+ }
62
+
63
+ /**
64
+ * Format project status as colored string parts
65
+ */
66
+ export function formatProjectStatus(project: Project): string {
67
+ const statusParts: string[] = [];
68
+
69
+ if (project.status) {
70
+ if (project.status.isDirty) {
71
+ statusParts.push(chalk.yellow(`${project.status.modifiedCount}M`));
72
+ }
73
+ if (project.status.untrackedCount > 0) {
74
+ statusParts.push(chalk.gray(`${project.status.untrackedCount}?`));
75
+ }
76
+ if (project.status.isAhead) {
77
+ statusParts.push(chalk.blue(`↑${project.status.unpushedCommits}`));
78
+ }
79
+ if (project.status.isBehind) {
80
+ statusParts.push(chalk.magenta(`↓${project.status.unpulledCommits}`));
81
+ }
82
+ if (!project.status.hasRemote) {
83
+ statusParts.push(chalk.gray("no-remote"));
84
+ }
85
+ }
86
+
87
+ return statusParts.length > 0 ? statusParts.join(" ") : chalk.green("clean");
88
+ }
89
+
90
+ /**
91
+ * Format a single project for display
92
+ */
93
+ export function formatProject(project: Project, verbose = false): string {
94
+ const typeIcon = getProjectTypeIcon(project.type);
95
+ const status = formatProjectStatus(project);
96
+
97
+ if (verbose) {
98
+ return [
99
+ `${typeIcon} ${chalk.bold(project.name)}`,
100
+ ` Path: ${project.path}`,
101
+ ` Status: ${status}`,
102
+ ` Branch: ${project.status?.currentBranch ?? "N/A"}`,
103
+ ].join("\n");
104
+ }
105
+
106
+ return `${typeIcon} ${project.name.padEnd(30)} ${status}`;
107
+ }
108
+
109
+ /**
110
+ * Calculate stats from a list of projects
111
+ */
112
+ export function calculateProjectStats(projects: Project[]): ProjectStats {
113
+ return {
114
+ total: projects.length,
115
+ gitCount: projects.filter((p) => p.type === "git").length,
116
+ submoduleCount: projects.filter((p) => p.type === "git-submodule").length,
117
+ nonGitCount: projects.filter((p) => p.type === "non-git").length,
118
+ dirtyCount: projects.filter((p) => p.status?.isDirty).length,
119
+ unpushedCount: projects.filter((p) => p.status?.isAhead).length,
120
+ unpulledCount: projects.filter((p) => p.status?.isBehind).length,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Format project stats as a summary line
126
+ */
127
+ export function formatProjectStats(stats: ProjectStats): string {
128
+ return [
129
+ `${stats.gitCount} git`,
130
+ `${stats.submoduleCount} submodules`,
131
+ `${stats.nonGitCount} non-git`,
132
+ chalk.yellow(`${stats.dirtyCount} dirty`),
133
+ chalk.blue(`${stats.unpushedCount} unpushed`),
134
+ ].join(" | ");
135
+ }
136
+
137
+ /**
138
+ * Format full project list output
139
+ */
140
+ export function formatProjectList(
141
+ projects: Project[],
142
+ options: FormatOptions = {}
143
+ ): string {
144
+ if (options.json) {
145
+ return JSON.stringify(projects, null, 2);
146
+ }
147
+
148
+ const lines: string[] = [];
149
+ lines.push(chalk.cyan(`\nFound ${projects.length} projects:\n`));
150
+
151
+ for (const project of projects) {
152
+ lines.push(formatProject(project, options.verbose));
153
+ }
154
+
155
+ const stats = calculateProjectStats(projects);
156
+ lines.push(chalk.gray("\n---"));
157
+ lines.push(formatProjectStats(stats));
158
+
159
+ return lines.join("\n");
160
+ }
161
+
162
+ // ============================================================================
163
+ // Status Summary Formatting
164
+ // ============================================================================
165
+
166
+ /**
167
+ * Calculate status summary from projects
168
+ */
169
+ export function calculateStatusSummary(projects: Project[]): StatusSummary {
170
+ return {
171
+ total: projects.length,
172
+ dirty: projects.filter((p) => p.status?.isDirty),
173
+ unpushed: projects.filter((p) => p.status?.isAhead),
174
+ unpulled: projects.filter((p) => p.status?.isBehind),
175
+ noRemote: projects.filter((p) => p.type === "git" && !p.status?.hasRemote),
176
+ nonGit: projects.filter((p) => p.type === "non-git"),
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Format status summary as JSON
182
+ */
183
+ export function formatStatusSummaryJson(summary: StatusSummary): string {
184
+ return JSON.stringify(
185
+ {
186
+ total: summary.total,
187
+ dirty: summary.dirty.map((p) => p.name),
188
+ unpushed: summary.unpushed.map((p) => p.name),
189
+ unpulled: summary.unpulled.map((p) => p.name),
190
+ noRemote: summary.noRemote.map((p) => p.name),
191
+ nonGit: summary.nonGit.map((p) => p.name),
192
+ },
193
+ null,
194
+ 2
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Format status summary for display
200
+ */
201
+ export function formatStatusSummary(summary: StatusSummary): string {
202
+ const lines: string[] = [];
203
+ lines.push(chalk.cyan(`\n=== Status Summary (${summary.total} projects) ===\n`));
204
+
205
+ if (summary.dirty.length > 0) {
206
+ lines.push(chalk.yellow(`Dirty (${summary.dirty.length}):`));
207
+ summary.dirty.forEach((p) => lines.push(` ${p.name}`));
208
+ lines.push("");
209
+ }
210
+
211
+ if (summary.unpushed.length > 0) {
212
+ lines.push(chalk.blue(`Unpushed (${summary.unpushed.length}):`));
213
+ summary.unpushed.forEach((p) =>
214
+ lines.push(` ${p.name} (↑${p.status?.unpushedCommits})`)
215
+ );
216
+ lines.push("");
217
+ }
218
+
219
+ if (summary.unpulled.length > 0) {
220
+ lines.push(chalk.magenta(`Unpulled (${summary.unpulled.length}):`));
221
+ summary.unpulled.forEach((p) =>
222
+ lines.push(` ${p.name} (↓${p.status?.unpulledCommits})`)
223
+ );
224
+ lines.push("");
225
+ }
226
+
227
+ if (summary.noRemote.length > 0) {
228
+ lines.push(chalk.gray(`No Remote (${summary.noRemote.length}):`));
229
+ summary.noRemote.forEach((p) => lines.push(` ${p.name}`));
230
+ lines.push("");
231
+ }
232
+
233
+ if (
234
+ summary.dirty.length === 0 &&
235
+ summary.unpushed.length === 0 &&
236
+ summary.unpulled.length === 0
237
+ ) {
238
+ lines.push(chalk.green("✓ All repositories are clean and in sync!"));
239
+ }
240
+
241
+ return lines.join("\n");
242
+ }
243
+
244
+ // ============================================================================
245
+ // Batch Operation Result Formatting
246
+ // ============================================================================
247
+
248
+ /**
249
+ * Format batch operation result
250
+ */
251
+ export function formatBatchResult(
252
+ result: BatchResult,
253
+ operationName: string
254
+ ): string {
255
+ const lines: string[] = [];
256
+
257
+ lines.push(
258
+ chalk.green(`✓ ${operationName} ${result.successful}/${result.total} repositories`)
259
+ );
260
+
261
+ if (result.failed > 0) {
262
+ lines.push(chalk.red(`✗ ${result.failed} failed:`));
263
+ result.results
264
+ .filter((r) => !r.success)
265
+ .forEach((r) => lines.push(` ${r.projectPath}: ${r.error}`));
266
+ }
267
+
268
+ lines.push(chalk.gray(`Duration: ${result.duration}ms`));
269
+
270
+ return lines.join("\n");
271
+ }
272
+
273
+ /**
274
+ * Format progress indicator (for stdout.write)
275
+ */
276
+ export function formatProgress(completed: number, total: number): string {
277
+ return `\r Progress: ${completed}/${total}`;
278
+ }
279
+
280
+ // ============================================================================
281
+ // Dirty Repos Formatting
282
+ // ============================================================================
283
+
284
+ /**
285
+ * Format dirty repos list
286
+ */
287
+ export function formatDirtyRepos(dirty: Project[], json = false): string {
288
+ if (json) {
289
+ return JSON.stringify(dirty, null, 2);
290
+ }
291
+
292
+ if (dirty.length === 0) {
293
+ return chalk.green("\n✓ All repositories are clean!");
294
+ }
295
+
296
+ const lines: string[] = [];
297
+ lines.push(chalk.yellow(`\n${dirty.length} dirty repositories:\n`));
298
+
299
+ dirty.forEach((p) => {
300
+ const changes: string[] = [];
301
+ if (p.status?.modifiedCount) changes.push(`${p.status.modifiedCount} modified`);
302
+ if (p.status?.stagedCount) changes.push(`${p.status.stagedCount} staged`);
303
+ if (p.status?.untrackedCount) changes.push(`${p.status.untrackedCount} untracked`);
304
+ lines.push(` ${chalk.bold(p.name)}: ${changes.join(", ")}`);
305
+ });
306
+
307
+ return lines.join("\n");
308
+ }
309
+
310
+ // ============================================================================
311
+ // Unified Repo Formatting
312
+ // ============================================================================
313
+
314
+ /**
315
+ * Get source icon for unified repo
316
+ */
317
+ export function getSourceIcon(source: UnifiedRepo["source"]): string {
318
+ const icons = {
319
+ local: chalk.blue("L"),
320
+ github: chalk.magenta("G"),
321
+ both: chalk.green("✓"),
322
+ };
323
+ return icons[source];
324
+ }
325
+
326
+ /**
327
+ * Format unified repo status
328
+ */
329
+ export function formatUnifiedRepoStatus(repo: UnifiedRepo): string {
330
+ const statusParts: string[] = [];
331
+
332
+ if (repo.source === "github") {
333
+ statusParts.push(chalk.yellow("not cloned"));
334
+ } else if (repo.local?.status) {
335
+ if (repo.local.status.isDirty) {
336
+ statusParts.push(chalk.yellow(`${repo.local.status.modifiedCount}M`));
337
+ }
338
+ if (repo.local.status.isAhead) {
339
+ statusParts.push(chalk.blue(`↑${repo.local.status.unpushedCommits}`));
340
+ }
341
+ if (repo.local.status.isBehind) {
342
+ statusParts.push(chalk.magenta(`↓${repo.local.status.unpulledCommits}`));
343
+ }
344
+ if (!repo.isOnGitHub) {
345
+ statusParts.push(chalk.gray("local-only"));
346
+ }
347
+ }
348
+
349
+ return statusParts.length > 0 ? statusParts.join(" ") : chalk.green("synced");
350
+ }
351
+
352
+ /**
353
+ * Format a unified repo for display
354
+ */
355
+ export function formatUnifiedRepo(repo: UnifiedRepo, verbose = false): string {
356
+ const sourceIcon = getSourceIcon(repo.source);
357
+ const status = formatUnifiedRepoStatus(repo);
358
+ const visibility = repo.github?.isPrivate ? chalk.gray("(private)") : "";
359
+
360
+ if (verbose) {
361
+ const lines = [
362
+ `${sourceIcon} ${chalk.bold(repo.name)} ${visibility}`,
363
+ repo.localPath ? ` Local: ${repo.localPath}` : null,
364
+ repo.github ? ` GitHub: ${repo.github.fullName}` : null,
365
+ repo.github?.description ? ` Desc: ${repo.github.description}` : null,
366
+ ` Status: ${status}`,
367
+ ].filter(Boolean);
368
+ return lines.join("\n");
369
+ }
370
+
371
+ return `${sourceIcon} ${repo.name.padEnd(35)} ${status} ${visibility}`;
372
+ }
373
+
374
+ /**
375
+ * Format unified stats line
376
+ */
377
+ export function formatUnifiedStats(stats: UnifiedStats): string {
378
+ return [
379
+ chalk.green(`${stats.both} synced`),
380
+ chalk.blue(`${stats.localOnly} local-only`),
381
+ chalk.magenta(`${stats.githubOnly} github-only`),
382
+ chalk.yellow(`${stats.dirty} dirty`),
383
+ chalk.blue(`${stats.unpushed} unpushed`),
384
+ ].join(" | ");
385
+ }
386
+
387
+ /**
388
+ * Get view mode label
389
+ */
390
+ export function getViewModeLabel(mode: ViewMode): string {
391
+ const labels: Record<ViewMode, string> = {
392
+ local: "Local Only",
393
+ github: "GitHub Only (Not Cloned)",
394
+ combined: "All Repositories",
395
+ };
396
+ return labels[mode];
397
+ }
398
+
399
+ /**
400
+ * Format unified repo list
401
+ */
402
+ export function formatUnifiedRepoList(
403
+ repos: UnifiedRepo[],
404
+ stats: UnifiedStats,
405
+ viewMode: ViewMode,
406
+ options: FormatOptions = {}
407
+ ): string {
408
+ if (options.json) {
409
+ return JSON.stringify(repos, null, 2);
410
+ }
411
+
412
+ const lines: string[] = [];
413
+ lines.push(chalk.cyan(`\n=== ${getViewModeLabel(viewMode)} (${repos.length}) ===\n`));
414
+
415
+ for (const repo of repos) {
416
+ lines.push(formatUnifiedRepo(repo, options.verbose));
417
+ }
418
+
419
+ lines.push(chalk.gray("\n---"));
420
+ lines.push(formatUnifiedStats(stats));
421
+
422
+ return lines.join("\n");
423
+ }
424
+
425
+ // ============================================================================
426
+ // GitHub Auth Formatting
427
+ // ============================================================================
428
+
429
+ /**
430
+ * Format GitHub auth success
431
+ */
432
+ export function formatAuthSuccess(login: string, name?: string): string {
433
+ const lines = [chalk.green(`✓ Authenticated as ${login}`)];
434
+ if (name) {
435
+ lines.push(chalk.gray(` Name: ${name}`));
436
+ }
437
+ return lines.join("\n");
438
+ }
439
+
440
+ /**
441
+ * Format GitHub auth failure
442
+ */
443
+ export function formatAuthFailure(error?: string): string {
444
+ const lines = [chalk.red(`✗ Authentication failed${error ? `: ${error}` : ""}`)];
445
+ return lines.join("\n");
446
+ }
447
+
448
+ /**
449
+ * Format GitHub token not set message
450
+ */
451
+ export function formatNoToken(): string {
452
+ return [
453
+ chalk.red("✗ GITHUB_TOKEN not set"),
454
+ chalk.gray("\nTo authenticate:"),
455
+ chalk.white(" gitforest login"),
456
+ chalk.gray(" (Opens browser for GitHub OAuth)"),
457
+ chalk.gray("\nOr set token manually:"),
458
+ chalk.gray(" export GITHUB_TOKEN=your_token"),
459
+ ].join("\n");
460
+ }
461
+
462
+ // ============================================================================
463
+ // Operation Messages
464
+ // ============================================================================
465
+
466
+ /**
467
+ * Format scanning message
468
+ */
469
+ export function formatScanning(message = "Scanning directories..."): string {
470
+ return chalk.cyan(message);
471
+ }
472
+
473
+ /**
474
+ * Format warning message
475
+ */
476
+ export function formatWarning(message: string): string {
477
+ return chalk.yellow(`Warning: ${message}`);
478
+ }
479
+
480
+ /**
481
+ * Format error message
482
+ */
483
+ export function formatError(message: string): string {
484
+ return chalk.red(message);
485
+ }
486
+
487
+ /**
488
+ * Format success message
489
+ */
490
+ export function formatSuccess(message: string): string {
491
+ return chalk.green(message);
492
+ }
493
+
494
+ /**
495
+ * Format info message
496
+ */
497
+ export function formatInfo(message: string): string {
498
+ return chalk.cyan(message);
499
+ }
500
+
501
+ /**
502
+ * Format a simple operation result (success/failure)
503
+ */
504
+ export function formatOperationItem(
505
+ name: string,
506
+ success: boolean,
507
+ error?: string
508
+ ): string {
509
+ if (success) {
510
+ return chalk.green(` ✓ ${name}`);
511
+ }
512
+ return chalk.red(` ✗ ${name}${error ? `: ${error}` : ""}`);
513
+ }
514
+
515
+ /**
516
+ * Format operation summary line
517
+ */
518
+ export function formatOperationSummary(
519
+ operation: string,
520
+ success: number,
521
+ total: number
522
+ ): string {
523
+ return chalk.green(`${operation} ${success}/${total} ${success === 1 ? "item" : "items"}`);
524
+ }
525
+
526
+ // ============================================================================
527
+ // Unified Status Formatting
528
+ // ============================================================================
529
+
530
+ export interface UnifiedStatusData {
531
+ stats: UnifiedStats;
532
+ githubOnly: UnifiedRepo[];
533
+ localOnly: UnifiedRepo[];
534
+ dirty: UnifiedRepo[];
535
+ unpushed: UnifiedRepo[];
536
+ unpulled: UnifiedRepo[];
537
+ }
538
+
539
+ /**
540
+ * Format unified status as JSON
541
+ */
542
+ export function formatUnifiedStatusJson(data: UnifiedStatusData): string {
543
+ return JSON.stringify(
544
+ {
545
+ stats: data.stats,
546
+ githubOnly: data.githubOnly.map((r) => r.github?.fullName),
547
+ localOnly: data.localOnly.map((r) => r.name),
548
+ dirty: data.dirty.map((r) => r.name),
549
+ unpushed: data.unpushed.map((r) => r.name),
550
+ unpulled: data.unpulled.map((r) => r.name),
551
+ },
552
+ null,
553
+ 2
554
+ );
555
+ }
556
+
557
+ /**
558
+ * Format unified status for display
559
+ */
560
+ export function formatUnifiedStatusDisplay(data: UnifiedStatusData): string {
561
+ const { stats, githubOnly, dirty, unpushed, unpulled } = data;
562
+ const lines: string[] = [];
563
+
564
+ lines.push(chalk.cyan(`\n=== Unified Status ===\n`));
565
+
566
+ lines.push(`Total: ${stats.total} repositories`);
567
+ lines.push(` ${chalk.green(`${stats.both} synced`)} (local + GitHub)`);
568
+ lines.push(` ${chalk.blue(`${stats.localOnly} local-only`)} (not on GitHub)`);
569
+ lines.push(` ${chalk.magenta(`${stats.githubOnly} github-only`)} (not cloned)\n`);
570
+
571
+ if (githubOnly.length > 0) {
572
+ lines.push(chalk.magenta(`Not Cloned (${githubOnly.length}):`));
573
+ githubOnly.slice(0, 10).forEach((r) => {
574
+ const desc = r.github?.description
575
+ ? chalk.gray(` - ${r.github.description.slice(0, 40)}`)
576
+ : "";
577
+ lines.push(` ${r.github?.fullName}${desc}`);
578
+ });
579
+ if (githubOnly.length > 10) {
580
+ lines.push(chalk.gray(` ... and ${githubOnly.length - 10} more`));
581
+ }
582
+ lines.push("");
583
+ }
584
+
585
+ if (dirty.length > 0) {
586
+ lines.push(chalk.yellow(`Dirty (${dirty.length}):`));
587
+ dirty.forEach((r) => lines.push(` ${r.name}`));
588
+ lines.push("");
589
+ }
590
+
591
+ if (unpushed.length > 0) {
592
+ lines.push(chalk.blue(`Unpushed (${unpushed.length}):`));
593
+ unpushed.forEach((r) =>
594
+ lines.push(` ${r.name} (↑${r.local?.status?.unpushedCommits})`)
595
+ );
596
+ lines.push("");
597
+ }
598
+
599
+ if (unpulled.length > 0) {
600
+ lines.push(chalk.magenta(`Unpulled (${unpulled.length}):`));
601
+ unpulled.forEach((r) =>
602
+ lines.push(` ${r.name} (↓${r.local?.status?.unpulledCommits})`)
603
+ );
604
+ lines.push("");
605
+ }
606
+
607
+ if (
608
+ dirty.length === 0 &&
609
+ unpushed.length === 0 &&
610
+ unpulled.length === 0 &&
611
+ githubOnly.length === 0
612
+ ) {
613
+ lines.push(chalk.green("✓ All repositories are synced!"));
614
+ }
615
+
616
+ return lines.join("\n");
617
+ }
618
+
619
+ /**
620
+ * Format clone result item
621
+ */
622
+ export function formatCloneItem(
623
+ fullName: string | undefined,
624
+ success: boolean,
625
+ path?: string,
626
+ error?: string
627
+ ): string {
628
+ if (success) {
629
+ return chalk.green(` ✓ ${fullName} → ${path}`);
630
+ }
631
+ return chalk.red(` ✗ ${fullName}: ${error}`);
632
+ }