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,618 @@
1
+ /**
2
+ * Tests for UnifiedProjectItem component
3
+ *
4
+ * Note: This component uses Nerd Font icons via Unicode escape sequences.
5
+ * Tests check for repo names and text content rather than specific icon characters,
6
+ * since icon rendering depends on terminal font support.
7
+ */
8
+
9
+ import { describe, test, expect } from "bun:test";
10
+ import { render } from "ink-testing-library";
11
+ import { UnifiedProjectItem } from "../../../src/components/UnifiedProjectItem.tsx";
12
+ import type { UnifiedRepo, GitHubRepoInfo, Project } from "../../../src/types/index.ts";
13
+
14
+ // Mock data helpers
15
+ function createMockGitHubRepo(overrides: Partial<GitHubRepoInfo> = {}): GitHubRepoInfo {
16
+ return {
17
+ name: "test-repo",
18
+ fullName: "user/test-repo",
19
+ owner: "user",
20
+ description: "Test repository",
21
+ htmlUrl: "https://github.com/user/test-repo",
22
+ sshUrl: "git@github.com:user/test-repo.git",
23
+ cloneUrl: "https://github.com/user/test-repo.git",
24
+ isPrivate: false,
25
+ isArchived: false,
26
+ isFork: false,
27
+ pushedAt: new Date("2024-01-15T09:00:00Z"),
28
+ updatedAt: new Date("2024-01-14T08:00:00Z"),
29
+ defaultBranch: "main",
30
+ language: "TypeScript",
31
+ size: 1024,
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ function createMockProject(overrides: Partial<Project> = {}): Project {
37
+ return {
38
+ id: "test-project",
39
+ name: "test-project",
40
+ path: "/home/user/projects/test-project",
41
+ type: "git",
42
+ projectMarker: "package.json",
43
+ status: {
44
+ hasUnstagedChanges: false,
45
+ hasStagedChanges: false,
46
+ hasUntrackedFiles: false,
47
+ modifiedCount: 0,
48
+ stagedCount: 0,
49
+ untrackedCount: 0,
50
+ currentBranch: "main",
51
+ trackingBranch: "origin/main",
52
+ unpushedCommits: 0,
53
+ unpulledCommits: 0,
54
+ hasRemote: true,
55
+ remoteUrl: "https://github.com/user/test-project.git",
56
+ lastLocalCommit: new Date("2024-01-15T10:00:00Z"),
57
+ lastRemoteActivity: new Date("2024-01-15T09:00:00Z"),
58
+ hasCommits: true,
59
+ isDirty: false,
60
+ isAhead: false,
61
+ isBehind: false,
62
+ isOutOfSync: false,
63
+ },
64
+ submodule: null,
65
+ lastScanned: new Date(),
66
+ lastModified: null,
67
+ ...overrides,
68
+ };
69
+ }
70
+
71
+ describe("UnifiedProjectItem", () => {
72
+ describe("GitHub-only repos", () => {
73
+ test("renders repo name", () => {
74
+ const repo: UnifiedRepo = {
75
+ id: "github-only",
76
+ name: "github-only-repo",
77
+ source: "github",
78
+ local: null,
79
+ github: createMockGitHubRepo({
80
+ name: "github-only-repo",
81
+ fullName: "user/github-only-repo",
82
+ }),
83
+ isCloned: false,
84
+ isOnGitHub: true,
85
+ localPath: null,
86
+ };
87
+
88
+ const { lastFrame } = render(
89
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
90
+ );
91
+
92
+ const output = lastFrame();
93
+ expect(output).toContain("github-only-repo");
94
+ });
95
+
96
+ test("renders private repo name", () => {
97
+ const repo: UnifiedRepo = {
98
+ id: "github-private",
99
+ name: "private-repo",
100
+ source: "github",
101
+ local: null,
102
+ github: createMockGitHubRepo({
103
+ name: "private-repo",
104
+ fullName: "user/private-repo",
105
+ isPrivate: true,
106
+ }),
107
+ isCloned: false,
108
+ isOnGitHub: true,
109
+ localPath: null,
110
+ };
111
+
112
+ const { lastFrame } = render(
113
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
114
+ );
115
+
116
+ const output = lastFrame();
117
+ expect(output).toContain("private-repo");
118
+ });
119
+
120
+ test("renders public repo name", () => {
121
+ const repo: UnifiedRepo = {
122
+ id: "github-public",
123
+ name: "public-repo",
124
+ source: "github",
125
+ local: null,
126
+ github: createMockGitHubRepo({
127
+ name: "public-repo",
128
+ fullName: "user/public-repo",
129
+ isPrivate: false,
130
+ }),
131
+ isCloned: false,
132
+ isOnGitHub: true,
133
+ localPath: null,
134
+ };
135
+
136
+ const { lastFrame } = render(
137
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
138
+ );
139
+
140
+ const output = lastFrame();
141
+ expect(output).toContain("public-repo");
142
+ });
143
+
144
+ test("shows last activity time", () => {
145
+ const repo: UnifiedRepo = {
146
+ id: "github-lang",
147
+ name: "lang-repo",
148
+ source: "github",
149
+ local: null,
150
+ github: createMockGitHubRepo({
151
+ name: "lang-repo",
152
+ fullName: "user/lang-repo",
153
+ language: "Rust",
154
+ pushedAt: new Date(Date.now() - 86400000), // 1 day ago
155
+ }),
156
+ isCloned: false,
157
+ isOnGitHub: true,
158
+ localPath: null,
159
+ };
160
+
161
+ const { lastFrame } = render(
162
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
163
+ );
164
+
165
+ const output = lastFrame();
166
+ // Date now shows just "1d" without "ago"
167
+ expect(output).toContain("1d");
168
+ });
169
+ });
170
+
171
+ describe("Local-only repos", () => {
172
+ test("renders local repo name", () => {
173
+ const repo: UnifiedRepo = {
174
+ id: "local-only",
175
+ name: "local-only-project",
176
+ source: "local",
177
+ local: createMockProject({
178
+ name: "local-only-project",
179
+ status: {
180
+ ...createMockProject().status!,
181
+ hasRemote: false,
182
+ remoteUrl: null,
183
+ },
184
+ }),
185
+ github: null,
186
+ isCloned: true,
187
+ isOnGitHub: false,
188
+ localPath: "/home/user/local-only-project",
189
+ };
190
+
191
+ const { lastFrame } = render(
192
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
193
+ );
194
+
195
+ const output = lastFrame();
196
+ expect(output).toContain("local-only-project");
197
+ expect(output).toContain("local"); // Status shows "local" for no remote
198
+ });
199
+
200
+ test("shows current branch name", () => {
201
+ const repo: UnifiedRepo = {
202
+ id: "local-branch",
203
+ name: "branch-project",
204
+ source: "local",
205
+ local: createMockProject({
206
+ name: "branch-project",
207
+ status: {
208
+ ...createMockProject().status!,
209
+ currentBranch: "develop",
210
+ },
211
+ }),
212
+ github: null,
213
+ isCloned: true,
214
+ isOnGitHub: false,
215
+ localPath: "/home/user/branch-project",
216
+ };
217
+
218
+ const { lastFrame } = render(
219
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
220
+ );
221
+
222
+ const output = lastFrame();
223
+ expect(output).toContain("develop");
224
+ });
225
+
226
+ test("renders dirty repo", () => {
227
+ const repo: UnifiedRepo = {
228
+ id: "local-dirty",
229
+ name: "dirty-project",
230
+ source: "local",
231
+ local: createMockProject({
232
+ name: "dirty-project",
233
+ status: {
234
+ ...createMockProject().status!,
235
+ isDirty: true,
236
+ },
237
+ }),
238
+ github: null,
239
+ isCloned: true,
240
+ isOnGitHub: false,
241
+ localPath: "/home/user/dirty-project",
242
+ };
243
+
244
+ const { lastFrame } = render(
245
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
246
+ );
247
+
248
+ const output = lastFrame();
249
+ expect(output).toContain("dirty-project");
250
+ // Dirty status is shown via icon, just verify the repo renders
251
+ });
252
+
253
+ test("renders submodule repo", () => {
254
+ const repo: UnifiedRepo = {
255
+ id: "local-submodule",
256
+ name: "submodule-project",
257
+ source: "local",
258
+ local: createMockProject({
259
+ name: "submodule-project",
260
+ type: "git-submodule",
261
+ }),
262
+ github: null,
263
+ isCloned: true,
264
+ isOnGitHub: false,
265
+ localPath: "/home/user/submodule-project",
266
+ };
267
+
268
+ const { lastFrame } = render(
269
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
270
+ );
271
+
272
+ const output = lastFrame();
273
+ expect(output).toContain("submodule-project");
274
+ // Submodule indicator not currently implemented in component
275
+ });
276
+
277
+ test("renders non-git project", () => {
278
+ const repo: UnifiedRepo = {
279
+ id: "local-marker",
280
+ name: "marker-project",
281
+ source: "local",
282
+ local: createMockProject({
283
+ name: "marker-project",
284
+ type: "non-git",
285
+ projectMarker: "package.json",
286
+ }),
287
+ github: null,
288
+ isCloned: true,
289
+ isOnGitHub: false,
290
+ localPath: "/home/user/marker-project",
291
+ };
292
+
293
+ const { lastFrame } = render(
294
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
295
+ );
296
+
297
+ const output = lastFrame();
298
+ expect(output).toContain("marker-project");
299
+ // Project marker indicator not currently implemented in component
300
+ });
301
+ });
302
+
303
+ describe("Synced repos (both)", () => {
304
+ test("renders synced repo name", () => {
305
+ const repo: UnifiedRepo = {
306
+ id: "synced",
307
+ name: "synced-project",
308
+ source: "both",
309
+ local: createMockProject({
310
+ name: "synced-project",
311
+ }),
312
+ github: createMockGitHubRepo({
313
+ name: "synced-project",
314
+ fullName: "user/synced-project",
315
+ }),
316
+ isCloned: true,
317
+ isOnGitHub: true,
318
+ localPath: "/home/user/synced-project",
319
+ };
320
+
321
+ const { lastFrame } = render(
322
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
323
+ );
324
+
325
+ const output = lastFrame();
326
+ expect(output).toContain("synced-project");
327
+ expect(output).toContain("sync"); // Sync status
328
+ });
329
+
330
+ test("shows unpushed commits count", () => {
331
+ const repo: UnifiedRepo = {
332
+ id: "synced-ahead",
333
+ name: "ahead-project",
334
+ source: "both",
335
+ local: createMockProject({
336
+ name: "ahead-project",
337
+ status: {
338
+ ...createMockProject().status!,
339
+ isAhead: true,
340
+ unpushedCommits: 3,
341
+ },
342
+ }),
343
+ github: createMockGitHubRepo({
344
+ name: "ahead-project",
345
+ fullName: "user/ahead-project",
346
+ }),
347
+ isCloned: true,
348
+ isOnGitHub: true,
349
+ localPath: "/home/user/ahead-project",
350
+ };
351
+
352
+ const { lastFrame } = render(
353
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
354
+ );
355
+
356
+ const output = lastFrame();
357
+ // Component uses Nerd Font arrow icon + number
358
+ expect(output).toContain("3");
359
+ });
360
+
361
+ test("shows unpulled commits count", () => {
362
+ const repo: UnifiedRepo = {
363
+ id: "synced-behind",
364
+ name: "behind-project",
365
+ source: "both",
366
+ local: createMockProject({
367
+ name: "behind-project",
368
+ status: {
369
+ ...createMockProject().status!,
370
+ isBehind: true,
371
+ unpulledCommits: 5,
372
+ },
373
+ }),
374
+ github: createMockGitHubRepo({
375
+ name: "behind-project",
376
+ fullName: "user/behind-project",
377
+ }),
378
+ isCloned: true,
379
+ isOnGitHub: true,
380
+ localPath: "/home/user/behind-project",
381
+ };
382
+
383
+ const { lastFrame } = render(
384
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
385
+ );
386
+
387
+ const output = lastFrame();
388
+ // Component uses Nerd Font arrow icon + number
389
+ expect(output).toContain("5");
390
+ });
391
+
392
+ test("shows no-remote status", () => {
393
+ const repo: UnifiedRepo = {
394
+ id: "synced-no-remote",
395
+ name: "no-remote-project",
396
+ source: "both",
397
+ local: createMockProject({
398
+ name: "no-remote-project",
399
+ status: {
400
+ ...createMockProject().status!,
401
+ hasRemote: false,
402
+ remoteUrl: null,
403
+ },
404
+ }),
405
+ github: createMockGitHubRepo({
406
+ name: "no-remote-project",
407
+ fullName: "user/no-remote-project",
408
+ }),
409
+ isCloned: true,
410
+ isOnGitHub: true,
411
+ localPath: "/home/user/no-remote-project",
412
+ };
413
+
414
+ const { lastFrame } = render(
415
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
416
+ );
417
+
418
+ const output = lastFrame();
419
+ expect(output).toContain("no-rem");
420
+ });
421
+ });
422
+
423
+ describe("Cursor and selection states", () => {
424
+ test("shows cursor indicator when isCursor is true", () => {
425
+ const repo: UnifiedRepo = {
426
+ id: "cursor-test",
427
+ name: "cursor-test",
428
+ source: "local",
429
+ local: createMockProject({ name: "cursor-test" }),
430
+ github: null,
431
+ isCloned: true,
432
+ isOnGitHub: false,
433
+ localPath: "/home/user/cursor-test",
434
+ };
435
+
436
+ const { lastFrame } = render(
437
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={true} />
438
+ );
439
+
440
+ const output = lastFrame();
441
+ // Cursor styling is applied via ink - verify repo renders with cursor state
442
+ expect(output).toContain("cursor-test");
443
+ expect(output).toContain("synced"); // Sync shows synced
444
+ });
445
+
446
+ test("shows selection checkbox when selected", () => {
447
+ const repo: UnifiedRepo = {
448
+ id: "selection-test",
449
+ name: "selection-test",
450
+ source: "local",
451
+ local: createMockProject({ name: "selection-test" }),
452
+ github: null,
453
+ isCloned: true,
454
+ isOnGitHub: false,
455
+ localPath: "/home/user/selection-test",
456
+ };
457
+
458
+ const { lastFrame } = render(
459
+ <UnifiedProjectItem repo={repo} isSelected={true} isCursor={false} />
460
+ );
461
+
462
+ const output = lastFrame();
463
+ expect(output).toContain("[x]"); // Selected checkbox
464
+ });
465
+
466
+ test("shows unselected checkbox when not selected", () => {
467
+ const repo: UnifiedRepo = {
468
+ id: "no-selection-test",
469
+ name: "no-selection-test",
470
+ source: "local",
471
+ local: createMockProject({ name: "no-selection-test" }),
472
+ github: null,
473
+ isCloned: true,
474
+ isOnGitHub: false,
475
+ localPath: "/home/user/no-selection-test",
476
+ };
477
+
478
+ const { lastFrame } = render(
479
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
480
+ );
481
+
482
+ const output = lastFrame();
483
+ expect(output).toContain("[ ]"); // Unselected checkbox
484
+ });
485
+
486
+ test("combines cursor and selection states", () => {
487
+ const repo: UnifiedRepo = {
488
+ id: "combined-test",
489
+ name: "combined-test",
490
+ source: "local",
491
+ local: createMockProject({ name: "combined-test" }),
492
+ github: null,
493
+ isCloned: true,
494
+ isOnGitHub: false,
495
+ localPath: "/home/user/combined-test",
496
+ };
497
+
498
+ const { lastFrame } = render(
499
+ <UnifiedProjectItem repo={repo} isSelected={true} isCursor={true} />
500
+ );
501
+
502
+ const output = lastFrame();
503
+ // Verify both cursor and selection states apply - cursor styling via ink
504
+ expect(output).toContain("combined-test");
505
+ expect(output).toContain("[x]"); // Selected
506
+ });
507
+ });
508
+
509
+ describe("Last activity formatting", () => {
510
+ test("shows 'today' for recent activity", () => {
511
+ const repo: UnifiedRepo = {
512
+ id: "today-test",
513
+ name: "today-test",
514
+ source: "local",
515
+ local: createMockProject({
516
+ name: "today-test",
517
+ status: {
518
+ ...createMockProject().status!,
519
+ lastLocalCommit: new Date(),
520
+ },
521
+ }),
522
+ github: null,
523
+ isCloned: true,
524
+ isOnGitHub: false,
525
+ localPath: "/home/user/today-test",
526
+ };
527
+
528
+ const { lastFrame } = render(
529
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
530
+ );
531
+
532
+ const output = lastFrame();
533
+ expect(output).toContain("today");
534
+ });
535
+
536
+ test("shows '1d ago' for 1 day ago", () => {
537
+ const yesterday = new Date();
538
+ yesterday.setDate(yesterday.getDate() - 1);
539
+
540
+ const repo: UnifiedRepo = {
541
+ id: "yesterday-test",
542
+ name: "yesterday-test",
543
+ source: "github",
544
+ local: null,
545
+ github: createMockGitHubRepo({
546
+ name: "yesterday-test",
547
+ pushedAt: yesterday,
548
+ }),
549
+ isCloned: false,
550
+ isOnGitHub: true,
551
+ localPath: null,
552
+ };
553
+
554
+ const { lastFrame } = render(
555
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
556
+ );
557
+
558
+ const output = lastFrame();
559
+ // Date now shows just "1d" without "ago"
560
+ expect(output).toContain("1d");
561
+ });
562
+
563
+ test("shows days ago for recent activity", () => {
564
+ const threeDaysAgo = new Date();
565
+ threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
566
+
567
+ const repo: UnifiedRepo = {
568
+ id: "days-test",
569
+ name: "days-test",
570
+ source: "github",
571
+ local: null,
572
+ github: createMockGitHubRepo({
573
+ name: "days-test",
574
+ pushedAt: threeDaysAgo,
575
+ }),
576
+ isCloned: false,
577
+ isOnGitHub: true,
578
+ localPath: null,
579
+ };
580
+
581
+ const { lastFrame } = render(
582
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
583
+ );
584
+
585
+ const output = lastFrame();
586
+ // Date now shows just "3d" without "ago"
587
+ expect(output).toContain("3d");
588
+ });
589
+
590
+ test("shows 'empty' status for repos with no commits", () => {
591
+ const repo: UnifiedRepo = {
592
+ id: "empty-test",
593
+ name: "empty-test",
594
+ source: "local",
595
+ local: createMockProject({
596
+ name: "empty-test",
597
+ status: {
598
+ ...createMockProject().status!,
599
+ hasCommits: false,
600
+ lastLocalCommit: null,
601
+ },
602
+ }),
603
+ github: null,
604
+ isCloned: true,
605
+ isOnGitHub: false,
606
+ localPath: "/home/user/empty-test",
607
+ };
608
+
609
+ const { lastFrame } = render(
610
+ <UnifiedProjectItem repo={repo} isSelected={false} isCursor={false} />
611
+ );
612
+
613
+ const output = lastFrame();
614
+ // Status column shows "empty" for repos with no commits
615
+ expect(output).toContain("empty");
616
+ });
617
+ });
618
+ });