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,1201 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ toGitHubRepoInfo,
4
+ createUnifiedView,
5
+ filterByViewMode,
6
+ sortUnifiedRepos,
7
+ filterUnifiedRepos,
8
+ getUnifiedStats,
9
+ cloneGitHubRepo,
10
+ } from "../../../src/github/unified.ts";
11
+ import type { Project, GitHubRepoInfo } from "../../../src/types/index.ts";
12
+ import type { GitHubRepoData } from "../../../src/services/github.ts";
13
+ import type { GitService } from "../../../src/services/git.ts";
14
+ import {
15
+ createMockProject,
16
+ createMockGitHubRepoInfo,
17
+ createMockUnifiedRepo,
18
+ } from "../mocks/index.ts";
19
+
20
+ describe("toGitHubRepoInfo", () => {
21
+ test("converts GitHubRepoData to GitHubRepoInfo", () => {
22
+ const repo: GitHubRepoData = {
23
+ id: 123,
24
+ name: "my-repo",
25
+ fullName: "user/my-repo",
26
+ description: "A test repo",
27
+ htmlUrl: "https://github.com/user/my-repo",
28
+ sshUrl: "git@github.com:user/my-repo.git",
29
+ cloneUrl: "https://github.com/user/my-repo.git",
30
+ isPrivate: true,
31
+ isArchived: false,
32
+ isFork: false,
33
+ createdAt: "2024-01-01T00:00:00Z",
34
+ updatedAt: "2024-06-01T00:00:00Z",
35
+ pushedAt: "2024-06-15T00:00:00Z",
36
+ size: 1024,
37
+ stargazersCount: 10,
38
+ forksCount: 5,
39
+ openIssuesCount: 2,
40
+ watchersCount: 15,
41
+ topics: ["typescript", "test"],
42
+ license: { name: "MIT" },
43
+ hasIssues: true,
44
+ hasWiki: true,
45
+ hasDiscussions: false,
46
+ language: "TypeScript",
47
+ defaultBranch: "main",
48
+ owner: {
49
+ login: "user",
50
+ type: "User",
51
+ },
52
+ };
53
+
54
+ const result = toGitHubRepoInfo(repo);
55
+
56
+ expect(result.name).toBe("my-repo");
57
+ expect(result.fullName).toBe("user/my-repo");
58
+ expect(result.owner).toBe("user");
59
+ expect(result.description).toBe("A test repo");
60
+ expect(result.isPrivate).toBe(true);
61
+ expect(result.isArchived).toBe(false);
62
+ expect(result.isFork).toBe(false);
63
+ expect(result.language).toBe("TypeScript");
64
+ expect(result.pushedAt).toBeInstanceOf(Date);
65
+ });
66
+
67
+ test("handles null pushed_at", () => {
68
+ const repo: GitHubRepoData = {
69
+ id: 123,
70
+ name: "empty-repo",
71
+ fullName: "user/empty-repo",
72
+ description: null,
73
+ htmlUrl: "https://github.com/user/empty-repo",
74
+ sshUrl: "git@github.com:user/empty-repo.git",
75
+ cloneUrl: "https://github.com/user/empty-repo.git",
76
+ isPrivate: false,
77
+ isArchived: false,
78
+ isFork: false,
79
+ createdAt: "2024-01-01T00:00:00Z",
80
+ updatedAt: "2024-01-01T00:00:00Z",
81
+ pushedAt: null,
82
+ size: 0,
83
+ stargazersCount: 0,
84
+ forksCount: 0,
85
+ openIssuesCount: 0,
86
+ watchersCount: 0,
87
+ topics: [],
88
+ license: null,
89
+ hasIssues: true,
90
+ hasWiki: true,
91
+ hasDiscussions: false,
92
+ language: null,
93
+ defaultBranch: "main",
94
+ owner: {
95
+ login: "user",
96
+ type: "User",
97
+ },
98
+ };
99
+
100
+ const result = toGitHubRepoInfo(repo);
101
+
102
+ expect(result.pushedAt).toBeNull();
103
+ expect(result.description).toBeNull();
104
+ expect(result.language).toBeNull();
105
+ });
106
+
107
+ test("handles missing optional fields", () => {
108
+ const repo: GitHubRepoData = {
109
+ id: 123,
110
+ name: "minimal-repo",
111
+ fullName: "user/minimal-repo",
112
+ description: "Minimal repo",
113
+ htmlUrl: "https://github.com/user/minimal-repo",
114
+ sshUrl: "git@github.com:user/minimal-repo.git",
115
+ cloneUrl: "https://github.com/user/minimal-repo.git",
116
+ isPrivate: false,
117
+ isArchived: false,
118
+ isFork: false,
119
+ createdAt: "2024-01-01T00:00:00Z",
120
+ updatedAt: "2024-01-01T00:00:00Z",
121
+ pushedAt: "2024-01-01T00:00:00Z",
122
+ size: 100,
123
+ stargazersCount: 0,
124
+ forksCount: 0,
125
+ openIssuesCount: 0,
126
+ watchersCount: 0,
127
+ topics: [],
128
+ license: null,
129
+ hasIssues: false,
130
+ hasWiki: false,
131
+ hasDiscussions: false,
132
+ language: null,
133
+ defaultBranch: "main",
134
+ owner: {
135
+ login: "user",
136
+ type: "User",
137
+ },
138
+ };
139
+
140
+ const result = toGitHubRepoInfo(repo);
141
+
142
+ expect(result.license).toBeNull();
143
+ expect(result.hasIssues).toBe(false);
144
+ expect(result.hasWiki).toBe(false);
145
+ expect(result.hasDiscussions).toBe(false);
146
+ });
147
+ });
148
+
149
+ describe("createUnifiedView", () => {
150
+ test("matches local projects with GitHub repos", () => {
151
+ const local = [
152
+ createMockProject({
153
+ name: "my-repo",
154
+ status: {
155
+ ...createMockProject().status!,
156
+ remoteUrl: "git@github.com:user/my-repo.git",
157
+ hasRemote: true
158
+ }
159
+ }),
160
+ ];
161
+ const github = [createMockGitHubRepoInfo({ name: "my-repo", owner: "user" })];
162
+
163
+ const unified = createUnifiedView(local, github);
164
+
165
+ expect(unified).toHaveLength(1);
166
+ expect(unified[0]?.source).toBe("both");
167
+ expect(unified[0]?.isCloned).toBe(true);
168
+ expect(unified[0]?.isOnGitHub).toBe(true);
169
+ });
170
+
171
+ test("identifies local-only projects", () => {
172
+ const local = [createMockProject({
173
+ name: "local-only",
174
+ status: {
175
+ ...createMockProject().status!,
176
+ remoteUrl: null,
177
+ hasRemote: false
178
+ }
179
+ })];
180
+ const github: GitHubRepoInfo[] = [];
181
+
182
+ const unified = createUnifiedView(local, github);
183
+
184
+ expect(unified).toHaveLength(1);
185
+ expect(unified[0]?.source).toBe("local");
186
+ expect(unified[0]?.isCloned).toBe(true);
187
+ expect(unified[0]?.isOnGitHub).toBe(false);
188
+ });
189
+
190
+ test("identifies GitHub-only repos", () => {
191
+ const local: Project[] = [];
192
+ const github = [createMockGitHubRepoInfo({ name: "github-only" })];
193
+
194
+ const unified = createUnifiedView(local, github);
195
+
196
+ expect(unified).toHaveLength(1);
197
+ expect(unified[0]?.source).toBe("github");
198
+ expect(unified[0]?.isCloned).toBe(false);
199
+ expect(unified[0]?.isOnGitHub).toBe(true);
200
+ });
201
+
202
+ test("handles mixed projects correctly", () => {
203
+ const local = [
204
+ createMockProject({
205
+ name: "synced",
206
+ status: {
207
+ ...createMockProject().status!,
208
+ remoteUrl: "git@github.com:user/synced.git",
209
+ hasRemote: true
210
+ }
211
+ }),
212
+ createMockProject({
213
+ name: "local-only",
214
+ status: {
215
+ ...createMockProject().status!,
216
+ remoteUrl: null,
217
+ hasRemote: false
218
+ }
219
+ }),
220
+ ];
221
+ const github = [
222
+ createMockGitHubRepoInfo({ name: "synced", owner: "user" }),
223
+ createMockGitHubRepoInfo({ name: "github-only" }),
224
+ ];
225
+
226
+ const unified = createUnifiedView(local, github);
227
+
228
+ expect(unified).toHaveLength(3);
229
+
230
+ const synced = unified.find((r) => r.name === "synced");
231
+ const localOnly = unified.find((r) => r.name === "local-only");
232
+ const githubOnly = unified.find((r) => r.name === "github-only");
233
+
234
+ expect(synced?.source).toBe("both");
235
+ expect(localOnly?.source).toBe("local");
236
+ expect(githubOnly?.source).toBe("github");
237
+ });
238
+
239
+ test("matches HTTPS remote URLs", () => {
240
+ const local = [
241
+ createMockProject({
242
+ name: "https-repo",
243
+ status: {
244
+ ...createMockProject().status!,
245
+ remoteUrl: "https://github.com/user/https-repo.git",
246
+ hasRemote: true
247
+ }
248
+ }),
249
+ ];
250
+ const github = [createMockGitHubRepoInfo({ name: "https-repo", owner: "user" })];
251
+
252
+ const unified = createUnifiedView(local, github);
253
+
254
+ expect(unified).toHaveLength(1);
255
+ expect(unified[0]?.source).toBe("both");
256
+ });
257
+
258
+ test("handles case-insensitive matching", () => {
259
+ const local = [
260
+ createMockProject({
261
+ name: "MyRepo",
262
+ status: {
263
+ ...createMockProject().status!,
264
+ remoteUrl: "git@github.com:user/MyRepo.git",
265
+ hasRemote: true
266
+ }
267
+ }),
268
+ ];
269
+ const github = [createMockGitHubRepoInfo({ name: "myrepo", fullName: "user/myrepo" })];
270
+
271
+ const unified = createUnifiedView(local, github);
272
+
273
+ expect(unified).toHaveLength(1);
274
+ expect(unified[0]?.source).toBe("both");
275
+ });
276
+
277
+ test("handles empty arrays", () => {
278
+ const local: Project[] = [];
279
+ const github: GitHubRepoInfo[] = [];
280
+
281
+ const unified = createUnifiedView(local, github);
282
+
283
+ expect(unified).toHaveLength(0);
284
+ });
285
+
286
+ test("handles multiple GitHub repos with same name but different owners", () => {
287
+ const local = [
288
+ createMockProject({
289
+ name: "test-repo",
290
+ status: {
291
+ ...createMockProject().status!,
292
+ remoteUrl: "git@github.com:user1/test-repo.git",
293
+ hasRemote: true
294
+ }
295
+ }),
296
+ ];
297
+ const github = [
298
+ createMockGitHubRepoInfo({ name: "test-repo", owner: "user1", fullName: "user1/test-repo" }),
299
+ createMockGitHubRepoInfo({ name: "test-repo", owner: "user2", fullName: "user2/test-repo" }),
300
+ ];
301
+
302
+ const unified = createUnifiedView(local, github);
303
+
304
+ expect(unified).toHaveLength(2);
305
+ const matched = unified.find(r => r.source === "both");
306
+ expect(matched?.github?.owner).toBe("user1");
307
+ });
308
+ });
309
+
310
+ describe("filterByViewMode", () => {
311
+ test("filters to local view", () => {
312
+ const repos = [
313
+ createMockUnifiedRepo({ name: "synced", source: "both", isCloned: true, isOnGitHub: true }),
314
+ createMockUnifiedRepo({ name: "local-only", source: "local", isCloned: true, isOnGitHub: false }),
315
+ createMockUnifiedRepo({ name: "github-only", source: "github", isCloned: false, isOnGitHub: true }),
316
+ ];
317
+
318
+ const filtered = filterByViewMode(repos, "local");
319
+
320
+ expect(filtered).toHaveLength(2);
321
+ expect(filtered.every((r) => r.isCloned)).toBe(true);
322
+ });
323
+
324
+ test("filters to github view", () => {
325
+ const repos = [
326
+ createMockUnifiedRepo({ name: "synced", source: "both", isCloned: true, isOnGitHub: true }),
327
+ createMockUnifiedRepo({ name: "local-only", source: "local", isCloned: true, isOnGitHub: false }),
328
+ createMockUnifiedRepo({ name: "github-only", source: "github", isCloned: false, isOnGitHub: true }),
329
+ ];
330
+
331
+ const filtered = filterByViewMode(repos, "github");
332
+
333
+ expect(filtered).toHaveLength(2);
334
+ expect(filtered.every((r) => r.isOnGitHub)).toBe(true);
335
+ });
336
+
337
+ test("returns all for combined view", () => {
338
+ const repos = [
339
+ createMockUnifiedRepo({ name: "synced", source: "both", isCloned: true, isOnGitHub: true }),
340
+ createMockUnifiedRepo({ name: "local-only", source: "local", isCloned: true, isOnGitHub: false }),
341
+ createMockUnifiedRepo({ name: "github-only", source: "github", isCloned: false, isOnGitHub: true }),
342
+ ];
343
+
344
+ const filtered = filterByViewMode(repos, "combined");
345
+
346
+ expect(filtered).toHaveLength(3);
347
+ });
348
+ });
349
+
350
+ describe("sortUnifiedRepos", () => {
351
+ test("sorts by name ascending", () => {
352
+ const repos = [
353
+ createMockUnifiedRepo({ name: "zebra" }),
354
+ createMockUnifiedRepo({ name: "alpha" }),
355
+ ];
356
+
357
+ const sorted = sortUnifiedRepos(repos, "name", "asc");
358
+
359
+ expect(sorted[0]?.name).toBe("alpha");
360
+ expect(sorted[1]?.name).toBe("zebra");
361
+ });
362
+
363
+ test("sorts by name descending", () => {
364
+ const repos = [
365
+ createMockUnifiedRepo({ name: "zebra" }),
366
+ createMockUnifiedRepo({ name: "alpha" }),
367
+ ];
368
+
369
+ const sorted = sortUnifiedRepos(repos, "name", "desc");
370
+
371
+ expect(sorted[0]?.name).toBe("zebra");
372
+ expect(sorted[1]?.name).toBe("alpha");
373
+ });
374
+
375
+ test("sorts by status (github-only first in desc)", () => {
376
+ const repos = [
377
+ createMockUnifiedRepo({ name: "zebra", source: "both" }),
378
+ createMockUnifiedRepo({ name: "alpha", source: "github", isCloned: false }),
379
+ ];
380
+
381
+ const sorted = sortUnifiedRepos(repos, "status", "desc");
382
+
383
+ // GitHub-only repos should come first (not cloned = needs attention)
384
+ expect(sorted[0]?.name).toBe("alpha");
385
+ expect(sorted[0]?.source).toBe("github");
386
+ });
387
+
388
+ test("sorts by lastActivity (Date objects)", () => {
389
+ const localProject = createMockProject({ name: "old-repo" });
390
+ const repos = [
391
+ createMockUnifiedRepo({
392
+ name: "old-repo",
393
+ source: "both",
394
+ local: localProject,
395
+ }),
396
+ createMockUnifiedRepo({
397
+ name: "new-repo",
398
+ source: "github",
399
+ local: null,
400
+ github: createMockGitHubRepoInfo({ name: "new-repo", pushedAt: new Date("2024-06-01") }),
401
+ }),
402
+ ];
403
+
404
+ // Set local commit date for old-repo
405
+ if (repos[0]?.local?.status) {
406
+ repos[0].local.status.lastLocalCommit = new Date("2024-01-01");
407
+ }
408
+
409
+ const sorted = sortUnifiedRepos(repos, "lastActivity", "desc");
410
+
411
+ // Newer repo should come first
412
+ expect(sorted[0]?.name).toBe("new-repo");
413
+ expect(sorted[1]?.name).toBe("old-repo");
414
+ });
415
+
416
+ test("sorts by lastActivity (string dates)", () => {
417
+ const repos = [
418
+ createMockUnifiedRepo({
419
+ name: "old-repo",
420
+ source: "github",
421
+ local: null,
422
+ github: createMockGitHubRepoInfo({ name: "old-repo", pushedAt: new Date("2024-01-01") }),
423
+ }),
424
+ createMockUnifiedRepo({
425
+ name: "new-repo",
426
+ source: "github",
427
+ local: null,
428
+ github: createMockGitHubRepoInfo({ name: "new-repo", pushedAt: new Date("2024-06-01") }),
429
+ }),
430
+ ];
431
+
432
+ const sorted = sortUnifiedRepos(repos, "lastActivity", "desc");
433
+
434
+ // Newer repo should come first
435
+ expect(sorted[0]?.name).toBe("new-repo");
436
+ expect(sorted[1]?.name).toBe("old-repo");
437
+ });
438
+
439
+ test("sorts by stars", () => {
440
+ const repos = [
441
+ createMockUnifiedRepo({
442
+ name: "low-stars",
443
+ source: "github",
444
+ local: null,
445
+ github: createMockGitHubRepoInfo({ name: "low-stars", stargazersCount: 10 }),
446
+ }),
447
+ createMockUnifiedRepo({
448
+ name: "high-stars",
449
+ source: "github",
450
+ local: null,
451
+ github: createMockGitHubRepoInfo({ name: "high-stars", stargazersCount: 1000 }),
452
+ }),
453
+ ];
454
+
455
+ const sorted = sortUnifiedRepos(repos, "stars", "desc");
456
+
457
+ // Higher stars should come first
458
+ expect(sorted[0]?.name).toBe("high-stars");
459
+ expect(sorted[0]?.github?.stargazersCount).toBe(1000);
460
+ expect(sorted[1]?.name).toBe("low-stars");
461
+ expect(sorted[1]?.github?.stargazersCount).toBe(10);
462
+ });
463
+
464
+ test("sorts by size", () => {
465
+ const repos = [
466
+ createMockUnifiedRepo({
467
+ name: "small-repo",
468
+ source: "github",
469
+ local: null,
470
+ github: createMockGitHubRepoInfo({ name: "small-repo", size: 100 }),
471
+ }),
472
+ createMockUnifiedRepo({
473
+ name: "large-repo",
474
+ source: "github",
475
+ local: null,
476
+ github: createMockGitHubRepoInfo({ name: "large-repo", size: 10000 }),
477
+ }),
478
+ ];
479
+
480
+ const sorted = sortUnifiedRepos(repos, "size", "desc");
481
+
482
+ // Larger size should come first
483
+ expect(sorted[0]?.name).toBe("large-repo");
484
+ expect(sorted[0]?.github?.size).toBe(10000);
485
+ expect(sorted[1]?.name).toBe("small-repo");
486
+ expect(sorted[1]?.github?.size).toBe(100);
487
+ });
488
+
489
+ test("does not mutate original array", () => {
490
+ const repos = [
491
+ createMockUnifiedRepo({ name: "zebra" }),
492
+ createMockUnifiedRepo({ name: "alpha" }),
493
+ ];
494
+ const originalFirst = repos[0]?.name;
495
+ sortUnifiedRepos(repos, "name", "asc");
496
+ expect(repos[0]?.name).toBe(originalFirst);
497
+ });
498
+
499
+ test("handles missing github data for stars and size sorts", () => {
500
+ const repos = [
501
+ createMockUnifiedRepo({
502
+ name: "local-only",
503
+ source: "local",
504
+ local: createMockProject({ name: "local-only" }),
505
+ github: null,
506
+ }),
507
+ createMockUnifiedRepo({
508
+ name: "github-repo",
509
+ source: "github",
510
+ local: null,
511
+ github: createMockGitHubRepoInfo({ name: "github-repo", size: 1000, stargazersCount: 100 }),
512
+ }),
513
+ ];
514
+
515
+ const sortedByStars = sortUnifiedRepos(repos, "stars", "desc");
516
+ expect(sortedByStars[0]?.name).toBe("github-repo");
517
+ expect(sortedByStars[1]?.name).toBe("local-only");
518
+
519
+ const sortedBySize = sortUnifiedRepos(repos, "size", "desc");
520
+ expect(sortedBySize[0]?.name).toBe("github-repo");
521
+ expect(sortedBySize[1]?.name).toBe("local-only");
522
+ });
523
+ });
524
+
525
+ describe("filterUnifiedRepos", () => {
526
+ test("filters by name", () => {
527
+ const repos = [
528
+ createMockUnifiedRepo({
529
+ name: "my-awesome-project",
530
+ source: "both",
531
+ local: createMockProject({ name: "my-awesome-project" }),
532
+ github: createMockGitHubRepoInfo({
533
+ name: "my-awesome-project",
534
+ description: "An awesome TypeScript project",
535
+ language: "TypeScript",
536
+ }),
537
+ }),
538
+ createMockUnifiedRepo({
539
+ name: "rust-lib",
540
+ source: "github",
541
+ local: null,
542
+ github: createMockGitHubRepoInfo({
543
+ name: "rust-lib",
544
+ description: "A Rust library",
545
+ language: "Rust",
546
+ }),
547
+ }),
548
+ ];
549
+
550
+ const filtered = filterUnifiedRepos(repos, "awesome");
551
+
552
+ expect(filtered).toHaveLength(1);
553
+ expect(filtered[0]?.name).toBe("my-awesome-project");
554
+ });
555
+
556
+ test("filters by description", () => {
557
+ const repos = [
558
+ createMockUnifiedRepo({
559
+ name: "my-awesome-project",
560
+ source: "both",
561
+ local: createMockProject({ name: "my-awesome-project" }),
562
+ github: createMockGitHubRepoInfo({
563
+ name: "my-awesome-project",
564
+ description: "An awesome TypeScript project",
565
+ language: "TypeScript",
566
+ }),
567
+ }),
568
+ createMockUnifiedRepo({
569
+ name: "rust-lib",
570
+ source: "github",
571
+ local: null,
572
+ github: createMockGitHubRepoInfo({
573
+ name: "rust-lib",
574
+ description: "A Rust library",
575
+ language: "Rust",
576
+ }),
577
+ }),
578
+ ];
579
+
580
+ const filtered = filterUnifiedRepos(repos, "library");
581
+
582
+ expect(filtered).toHaveLength(1);
583
+ expect(filtered[0]?.name).toBe("rust-lib");
584
+ });
585
+
586
+ test("filters by language", () => {
587
+ const repos = [
588
+ createMockUnifiedRepo({
589
+ name: "my-awesome-project",
590
+ source: "both",
591
+ local: createMockProject({ name: "my-awesome-project" }),
592
+ github: createMockGitHubRepoInfo({
593
+ name: "my-awesome-project",
594
+ description: "An awesome TypeScript project",
595
+ language: "TypeScript",
596
+ }),
597
+ }),
598
+ createMockUnifiedRepo({
599
+ name: "rust-lib",
600
+ source: "github",
601
+ local: null,
602
+ github: createMockGitHubRepoInfo({
603
+ name: "rust-lib",
604
+ description: "A Rust library",
605
+ language: "Rust",
606
+ }),
607
+ }),
608
+ ];
609
+
610
+ const filtered = filterUnifiedRepos(repos, "rust");
611
+
612
+ expect(filtered).toHaveLength(1);
613
+ expect(filtered[0]?.name).toBe("rust-lib");
614
+ });
615
+
616
+ test("filters by path", () => {
617
+ const repos = [
618
+ createMockUnifiedRepo({
619
+ name: "my-project",
620
+ source: "local",
621
+ local: createMockProject({ name: "my-project", path: "/home/user/projects/my-project" }),
622
+ localPath: "/home/user/projects/my-project",
623
+ }),
624
+ createMockUnifiedRepo({
625
+ name: "other-project",
626
+ source: "local",
627
+ local: createMockProject({ name: "other-project", path: "/home/user/other/other-project" }),
628
+ localPath: "/home/user/other/other-project",
629
+ }),
630
+ ];
631
+
632
+ const filtered = filterUnifiedRepos(repos, "projects");
633
+
634
+ expect(filtered).toHaveLength(1);
635
+ expect(filtered[0]?.name).toBe("my-project");
636
+ });
637
+
638
+ test("filters by full name", () => {
639
+ const repos = [
640
+ createMockUnifiedRepo({
641
+ name: "my-project",
642
+ source: "github",
643
+ local: null,
644
+ github: createMockGitHubRepoInfo({ name: "my-project", fullName: "user1/my-project" }),
645
+ }),
646
+ createMockUnifiedRepo({
647
+ name: "other-project",
648
+ source: "github",
649
+ local: null,
650
+ github: createMockGitHubRepoInfo({ name: "other-project", fullName: "user2/other-project" }),
651
+ }),
652
+ ];
653
+
654
+ const filtered = filterUnifiedRepos(repos, "user1");
655
+
656
+ expect(filtered).toHaveLength(1);
657
+ expect(filtered[0]?.name).toBe("my-project");
658
+ });
659
+
660
+ test("filters by source type", () => {
661
+ const repos = [
662
+ createMockUnifiedRepo({ name: "local-repo", source: "local" }),
663
+ createMockUnifiedRepo({ name: "github-repo", source: "github" }),
664
+ createMockUnifiedRepo({ name: "synced-repo", source: "both" }),
665
+ ];
666
+
667
+ const filtered = filterUnifiedRepos(repos, "local");
668
+
669
+ // Should only match "local" repos (exact match)
670
+ expect(filtered).toHaveLength(1);
671
+ expect(filtered[0]?.name).toBe("local-repo");
672
+ expect(filtered[0]?.source).toBe("local");
673
+ });
674
+
675
+ test("filter is case-insensitive", () => {
676
+ const repos = [
677
+ createMockUnifiedRepo({
678
+ name: "my-awesome-project",
679
+ source: "both",
680
+ local: createMockProject({ name: "my-awesome-project" }),
681
+ github: createMockGitHubRepoInfo({
682
+ name: "my-awesome-project",
683
+ description: "An awesome TypeScript project",
684
+ language: "TypeScript",
685
+ }),
686
+ }),
687
+ createMockUnifiedRepo({
688
+ name: "rust-lib",
689
+ source: "github",
690
+ local: null,
691
+ github: createMockGitHubRepoInfo({
692
+ name: "rust-lib",
693
+ description: "A Rust library",
694
+ language: "Rust",
695
+ }),
696
+ }),
697
+ ];
698
+
699
+ const filtered = filterUnifiedRepos(repos, "TYPESCRIPT");
700
+
701
+ expect(filtered).toHaveLength(1);
702
+ expect(filtered[0]?.name).toBe("my-awesome-project");
703
+ });
704
+
705
+ test("returns all when filter is empty", () => {
706
+ const repos = [
707
+ createMockUnifiedRepo({
708
+ name: "my-awesome-project",
709
+ source: "both",
710
+ local: createMockProject({ name: "my-awesome-project" }),
711
+ github: createMockGitHubRepoInfo({
712
+ name: "my-awesome-project",
713
+ description: "An awesome TypeScript project",
714
+ language: "TypeScript",
715
+ }),
716
+ }),
717
+ createMockUnifiedRepo({
718
+ name: "rust-lib",
719
+ source: "github",
720
+ local: null,
721
+ github: createMockGitHubRepoInfo({
722
+ name: "rust-lib",
723
+ description: "A Rust library",
724
+ language: "Rust",
725
+ }),
726
+ }),
727
+ ];
728
+
729
+ const filtered = filterUnifiedRepos(repos, "");
730
+
731
+ expect(filtered).toHaveLength(2);
732
+ });
733
+
734
+ test("handles null values in github data", () => {
735
+ const repos = [
736
+ createMockUnifiedRepo({
737
+ name: "no-description",
738
+ source: "github",
739
+ local: null,
740
+ github: createMockGitHubRepoInfo({ name: "no-description", description: null, language: null }),
741
+ }),
742
+ ];
743
+
744
+ const filteredByDescription = filterUnifiedRepos(repos, "something");
745
+ expect(filteredByDescription).toHaveLength(0);
746
+
747
+ const filteredByLanguage = filterUnifiedRepos(repos, "javascript");
748
+ expect(filteredByLanguage).toHaveLength(0);
749
+ });
750
+ });
751
+
752
+ describe("getUnifiedStats", () => {
753
+ test("calculates stats correctly", () => {
754
+ const repos = [
755
+ createMockUnifiedRepo({
756
+ name: "synced",
757
+ source: "both",
758
+ local: createMockProject({ name: "synced", status: { ...createMockProject().status!, isDirty: true, isAhead: true } }),
759
+ }),
760
+ createMockUnifiedRepo({
761
+ name: "local-only",
762
+ source: "local",
763
+ local: createMockProject({ name: "local-only" }),
764
+ }),
765
+ createMockUnifiedRepo({
766
+ name: "github-only",
767
+ source: "github",
768
+ local: null,
769
+ }),
770
+ ];
771
+
772
+ const stats = getUnifiedStats(repos);
773
+
774
+ expect(stats.total).toBe(3);
775
+ expect(stats.both).toBe(1);
776
+ expect(stats.localOnly).toBe(1);
777
+ expect(stats.githubOnly).toBe(1);
778
+ expect(stats.dirty).toBe(1);
779
+ expect(stats.unpushed).toBe(1);
780
+ expect(stats.unpulled).toBe(0);
781
+ });
782
+
783
+ test("handles empty repos", () => {
784
+ const stats = getUnifiedStats([]);
785
+
786
+ expect(stats.total).toBe(0);
787
+ expect(stats.both).toBe(0);
788
+ expect(stats.localOnly).toBe(0);
789
+ expect(stats.githubOnly).toBe(0);
790
+ });
791
+
792
+ test("counts multiple dirty repos", () => {
793
+ const repos = [
794
+ createMockUnifiedRepo({
795
+ name: "dirty1",
796
+ source: "local",
797
+ local: createMockProject({ name: "dirty1", status: { ...createMockProject().status!, isDirty: true } }),
798
+ }),
799
+ createMockUnifiedRepo({
800
+ name: "dirty2",
801
+ source: "local",
802
+ local: createMockProject({ name: "dirty2", status: { ...createMockProject().status!, isDirty: true } }),
803
+ }),
804
+ createMockUnifiedRepo({
805
+ name: "clean",
806
+ source: "local",
807
+ local: createMockProject({ name: "clean", status: { ...createMockProject().status!, isDirty: false } }),
808
+ }),
809
+ ];
810
+
811
+ const stats = getUnifiedStats(repos);
812
+
813
+ expect(stats.dirty).toBe(2);
814
+ });
815
+
816
+ test("counts repos that are both ahead and behind", () => {
817
+ const repos = [
818
+ createMockUnifiedRepo({
819
+ name: "ahead-repo",
820
+ source: "local",
821
+ local: createMockProject({ name: "ahead-repo", status: { ...createMockProject().status!, isAhead: true } }),
822
+ }),
823
+ createMockUnifiedRepo({
824
+ name: "behind-repo",
825
+ source: "local",
826
+ local: createMockProject({ name: "behind-repo", status: { ...createMockProject().status!, isBehind: true } }),
827
+ }),
828
+ createMockUnifiedRepo({
829
+ name: "synced-repo",
830
+ source: "local",
831
+ local: createMockProject({ name: "synced-repo", status: { ...createMockProject().status!, isAhead: false, isBehind: false } }),
832
+ }),
833
+ ];
834
+
835
+ const stats = getUnifiedStats(repos);
836
+
837
+ expect(stats.unpushed).toBe(1);
838
+ expect(stats.unpulled).toBe(1);
839
+ });
840
+ });
841
+
842
+ describe("cloneGitHubRepo", () => {
843
+ test("successfully clones a GitHub repo", async () => {
844
+ const mockGitService: GitService = {
845
+ clone: async () => ({
846
+ success: true,
847
+ projectPath: "/tmp/test-repo",
848
+ operation: "clone",
849
+ duration: 100
850
+ }),
851
+ isGitRepo: async () => true,
852
+ getGitRoot: async () => "/test/repo",
853
+ isSubmodule: async () => false,
854
+ getSubmoduleParent: async () => null,
855
+ getStatus: async () => ({
856
+ isDirty: false,
857
+ hasUnstagedChanges: false,
858
+ hasStagedChanges: false,
859
+ hasUntrackedFiles: false,
860
+ modifiedCount: 0,
861
+ stagedCount: 0,
862
+ untrackedCount: 0,
863
+ currentBranch: "main",
864
+ trackingBranch: null,
865
+ unpushedCommits: 0,
866
+ unpulledCommits: 0,
867
+ hasRemote: true,
868
+ remoteUrl: null,
869
+ lastLocalCommit: new Date(),
870
+ lastRemoteActivity: null,
871
+ hasCommits: true,
872
+ isAhead: false,
873
+ isBehind: false,
874
+ isOutOfSync: false,
875
+ }),
876
+ getStatusPorcelain: async () => "",
877
+ getCurrentBranch: async () => "main",
878
+ getTrackingBranch: async () => null,
879
+ countUnpushedCommits: async () => 0,
880
+ countUnpulledCommits: async () => 0,
881
+ getRemoteUrl: async () => null,
882
+ getLastCommitDate: async () => new Date(),
883
+ getRemoteLastCommitDate: async () => new Date(),
884
+ listSubmodules: async () => [],
885
+ init: async () => ({
886
+ success: true,
887
+ projectPath: "/tmp/test-repo",
888
+ operation: "init",
889
+ duration: 100
890
+ }),
891
+ pull: async () => ({
892
+ success: true,
893
+ projectPath: "/tmp/test-repo",
894
+ operation: "pull",
895
+ duration: 100
896
+ }),
897
+ push: async () => ({
898
+ success: true,
899
+ projectPath: "/tmp/test-repo",
900
+ operation: "push",
901
+ duration: 100
902
+ }),
903
+ fetch: async () => ({
904
+ success: true,
905
+ projectPath: "/tmp/test-repo",
906
+ operation: "fetch",
907
+ duration: 100
908
+ }),
909
+ fetchAll: async () => ({
910
+ success: true,
911
+ projectPath: "/tmp/test-repo",
912
+ operation: "fetchAll",
913
+ duration: 100
914
+ }),
915
+ addRemote: async () => ({
916
+ success: true,
917
+ projectPath: "/tmp/test-repo",
918
+ operation: "addRemote",
919
+ duration: 100
920
+ }),
921
+ };
922
+
923
+ const repo = createMockUnifiedRepo({ name: "test-repo", source: "github" });
924
+ const result = await cloneGitHubRepo(repo, "/tmp", true, mockGitService);
925
+
926
+ expect(result.success).toBe(true);
927
+ expect(result.path).toBe("/tmp/test-repo");
928
+ });
929
+
930
+ test("handles clone failure", async () => {
931
+ const mockGitService: GitService = {
932
+ clone: async () => ({
933
+ success: false,
934
+ error: "Clone failed",
935
+ projectPath: "/tmp/test-repo",
936
+ operation: "clone",
937
+ duration: 100
938
+ }),
939
+ isGitRepo: async () => true,
940
+ getGitRoot: async () => "/test/repo",
941
+ isSubmodule: async () => false,
942
+ getSubmoduleParent: async () => null,
943
+ getStatus: async () => ({
944
+ isDirty: false,
945
+ hasUnstagedChanges: false,
946
+ hasStagedChanges: false,
947
+ hasUntrackedFiles: false,
948
+ modifiedCount: 0,
949
+ stagedCount: 0,
950
+ untrackedCount: 0,
951
+ currentBranch: "main",
952
+ trackingBranch: null,
953
+ unpushedCommits: 0,
954
+ unpulledCommits: 0,
955
+ hasRemote: true,
956
+ remoteUrl: null,
957
+ lastLocalCommit: new Date(),
958
+ lastRemoteActivity: null,
959
+ hasCommits: true,
960
+ isAhead: false,
961
+ isBehind: false,
962
+ isOutOfSync: false,
963
+ }),
964
+ getStatusPorcelain: async () => "",
965
+ getCurrentBranch: async () => "main",
966
+ getTrackingBranch: async () => null,
967
+ countUnpushedCommits: async () => 0,
968
+ countUnpulledCommits: async () => 0,
969
+ getRemoteUrl: async () => null,
970
+ getLastCommitDate: async () => new Date(),
971
+ getRemoteLastCommitDate: async () => new Date(),
972
+ listSubmodules: async () => [],
973
+ init: async () => ({
974
+ success: true,
975
+ projectPath: "/tmp/test-repo",
976
+ operation: "init",
977
+ duration: 100
978
+ }),
979
+ pull: async () => ({
980
+ success: true,
981
+ projectPath: "/tmp/test-repo",
982
+ operation: "pull",
983
+ duration: 100
984
+ }),
985
+ push: async () => ({
986
+ success: true,
987
+ projectPath: "/tmp/test-repo",
988
+ operation: "push",
989
+ duration: 100
990
+ }),
991
+ fetch: async () => ({
992
+ success: true,
993
+ projectPath: "/tmp/test-repo",
994
+ operation: "fetch",
995
+ duration: 100
996
+ }),
997
+ fetchAll: async () => ({
998
+ success: true,
999
+ projectPath: "/tmp/test-repo",
1000
+ operation: "fetchAll",
1001
+ duration: 100
1002
+ }),
1003
+ addRemote: async () => ({
1004
+ success: true,
1005
+ projectPath: "/tmp/test-repo",
1006
+ operation: "addRemote",
1007
+ duration: 100
1008
+ }),
1009
+ };
1010
+
1011
+ const repo = createMockUnifiedRepo({ name: "test-repo", source: "github" });
1012
+ const result = await cloneGitHubRepo(repo, "/tmp", true, mockGitService);
1013
+
1014
+ expect(result.success).toBe(false);
1015
+ expect(result.error).toBe("Clone failed");
1016
+ });
1017
+
1018
+ test("returns error when repo has no GitHub info", async () => {
1019
+ const repo = createMockUnifiedRepo({ name: "test-repo", source: "local", github: null });
1020
+ const result = await cloneGitHubRepo(repo, "/tmp");
1021
+
1022
+ expect(result.success).toBe(false);
1023
+ expect(result.error).toBe("No GitHub info available for this repo");
1024
+ });
1025
+
1026
+ test("uses HTTPS URL when useSSH is false", async () => {
1027
+ const mockGitService: GitService = {
1028
+ clone: async (url: string, targetDir: string) => {
1029
+ // Verify HTTPS URL is used
1030
+ expect(url).toBe("https://github.com/user/test-repo.git");
1031
+ return {
1032
+ success: true,
1033
+ projectPath: targetDir,
1034
+ operation: "clone",
1035
+ duration: 100
1036
+ };
1037
+ },
1038
+ isGitRepo: async () => true,
1039
+ getGitRoot: async () => "/test/repo",
1040
+ isSubmodule: async () => false,
1041
+ getSubmoduleParent: async () => null,
1042
+ getStatus: async () => ({
1043
+ isDirty: false,
1044
+ hasUnstagedChanges: false,
1045
+ hasStagedChanges: false,
1046
+ hasUntrackedFiles: false,
1047
+ modifiedCount: 0,
1048
+ stagedCount: 0,
1049
+ untrackedCount: 0,
1050
+ currentBranch: "main",
1051
+ trackingBranch: null,
1052
+ unpushedCommits: 0,
1053
+ unpulledCommits: 0,
1054
+ hasRemote: true,
1055
+ remoteUrl: null,
1056
+ lastLocalCommit: new Date(),
1057
+ lastRemoteActivity: null,
1058
+ hasCommits: true,
1059
+ isAhead: false,
1060
+ isBehind: false,
1061
+ isOutOfSync: false,
1062
+ }),
1063
+ getStatusPorcelain: async () => "",
1064
+ getCurrentBranch: async () => "main",
1065
+ getTrackingBranch: async () => null,
1066
+ countUnpushedCommits: async () => 0,
1067
+ countUnpulledCommits: async () => 0,
1068
+ getRemoteUrl: async () => null,
1069
+ getLastCommitDate: async () => new Date(),
1070
+ getRemoteLastCommitDate: async () => new Date(),
1071
+ listSubmodules: async () => [],
1072
+ init: async () => ({
1073
+ success: true,
1074
+ projectPath: "/tmp/test-repo",
1075
+ operation: "init",
1076
+ duration: 100
1077
+ }),
1078
+ pull: async () => ({
1079
+ success: true,
1080
+ projectPath: "/tmp/test-repo",
1081
+ operation: "pull",
1082
+ duration: 100
1083
+ }),
1084
+ push: async () => ({
1085
+ success: true,
1086
+ projectPath: "/tmp/test-repo",
1087
+ operation: "push",
1088
+ duration: 100
1089
+ }),
1090
+ fetch: async () => ({
1091
+ success: true,
1092
+ projectPath: "/tmp/test-repo",
1093
+ operation: "fetch",
1094
+ duration: 100
1095
+ }),
1096
+ fetchAll: async () => ({
1097
+ success: true,
1098
+ projectPath: "/tmp/test-repo",
1099
+ operation: "fetchAll",
1100
+ duration: 100
1101
+ }),
1102
+ addRemote: async () => ({
1103
+ success: true,
1104
+ projectPath: "/tmp/test-repo",
1105
+ operation: "addRemote",
1106
+ duration: 100
1107
+ }),
1108
+ };
1109
+
1110
+ const repo = createMockUnifiedRepo({ name: "test-repo", source: "github" });
1111
+ await cloneGitHubRepo(repo, "/tmp", false, mockGitService);
1112
+ });
1113
+
1114
+ test("uses SSH URL when useSSH is true", async () => {
1115
+ const mockGitService: GitService = {
1116
+ clone: async (url: string, targetDir: string) => {
1117
+ // Verify SSH URL is used
1118
+ expect(url).toBe("git@github.com:user/test-repo.git");
1119
+ return {
1120
+ success: true,
1121
+ projectPath: targetDir,
1122
+ operation: "clone",
1123
+ duration: 100
1124
+ };
1125
+ },
1126
+ isGitRepo: async () => true,
1127
+ getGitRoot: async () => "/test/repo",
1128
+ isSubmodule: async () => false,
1129
+ getSubmoduleParent: async () => null,
1130
+ getStatus: async () => ({
1131
+ isDirty: false,
1132
+ hasUnstagedChanges: false,
1133
+ hasStagedChanges: false,
1134
+ hasUntrackedFiles: false,
1135
+ modifiedCount: 0,
1136
+ stagedCount: 0,
1137
+ untrackedCount: 0,
1138
+ currentBranch: "main",
1139
+ trackingBranch: null,
1140
+ unpushedCommits: 0,
1141
+ unpulledCommits: 0,
1142
+ hasRemote: true,
1143
+ remoteUrl: null,
1144
+ lastLocalCommit: new Date(),
1145
+ lastRemoteActivity: null,
1146
+ hasCommits: true,
1147
+ isAhead: false,
1148
+ isBehind: false,
1149
+ isOutOfSync: false,
1150
+ }),
1151
+ getStatusPorcelain: async () => "",
1152
+ getCurrentBranch: async () => "main",
1153
+ getTrackingBranch: async () => null,
1154
+ countUnpushedCommits: async () => 0,
1155
+ countUnpulledCommits: async () => 0,
1156
+ getRemoteUrl: async () => null,
1157
+ getLastCommitDate: async () => new Date(),
1158
+ getRemoteLastCommitDate: async () => new Date(),
1159
+ listSubmodules: async () => [],
1160
+ init: async () => ({
1161
+ success: true,
1162
+ projectPath: "/tmp/test-repo",
1163
+ operation: "init",
1164
+ duration: 100
1165
+ }),
1166
+ pull: async () => ({
1167
+ success: true,
1168
+ projectPath: "/tmp/test-repo",
1169
+ operation: "pull",
1170
+ duration: 100
1171
+ }),
1172
+ push: async () => ({
1173
+ success: true,
1174
+ projectPath: "/tmp/test-repo",
1175
+ operation: "push",
1176
+ duration: 100
1177
+ }),
1178
+ fetch: async () => ({
1179
+ success: true,
1180
+ projectPath: "/tmp/test-repo",
1181
+ operation: "fetch",
1182
+ duration: 100
1183
+ }),
1184
+ fetchAll: async () => ({
1185
+ success: true,
1186
+ projectPath: "/tmp/test-repo",
1187
+ operation: "fetchAll",
1188
+ duration: 100
1189
+ }),
1190
+ addRemote: async () => ({
1191
+ success: true,
1192
+ projectPath: "/tmp/test-repo",
1193
+ operation: "addRemote",
1194
+ duration: 100
1195
+ }),
1196
+ };
1197
+
1198
+ const repo = createMockUnifiedRepo({ name: "test-repo", source: "github" });
1199
+ await cloneGitHubRepo(repo, "/tmp", true, mockGitService);
1200
+ });
1201
+ });