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,1116 @@
1
+ /**
2
+ * Tests for CLI formatters
3
+ */
4
+
5
+ import { describe, test, expect } from "bun:test";
6
+ import {
7
+ getProjectTypeIcon,
8
+ formatProjectStatus,
9
+ formatProject,
10
+ formatBatchResult,
11
+ formatProgress,
12
+ formatWarning,
13
+ formatError,
14
+ formatInfo,
15
+ formatSuccess,
16
+ formatScanning,
17
+ formatOperationItem,
18
+ formatOperationSummary,
19
+ calculateStatusSummary,
20
+ calculateProjectStats,
21
+ formatProjectStats,
22
+ formatProjectList,
23
+ formatStatusSummary,
24
+ formatStatusSummaryJson,
25
+ formatDirtyRepos,
26
+ getSourceIcon,
27
+ formatUnifiedRepoStatus,
28
+ formatUnifiedRepo,
29
+ formatUnifiedStats,
30
+ formatAuthSuccess,
31
+ formatAuthFailure,
32
+ formatNoToken,
33
+ formatCloneItem,
34
+ } from "../../../src/cli/formatters.ts";
35
+ import {
36
+ createMockProject,
37
+ createDirtyProject,
38
+ createAheadProject,
39
+ createNonGitProject,
40
+ createSubmoduleProject,
41
+ defaultMockStatus,
42
+ createDirtyStatus,
43
+ createBehindStatus,
44
+ createNoRemoteStatus,
45
+ } from "../mocks/index.ts";
46
+
47
+ describe("CLI formatters", () => {
48
+ describe("getProjectTypeIcon", () => {
49
+ test("returns green dot for git", () => {
50
+ const icon = getProjectTypeIcon("git");
51
+ expect(icon).toContain("●");
52
+ });
53
+
54
+ test("returns magenta circle for submodule", () => {
55
+ const icon = getProjectTypeIcon("git-submodule");
56
+ expect(icon).toContain("○");
57
+ });
58
+
59
+ test("returns gray dash for non-git", () => {
60
+ const icon = getProjectTypeIcon("non-git");
61
+ expect(icon).toContain("-");
62
+ });
63
+ });
64
+
65
+ describe("formatProjectStatus", () => {
66
+ test("returns 'clean' for clean project", () => {
67
+ const project = createMockProject();
68
+ const status = formatProjectStatus(project);
69
+
70
+ expect(status).toContain("clean");
71
+ });
72
+
73
+ test("includes modified count for dirty project", () => {
74
+ const project = createDirtyProject();
75
+ const status = formatProjectStatus(project);
76
+
77
+ expect(status).toContain("M");
78
+ });
79
+
80
+ test("includes unpushed indicator for ahead project", () => {
81
+ const project = createAheadProject("test", 3);
82
+ const status = formatProjectStatus(project);
83
+
84
+ expect(status).toContain("↑");
85
+ expect(status).toContain("3");
86
+ });
87
+
88
+ test("includes unpulled indicator for behind project", () => {
89
+ const project = createMockProject({
90
+ status: createBehindStatus(2),
91
+ });
92
+ const status = formatProjectStatus(project);
93
+
94
+ expect(status).toContain("↓");
95
+ expect(status).toContain("2");
96
+ });
97
+
98
+ test("includes no-remote for project without remote", () => {
99
+ const project = createMockProject({
100
+ status: createNoRemoteStatus(),
101
+ });
102
+ const status = formatProjectStatus(project);
103
+
104
+ expect(status).toContain("no-remote");
105
+ });
106
+
107
+ test("handles non-git project", () => {
108
+ const project = createNonGitProject("npm-project");
109
+ const status = formatProjectStatus(project);
110
+
111
+ expect(status).toContain("clean");
112
+ });
113
+
114
+ test("includes untracked files count", () => {
115
+ const project = createMockProject({
116
+ status: {
117
+ ...defaultMockStatus,
118
+ untrackedCount: 5,
119
+ hasUntrackedFiles: true,
120
+ },
121
+ });
122
+ const status = formatProjectStatus(project);
123
+
124
+ expect(status).toContain("5?");
125
+ });
126
+
127
+ test("combines multiple status indicators", () => {
128
+ const project = createMockProject({
129
+ status: {
130
+ ...createDirtyStatus(),
131
+ unpushedCommits: 2,
132
+ isAhead: true,
133
+ unpulledCommits: 1,
134
+ isBehind: true,
135
+ untrackedCount: 3,
136
+ hasUntrackedFiles: true,
137
+ },
138
+ });
139
+ const status = formatProjectStatus(project);
140
+
141
+ expect(status).toContain("2M");
142
+ expect(status).toContain("3?");
143
+ expect(status).toContain("↑2");
144
+ expect(status).toContain("↓1");
145
+ });
146
+ });
147
+
148
+ describe("formatProject", () => {
149
+ test("includes project name", () => {
150
+ const project = createMockProject({ name: "my-project" });
151
+ const output = formatProject(project);
152
+
153
+ expect(output).toContain("my-project");
154
+ });
155
+
156
+ test("includes type icon", () => {
157
+ const project = createMockProject();
158
+ const output = formatProject(project);
159
+
160
+ expect(output).toContain("●");
161
+ });
162
+
163
+ test("includes path in verbose mode", () => {
164
+ const project = createMockProject({
165
+ name: "test",
166
+ path: "/home/user/test",
167
+ });
168
+ const output = formatProject(project, true);
169
+
170
+ expect(output).toContain("/home/user/test");
171
+ });
172
+
173
+ test("includes branch in verbose mode", () => {
174
+ const project = createMockProject({
175
+ name: "test",
176
+ status: {
177
+ ...defaultMockStatus,
178
+ currentBranch: "feature-branch",
179
+ },
180
+ });
181
+ const output = formatProject(project, true);
182
+
183
+ expect(output).toContain("feature-branch");
184
+ });
185
+
186
+ test("handles missing branch in verbose mode", () => {
187
+ const project = createNonGitProject("test");
188
+ const output = formatProject(project, true);
189
+
190
+ expect(output).toContain("N/A");
191
+ });
192
+ });
193
+
194
+ describe("formatBatchResult", () => {
195
+ test("formats successful batch", () => {
196
+ const result = {
197
+ total: 5,
198
+ successful: 5,
199
+ failed: 0,
200
+ results: [],
201
+ duration: 1000,
202
+ };
203
+
204
+ const output = formatBatchResult(result, "Pull");
205
+
206
+ expect(output).toContain("Pull");
207
+ expect(output).toContain("5/5");
208
+ expect(output).toContain("1000ms");
209
+ });
210
+
211
+ test("formats batch with failures", () => {
212
+ const result = {
213
+ total: 5,
214
+ successful: 3,
215
+ failed: 2,
216
+ results: [],
217
+ duration: 2000,
218
+ };
219
+
220
+ const output = formatBatchResult(result, "Push");
221
+
222
+ expect(output).toContain("Push");
223
+ expect(output).toContain("3/5");
224
+ expect(output).toContain("2 failed");
225
+ expect(output).toContain("2000ms");
226
+ });
227
+
228
+ test("formats all failed batch", () => {
229
+ const result = {
230
+ total: 3,
231
+ successful: 0,
232
+ failed: 3,
233
+ results: [],
234
+ duration: 500,
235
+ };
236
+
237
+ const output = formatBatchResult(result, "Fetch");
238
+
239
+ expect(output).toContain("Fetch");
240
+ expect(output).toContain("0/3");
241
+ expect(output).toContain("3 failed");
242
+ expect(output).toContain("500ms");
243
+ });
244
+
245
+ test("includes error details for failed operations", () => {
246
+ const result = {
247
+ total: 2,
248
+ successful: 1,
249
+ failed: 1,
250
+ results: [
251
+ { projectPath: "/project1", success: true, operation: "pull", message: "Success", duration: 100 },
252
+ { projectPath: "/project2", success: false, operation: "pull", error: "Permission denied", duration: 50 },
253
+ ],
254
+ duration: 150,
255
+ };
256
+
257
+ const output = formatBatchResult(result, "Pull");
258
+
259
+ expect(output).toContain("Permission denied");
260
+ expect(output).toContain("/project2");
261
+ });
262
+ });
263
+
264
+ describe("formatProgress", () => {
265
+ test("formats progress with completed and total", () => {
266
+ const output = formatProgress(50, 100);
267
+
268
+ expect(output).toContain("50");
269
+ expect(output).toContain("100");
270
+ expect(output).toContain("Progress:");
271
+ });
272
+
273
+ test("handles zero total", () => {
274
+ const output = formatProgress(0, 0);
275
+
276
+ expect(output).toContain("0/0");
277
+ });
278
+
279
+ test("handles completion", () => {
280
+ const output = formatProgress(100, 100);
281
+
282
+ expect(output).toContain("100/100");
283
+ });
284
+ });
285
+
286
+ describe("message formatters", () => {
287
+ test("formatWarning includes warning text", () => {
288
+ const output = formatWarning("This is a warning");
289
+
290
+ expect(output).toContain("This is a warning");
291
+ });
292
+
293
+ test("formatError includes error text", () => {
294
+ const output = formatError("Something went wrong");
295
+
296
+ expect(output).toContain("Something went wrong");
297
+ });
298
+
299
+ test("formatInfo includes info text", () => {
300
+ const output = formatInfo("Information message");
301
+
302
+ expect(output).toContain("Information message");
303
+ });
304
+
305
+ test("formatSuccess includes success text", () => {
306
+ const output = formatSuccess("Operation completed");
307
+
308
+ expect(output).toContain("Operation completed");
309
+ });
310
+
311
+ test("formatScanning includes scanning text", () => {
312
+ const output = formatScanning("Scanning repositories...");
313
+
314
+ expect(output).toContain("Scanning repositories...");
315
+ });
316
+
317
+ test("formatScanning uses default message", () => {
318
+ const output = formatScanning();
319
+
320
+ expect(output).toContain("Scanning directories...");
321
+ });
322
+ });
323
+
324
+ describe("formatOperationItem", () => {
325
+ test("formats successful operation", () => {
326
+ const output = formatOperationItem("Deploy", true);
327
+
328
+ expect(output).toContain("✓");
329
+ expect(output).toContain("Deploy");
330
+ });
331
+
332
+ test("formats failed operation", () => {
333
+ const output = formatOperationItem("Deploy", false, "Connection timeout");
334
+
335
+ expect(output).toContain("✗");
336
+ expect(output).toContain("Deploy");
337
+ expect(output).toContain("Connection timeout");
338
+ });
339
+
340
+ test("formats failed operation without error", () => {
341
+ const output = formatOperationItem("Deploy", false);
342
+
343
+ expect(output).toContain("✗");
344
+ expect(output).toContain("Deploy");
345
+ expect(output).not.toContain(":");
346
+ });
347
+ });
348
+
349
+ describe("formatOperationSummary", () => {
350
+ test("formats summary with single item", () => {
351
+ const output = formatOperationSummary("Deploy", 1, 1);
352
+
353
+ expect(output).toContain("Deploy");
354
+ expect(output).toContain("1/1");
355
+ expect(output).toContain("item");
356
+ });
357
+
358
+ test("formats summary with multiple items", () => {
359
+ const output = formatOperationSummary("Deploy", 5, 5);
360
+
361
+ expect(output).toContain("Deploy");
362
+ expect(output).toContain("5/5");
363
+ expect(output).toContain("items");
364
+ });
365
+ });
366
+
367
+ describe("calculateStatusSummary", () => {
368
+ test("calculates summary for mixed projects", () => {
369
+ const projects = [
370
+ createMockProject({ name: "clean" }),
371
+ createDirtyProject("dirty"),
372
+ createAheadProject("ahead", 2),
373
+ createNonGitProject("npm"),
374
+ ];
375
+
376
+ const summary = calculateStatusSummary(projects);
377
+
378
+ expect(summary.total).toBe(4);
379
+ expect(summary.dirty).toHaveLength(1);
380
+ expect(summary.unpushed).toHaveLength(1);
381
+ expect(summary.nonGit).toHaveLength(1);
382
+ });
383
+
384
+ test("identifies no-remote projects", () => {
385
+ const projects = [
386
+ createMockProject({ status: createNoRemoteStatus() }),
387
+ createMockProject(), // Has remote
388
+ ];
389
+
390
+ const summary = calculateStatusSummary(projects);
391
+
392
+ expect(summary.noRemote).toHaveLength(1);
393
+ });
394
+
395
+ test("identifies unpulled projects", () => {
396
+ const projects = [
397
+ createMockProject({ status: createBehindStatus(3) }),
398
+ createMockProject(), // Up to date
399
+ ];
400
+
401
+ const summary = calculateStatusSummary(projects);
402
+
403
+ expect(summary.unpulled).toHaveLength(1);
404
+ });
405
+
406
+ test("handles empty project list", () => {
407
+ const summary = calculateStatusSummary([]);
408
+
409
+ expect(summary.total).toBe(0);
410
+ expect(summary.dirty).toHaveLength(0);
411
+ expect(summary.unpushed).toHaveLength(0);
412
+ expect(summary.unpulled).toHaveLength(0);
413
+ expect(summary.noRemote).toHaveLength(0);
414
+ expect(summary.nonGit).toHaveLength(0);
415
+ });
416
+ });
417
+
418
+ describe("calculateProjectStats", () => {
419
+ test("counts project types correctly", () => {
420
+ const projects = [
421
+ createMockProject(), // git
422
+ createMockProject(), // git
423
+ createSubmoduleProject("sub"),
424
+ createNonGitProject("npm"),
425
+ ];
426
+
427
+ const stats = calculateProjectStats(projects);
428
+
429
+ expect(stats.total).toBe(4);
430
+ expect(stats.gitCount).toBe(2);
431
+ expect(stats.submoduleCount).toBe(1);
432
+ expect(stats.nonGitCount).toBe(1);
433
+ });
434
+
435
+ test("counts dirty and sync status", () => {
436
+ const projects = [
437
+ createDirtyProject(),
438
+ createAheadProject("ahead"),
439
+ createMockProject({ status: createBehindStatus() }),
440
+ ];
441
+
442
+ const stats = calculateProjectStats(projects);
443
+
444
+ expect(stats.dirtyCount).toBe(1);
445
+ expect(stats.unpushedCount).toBe(1);
446
+ expect(stats.unpulledCount).toBe(1);
447
+ });
448
+
449
+ test("handles empty project list", () => {
450
+ const stats = calculateProjectStats([]);
451
+
452
+ expect(stats.total).toBe(0);
453
+ expect(stats.gitCount).toBe(0);
454
+ expect(stats.submoduleCount).toBe(0);
455
+ expect(stats.nonGitCount).toBe(0);
456
+ expect(stats.dirtyCount).toBe(0);
457
+ expect(stats.unpushedCount).toBe(0);
458
+ expect(stats.unpulledCount).toBe(0);
459
+ });
460
+ });
461
+
462
+ describe("formatProjectStats", () => {
463
+ test("formats stats with all counts", () => {
464
+ const stats = {
465
+ total: 10,
466
+ gitCount: 6,
467
+ submoduleCount: 2,
468
+ nonGitCount: 2,
469
+ dirtyCount: 3,
470
+ unpushedCount: 1,
471
+ unpulledCount: 2,
472
+ };
473
+
474
+ const output = formatProjectStats(stats);
475
+
476
+ expect(output).toContain("6 git");
477
+ expect(output).toContain("2 submodules");
478
+ expect(output).toContain("2 non-git");
479
+ expect(output).toContain("3 dirty");
480
+ expect(output).toContain("1 unpushed");
481
+ });
482
+
483
+ test("formats stats with zero counts", () => {
484
+ const stats = {
485
+ total: 0,
486
+ gitCount: 0,
487
+ submoduleCount: 0,
488
+ nonGitCount: 0,
489
+ dirtyCount: 0,
490
+ unpushedCount: 0,
491
+ unpulledCount: 0,
492
+ };
493
+
494
+ const output = formatProjectStats(stats);
495
+
496
+ expect(output).toContain("0 git");
497
+ expect(output).toContain("0 submodules");
498
+ expect(output).toContain("0 non-git");
499
+ expect(output).toContain("0 dirty");
500
+ expect(output).toContain("0 unpushed");
501
+ });
502
+ });
503
+
504
+ describe("formatProjectList", () => {
505
+ test("formats project list in default mode", () => {
506
+ const projects = [
507
+ createMockProject({ name: "project1" }),
508
+ createDirtyProject("project2"),
509
+ createNonGitProject("project3"),
510
+ ];
511
+
512
+ const output = formatProjectList(projects);
513
+
514
+ expect(output).toContain("Found 3 projects");
515
+ expect(output).toContain("project1");
516
+ expect(output).toContain("project2");
517
+ expect(output).toContain("project3");
518
+ expect(output).toContain("---");
519
+ expect(output).toContain("2 git");
520
+ expect(output).toContain("1 non-git");
521
+ expect(output).toContain("1 dirty");
522
+ });
523
+
524
+ test("formats project list in verbose mode", () => {
525
+ const projects = [
526
+ createMockProject({ name: "project1", path: "/path/to/project1" }),
527
+ ];
528
+
529
+ const output = formatProjectList(projects, { verbose: true });
530
+
531
+ expect(output).toContain("Path: /path/to/project1");
532
+ expect(output).toContain("Status:");
533
+ expect(output).toContain("Branch:");
534
+ });
535
+
536
+ test("formats project list as JSON", () => {
537
+ const projects = [
538
+ createMockProject({ name: "project1" }),
539
+ createMockProject({ name: "project2" }),
540
+ ];
541
+
542
+ const output = formatProjectList(projects, { json: true });
543
+
544
+ const parsed = JSON.parse(output);
545
+ expect(parsed).toHaveLength(2);
546
+ expect(parsed[0].name).toBe("project1");
547
+ expect(parsed[1].name).toBe("project2");
548
+ });
549
+ });
550
+
551
+ describe("formatStatusSummary", () => {
552
+ test("formats summary with all categories", () => {
553
+ const summary = calculateStatusSummary([
554
+ createMockProject({ name: "clean" }),
555
+ createDirtyProject("dirty1"),
556
+ createDirtyProject("dirty2"),
557
+ createAheadProject("ahead1", 2),
558
+ createAheadProject("ahead2", 5),
559
+ createMockProject({ name: "behind", status: createBehindStatus(3) }),
560
+ createMockProject({ name: "no-remote", status: createNoRemoteStatus() }),
561
+ createNonGitProject("npm"),
562
+ ]);
563
+
564
+ const output = formatStatusSummary(summary);
565
+
566
+ expect(output).toContain("Status Summary (8 projects)");
567
+ expect(output).toContain("Dirty (2)");
568
+ expect(output).toContain("dirty1");
569
+ expect(output).toContain("dirty2");
570
+ expect(output).toContain("Unpushed (2)");
571
+ expect(output).toContain("ahead1 (↑2)");
572
+ expect(output).toContain("Unpulled (1)");
573
+ expect(output).toContain("behind (↓3)");
574
+ expect(output).toContain("No Remote (1)");
575
+ expect(output).toContain("no-remote");
576
+ });
577
+
578
+ test("shows clean message when all repos are clean", () => {
579
+ const summary = calculateStatusSummary([
580
+ createMockProject({ name: "clean1" }),
581
+ createMockProject({ name: "clean2" }),
582
+ ]);
583
+
584
+ const output = formatStatusSummary(summary);
585
+
586
+ expect(output).toContain("Status Summary (2 projects)");
587
+ expect(output).toContain("All repositories are clean and in sync!");
588
+ });
589
+
590
+ test("handles empty summary", () => {
591
+ const summary = calculateStatusSummary([]);
592
+
593
+ const output = formatStatusSummary(summary);
594
+
595
+ expect(output).toContain("Status Summary (0 projects)");
596
+ });
597
+ });
598
+
599
+ describe("formatStatusSummaryJson", () => {
600
+ test("formats summary as JSON", () => {
601
+ const summary = calculateStatusSummary([
602
+ createMockProject({ name: "clean" }),
603
+ createDirtyProject("dirty"),
604
+ createAheadProject("ahead", 2),
605
+ createNonGitProject("npm"),
606
+ ]);
607
+
608
+ const output = formatStatusSummaryJson(summary);
609
+ const parsed = JSON.parse(output);
610
+
611
+ expect(parsed.total).toBe(4);
612
+ expect(parsed.dirty).toEqual(["dirty"]);
613
+ expect(parsed.unpushed).toEqual(["ahead"]);
614
+ expect(parsed.nonGit).toEqual(["npm"]);
615
+ });
616
+ });
617
+
618
+ describe("formatDirtyRepos", () => {
619
+ test("formats empty dirty list", () => {
620
+ const output = formatDirtyRepos([]);
621
+
622
+ expect(output).toContain("All repositories are clean!");
623
+ });
624
+
625
+ test("formats dirty repos list", () => {
626
+ const dirty = [
627
+ createDirtyProject("project1"),
628
+ createMockProject({
629
+ name: "project2",
630
+ status: {
631
+ ...createDirtyStatus(),
632
+ stagedCount: 2,
633
+ untrackedCount: 1,
634
+ },
635
+ }),
636
+ ];
637
+
638
+ const output = formatDirtyRepos(dirty);
639
+
640
+ expect(output).toContain("2 dirty repositories");
641
+ expect(output).toContain("project1");
642
+ expect(output).toContain("project2");
643
+ expect(output).toContain("2 modified");
644
+ expect(output).toContain("2 staged");
645
+ expect(output).toContain("1 untracked");
646
+ });
647
+
648
+ test("formats as JSON", () => {
649
+ const dirty = [
650
+ createDirtyProject("project1"),
651
+ createDirtyProject("project2"),
652
+ ];
653
+
654
+ const output = formatDirtyRepos(dirty, true);
655
+ const parsed = JSON.parse(output);
656
+
657
+ expect(parsed).toHaveLength(2);
658
+ expect(parsed[0].name).toBe("project1");
659
+ expect(parsed[1].name).toBe("project2");
660
+ });
661
+ });
662
+
663
+ describe("getSourceIcon", () => {
664
+ test("returns L for local", () => {
665
+ const icon = getSourceIcon("local");
666
+ expect(icon).toContain("L");
667
+ });
668
+
669
+ test("returns G for github", () => {
670
+ const icon = getSourceIcon("github");
671
+ expect(icon).toContain("G");
672
+ });
673
+
674
+ test("returns checkmark for both", () => {
675
+ const icon = getSourceIcon("both");
676
+ expect(icon).toContain("✓");
677
+ });
678
+ });
679
+
680
+ describe("formatUnifiedRepoStatus", () => {
681
+ test("shows not cloned for github-only repo", () => {
682
+ const repo = {
683
+ id: "test-repo",
684
+ name: "test-repo",
685
+ source: "github" as const,
686
+ github: {
687
+ name: "test-repo",
688
+ fullName: "user/test-repo",
689
+ owner: "user",
690
+ description: null,
691
+ htmlUrl: "https://github.com/user/test-repo",
692
+ sshUrl: "git@github.com:user/test-repo.git",
693
+ cloneUrl: "https://github.com/user/test-repo.git",
694
+ isPrivate: false,
695
+ isArchived: false,
696
+ isFork: false,
697
+ pushedAt: new Date(),
698
+ updatedAt: new Date(),
699
+ defaultBranch: "main",
700
+ language: "TypeScript",
701
+ size: 1024,
702
+ },
703
+ local: null,
704
+ isCloned: false,
705
+ isOnGitHub: true,
706
+ localPath: null,
707
+ };
708
+
709
+ const status = formatUnifiedRepoStatus(repo);
710
+
711
+ expect(status).toContain("not cloned");
712
+ });
713
+
714
+ test("shows local-only for repo not on GitHub", () => {
715
+ const repo = {
716
+ id: "test-repo",
717
+ name: "test-repo",
718
+ source: "local" as const,
719
+ github: null,
720
+ local: createMockProject({ name: "test-repo" }),
721
+ isCloned: true,
722
+ isOnGitHub: false,
723
+ localPath: "/path/to/repo",
724
+ };
725
+
726
+ const status = formatUnifiedRepoStatus(repo);
727
+
728
+ expect(status).toContain("local-only");
729
+ });
730
+
731
+ test("shows synced for clean synced repo", () => {
732
+ const repo = {
733
+ id: "test-repo",
734
+ name: "test-repo",
735
+ source: "both" as const,
736
+ github: {
737
+ name: "test-repo",
738
+ fullName: "user/test-repo",
739
+ owner: "user",
740
+ description: null,
741
+ htmlUrl: "https://github.com/user/test-repo",
742
+ sshUrl: "git@github.com:user/test-repo.git",
743
+ cloneUrl: "https://github.com/user/test-repo.git",
744
+ isPrivate: false,
745
+ isArchived: false,
746
+ isFork: false,
747
+ pushedAt: new Date(),
748
+ updatedAt: new Date(),
749
+ defaultBranch: "main",
750
+ language: "TypeScript",
751
+ size: 1024,
752
+ },
753
+ local: createMockProject({ name: "test-repo" }),
754
+ isCloned: true,
755
+ isOnGitHub: true,
756
+ localPath: "/path/to/repo",
757
+ };
758
+
759
+ const status = formatUnifiedRepoStatus(repo);
760
+
761
+ expect(status).toContain("synced");
762
+ });
763
+
764
+ test("shows dirty status", () => {
765
+ const repo = {
766
+ id: "test-repo",
767
+ name: "test-repo",
768
+ source: "both" as const,
769
+ github: {
770
+ name: "test-repo",
771
+ fullName: "user/test-repo",
772
+ owner: "user",
773
+ description: null,
774
+ htmlUrl: "https://github.com/user/test-repo",
775
+ sshUrl: "git@github.com:user/test-repo.git",
776
+ cloneUrl: "https://github.com/user/test-repo.git",
777
+ isPrivate: false,
778
+ isArchived: false,
779
+ isFork: false,
780
+ pushedAt: new Date(),
781
+ updatedAt: new Date(),
782
+ defaultBranch: "main",
783
+ language: "TypeScript",
784
+ size: 1024,
785
+ },
786
+ local: createDirtyProject("test-repo"),
787
+ isCloned: true,
788
+ isOnGitHub: true,
789
+ localPath: "/path/to/repo",
790
+ };
791
+
792
+ const status = formatUnifiedRepoStatus(repo);
793
+
794
+ expect(status).toContain("2M");
795
+ });
796
+
797
+ test("shows ahead status", () => {
798
+ const repo = {
799
+ id: "test-repo",
800
+ name: "test-repo",
801
+ source: "both" as const,
802
+ github: {
803
+ name: "test-repo",
804
+ fullName: "user/test-repo",
805
+ owner: "user",
806
+ description: null,
807
+ htmlUrl: "https://github.com/user/test-repo",
808
+ sshUrl: "git@github.com:user/test-repo.git",
809
+ cloneUrl: "https://github.com/user/test-repo.git",
810
+ isPrivate: false,
811
+ isArchived: false,
812
+ isFork: false,
813
+ pushedAt: new Date(),
814
+ updatedAt: new Date(),
815
+ defaultBranch: "main",
816
+ language: "TypeScript",
817
+ size: 1024,
818
+ },
819
+ local: createAheadProject("test-repo", 3),
820
+ isCloned: true,
821
+ isOnGitHub: true,
822
+ localPath: "/path/to/repo",
823
+ };
824
+
825
+ const status = formatUnifiedRepoStatus(repo);
826
+
827
+ expect(status).toContain("↑3");
828
+ });
829
+
830
+ test("shows behind status", () => {
831
+ const repo = {
832
+ id: "test-repo",
833
+ name: "test-repo",
834
+ source: "both" as const,
835
+ github: {
836
+ name: "test-repo",
837
+ fullName: "user/test-repo",
838
+ owner: "user",
839
+ description: null,
840
+ htmlUrl: "https://github.com/user/test-repo",
841
+ sshUrl: "git@github.com:user/test-repo.git",
842
+ cloneUrl: "https://github.com/user/test-repo.git",
843
+ isPrivate: false,
844
+ isArchived: false,
845
+ isFork: false,
846
+ pushedAt: new Date(),
847
+ updatedAt: new Date(),
848
+ defaultBranch: "main",
849
+ language: "TypeScript",
850
+ size: 1024,
851
+ },
852
+ local: createMockProject({
853
+ name: "test-repo",
854
+ status: createBehindStatus(2)
855
+ }),
856
+ isCloned: true,
857
+ isOnGitHub: true,
858
+ localPath: "/path/to/repo",
859
+ };
860
+
861
+ const status = formatUnifiedRepoStatus(repo);
862
+
863
+ expect(status).toContain("↓2");
864
+ });
865
+ });
866
+
867
+ describe("formatUnifiedRepo", () => {
868
+ test("formats basic repo", () => {
869
+ const repo = {
870
+ id: "test-repo",
871
+ name: "test-repo",
872
+ source: "both" as const,
873
+ github: {
874
+ name: "test-repo",
875
+ fullName: "user/test-repo",
876
+ owner: "user",
877
+ description: null,
878
+ htmlUrl: "https://github.com/user/test-repo",
879
+ sshUrl: "git@github.com:user/test-repo.git",
880
+ cloneUrl: "https://github.com/user/test-repo.git",
881
+ isPrivate: false,
882
+ isArchived: false,
883
+ isFork: false,
884
+ pushedAt: new Date(),
885
+ updatedAt: new Date(),
886
+ defaultBranch: "main",
887
+ language: "TypeScript",
888
+ size: 1024,
889
+ },
890
+ local: createMockProject({ name: "test-repo" }),
891
+ isCloned: true,
892
+ isOnGitHub: true,
893
+ localPath: "/path/to/repo",
894
+ };
895
+
896
+ const output = formatUnifiedRepo(repo);
897
+
898
+ expect(output).toContain("test-repo");
899
+ expect(output).toContain("synced");
900
+ });
901
+
902
+ test("formats with private indicator", () => {
903
+ const repo = {
904
+ id: "test-repo",
905
+ name: "test-repo",
906
+ source: "both" as const,
907
+ github: {
908
+ name: "test-repo",
909
+ fullName: "user/test-repo",
910
+ owner: "user",
911
+ description: null,
912
+ htmlUrl: "https://github.com/user/test-repo",
913
+ sshUrl: "git@github.com:user/test-repo.git",
914
+ cloneUrl: "https://github.com/user/test-repo.git",
915
+ isPrivate: true,
916
+ isArchived: false,
917
+ isFork: false,
918
+ pushedAt: new Date(),
919
+ updatedAt: new Date(),
920
+ defaultBranch: "main",
921
+ language: "TypeScript",
922
+ size: 1024,
923
+ },
924
+ local: createMockProject({ name: "test-repo" }),
925
+ isCloned: true,
926
+ isOnGitHub: true,
927
+ localPath: "/path/to/repo",
928
+ };
929
+
930
+ const output = formatUnifiedRepo(repo);
931
+
932
+ expect(output).toContain("test-repo");
933
+ expect(output).toContain("(private)");
934
+ });
935
+
936
+ test("formats in verbose mode", () => {
937
+ const repo = {
938
+ id: "test-repo",
939
+ name: "test-repo",
940
+ source: "both" as const,
941
+ github: {
942
+ name: "test-repo",
943
+ fullName: "user/test-repo",
944
+ owner: "user",
945
+ description: "A test repository for formatting",
946
+ htmlUrl: "https://github.com/user/test-repo",
947
+ sshUrl: "git@github.com:user/test-repo.git",
948
+ cloneUrl: "https://github.com/user/test-repo.git",
949
+ isPrivate: false,
950
+ isArchived: false,
951
+ isFork: false,
952
+ pushedAt: new Date(),
953
+ updatedAt: new Date(),
954
+ defaultBranch: "main",
955
+ language: "TypeScript",
956
+ size: 1024,
957
+ },
958
+ local: createMockProject({ name: "test-repo" }),
959
+ isCloned: true,
960
+ isOnGitHub: true,
961
+ localPath: "/path/to/repo",
962
+ };
963
+
964
+ const output = formatUnifiedRepo(repo, true);
965
+
966
+ expect(output).toContain("test-repo");
967
+ expect(output).toContain("Local: /path/to/repo");
968
+ expect(output).toContain("GitHub: user/test-repo");
969
+ expect(output).toContain("Desc: A test repository for formatting");
970
+ expect(output).toContain("Status: synced");
971
+ });
972
+
973
+ test("handles github-only repo in verbose mode", () => {
974
+ const repo = {
975
+ id: "test-repo",
976
+ name: "test-repo",
977
+ source: "github" as const,
978
+ github: {
979
+ name: "test-repo",
980
+ fullName: "user/test-repo",
981
+ owner: "user",
982
+ description: "Not cloned yet",
983
+ htmlUrl: "https://github.com/user/test-repo",
984
+ sshUrl: "git@github.com:user/test-repo.git",
985
+ cloneUrl: "https://github.com/user/test-repo.git",
986
+ isPrivate: false,
987
+ isArchived: false,
988
+ isFork: false,
989
+ pushedAt: new Date(),
990
+ updatedAt: new Date(),
991
+ defaultBranch: "main",
992
+ language: "TypeScript",
993
+ size: 1024,
994
+ },
995
+ local: null,
996
+ isCloned: false,
997
+ isOnGitHub: true,
998
+ localPath: null,
999
+ };
1000
+
1001
+ const output = formatUnifiedRepo(repo, true);
1002
+
1003
+ expect(output).toContain("test-repo");
1004
+ expect(output).not.toContain("Local:");
1005
+ expect(output).toContain("GitHub: user/test-repo");
1006
+ expect(output).toContain("Desc: Not cloned yet");
1007
+ });
1008
+ });
1009
+
1010
+ describe("formatUnifiedStats", () => {
1011
+ test("formats stats with all counts", () => {
1012
+ const stats = {
1013
+ total: 10,
1014
+ both: 5,
1015
+ localOnly: 2,
1016
+ githubOnly: 3,
1017
+ dirty: 1,
1018
+ unpushed: 2,
1019
+ unpulled: 1,
1020
+ };
1021
+
1022
+ const output = formatUnifiedStats(stats);
1023
+
1024
+ expect(output).toContain("5 synced");
1025
+ expect(output).toContain("2 local-only");
1026
+ expect(output).toContain("3 github-only");
1027
+ expect(output).toContain("1 dirty");
1028
+ expect(output).toContain("2 unpushed");
1029
+ });
1030
+
1031
+ test("formats stats with zero counts", () => {
1032
+ const stats = {
1033
+ total: 0,
1034
+ both: 0,
1035
+ localOnly: 0,
1036
+ githubOnly: 0,
1037
+ dirty: 0,
1038
+ unpushed: 0,
1039
+ unpulled: 0,
1040
+ };
1041
+
1042
+ const output = formatUnifiedStats(stats);
1043
+
1044
+ expect(output).toContain("0 synced");
1045
+ expect(output).toContain("0 local-only");
1046
+ expect(output).toContain("0 github-only");
1047
+ expect(output).toContain("0 dirty");
1048
+ expect(output).toContain("0 unpushed");
1049
+ });
1050
+ });
1051
+
1052
+ describe("formatAuthSuccess", () => {
1053
+ test("formats success with login only", () => {
1054
+ const output = formatAuthSuccess("johndoe");
1055
+
1056
+ expect(output).toContain("✓");
1057
+ expect(output).toContain("Authenticated as johndoe");
1058
+ expect(output).not.toContain("Name:");
1059
+ });
1060
+
1061
+ test("formats success with login and name", () => {
1062
+ const output = formatAuthSuccess("johndoe", "John Doe");
1063
+
1064
+ expect(output).toContain("✓");
1065
+ expect(output).toContain("Authenticated as johndoe");
1066
+ expect(output).toContain("Name: John Doe");
1067
+ });
1068
+ });
1069
+
1070
+ describe("formatAuthFailure", () => {
1071
+ test("formats failure without error", () => {
1072
+ const output = formatAuthFailure();
1073
+
1074
+ expect(output).toContain("✗");
1075
+ expect(output).toContain("Authentication failed");
1076
+ expect(output).not.toContain(":");
1077
+ });
1078
+
1079
+ test("formats failure with error", () => {
1080
+ const output = formatAuthFailure("Invalid token");
1081
+
1082
+ expect(output).toContain("✗");
1083
+ expect(output).toContain("Authentication failed: Invalid token");
1084
+ });
1085
+ });
1086
+
1087
+ describe("formatNoToken", () => {
1088
+ test("formats no token message", () => {
1089
+ const output = formatNoToken();
1090
+
1091
+ expect(output).toContain("✗");
1092
+ expect(output).toContain("GITHUB_TOKEN not set");
1093
+ expect(output).toContain("gitforest login");
1094
+ expect(output).toContain("export GITHUB_TOKEN=your_token");
1095
+ });
1096
+ });
1097
+
1098
+ describe("formatCloneItem", () => {
1099
+ test("formats successful clone", () => {
1100
+ const output = formatCloneItem("user/repo", true, "/path/to/repo");
1101
+
1102
+ expect(output).toContain("✓");
1103
+ expect(output).toContain("user/repo");
1104
+ expect(output).toContain("→ /path/to/repo");
1105
+ });
1106
+
1107
+ test("formats failed clone", () => {
1108
+ const output = formatCloneItem("user/repo", false, undefined, "Already exists");
1109
+
1110
+ expect(output).toContain("✗");
1111
+ expect(output).toContain("user/repo");
1112
+ expect(output).toContain("Already exists");
1113
+ expect(output).not.toContain("→");
1114
+ });
1115
+ });
1116
+ });