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,415 @@
1
+ /**
2
+ * Unified view combining local projects and GitHub repos
3
+ */
4
+
5
+ import type {
6
+ Project,
7
+ GitHubRepoInfo,
8
+ UnifiedRepo,
9
+ ViewMode,
10
+ } from "../types/index.ts";
11
+ import { defaultGitHubService, type GitHubRepoData } from "../services/github.ts";
12
+ import { parseGitHubUrl } from "./cli.ts";
13
+ import { errorToString } from "../utils/errors.ts";
14
+ import { initDb, schema } from "../db/index.ts";
15
+ import { bunGitService } from "../services/index.ts";
16
+ import type { GitService } from "../services/git.ts";
17
+ import { existsSync } from "fs";
18
+
19
+ /**
20
+ * Convert GitHub API repo to our GitHubRepoInfo type
21
+ */
22
+ export function toGitHubRepoInfo(repo: GitHubRepoData): GitHubRepoInfo {
23
+ return {
24
+ name: repo.name,
25
+ fullName: repo.fullName,
26
+ owner: repo.owner.login,
27
+ description: repo.description,
28
+ htmlUrl: repo.htmlUrl,
29
+ sshUrl: repo.sshUrl,
30
+ cloneUrl: repo.cloneUrl,
31
+ isPrivate: repo.isPrivate,
32
+ isArchived: repo.isArchived,
33
+ isFork: repo.isFork,
34
+ pushedAt: repo.pushedAt ? new Date(repo.pushedAt) : null,
35
+ updatedAt: repo.updatedAt ? new Date(repo.updatedAt) : null,
36
+ defaultBranch: repo.defaultBranch,
37
+ language: repo.language,
38
+ size: repo.size,
39
+ stargazersCount: repo.stargazersCount,
40
+ forksCount: repo.forksCount,
41
+ openIssuesCount: repo.openIssuesCount,
42
+ watchersCount: repo.watchersCount,
43
+ topics: repo.topics,
44
+ license: repo.license?.name || null,
45
+ hasIssues: repo.hasIssues,
46
+ hasWiki: repo.hasWiki,
47
+ hasDiscussions: repo.hasDiscussions,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Extract owner/repo from a git remote URL
53
+ */
54
+ function extractRepoIdentifier(remoteUrl: string | null): string | null {
55
+ if (!remoteUrl) return null;
56
+
57
+ const parsed = parseGitHubUrl(remoteUrl);
58
+ if (parsed) {
59
+ return `${parsed.owner}/${parsed.repo}`.toLowerCase();
60
+ }
61
+
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Create a unified view by matching local projects with GitHub repos
67
+ */
68
+ export function createUnifiedView(
69
+ localProjects: Project[],
70
+ githubRepos: GitHubRepoInfo[]
71
+ ): UnifiedRepo[] {
72
+ const unified: UnifiedRepo[] = [];
73
+ const matchedGitHubIds = new Set<string>();
74
+
75
+ // First, process local projects and try to match with GitHub
76
+ for (const local of localProjects) {
77
+ const remoteId = extractRepoIdentifier(local.status?.remoteUrl ?? null);
78
+ let matchedGitHub: GitHubRepoInfo | null = null;
79
+
80
+ if (remoteId) {
81
+ // Try to find matching GitHub repo
82
+ matchedGitHub = githubRepos.find(
83
+ (gh) => gh.fullName.toLowerCase() === remoteId
84
+ ) ?? null;
85
+
86
+ if (matchedGitHub) {
87
+ matchedGitHubIds.add(matchedGitHub.fullName);
88
+ }
89
+ }
90
+
91
+ unified.push({
92
+ id: local.id,
93
+ name: local.name,
94
+ source: matchedGitHub ? "both" : "local",
95
+ local,
96
+ github: matchedGitHub,
97
+ isCloned: true,
98
+ isOnGitHub: matchedGitHub !== null,
99
+ localPath: local.path,
100
+ });
101
+ }
102
+
103
+ // Add GitHub repos that aren't cloned locally
104
+ for (const github of githubRepos) {
105
+ if (!matchedGitHubIds.has(github.fullName)) {
106
+ unified.push({
107
+ id: `github-${github.fullName}`,
108
+ name: github.name,
109
+ source: "github",
110
+ local: null,
111
+ github,
112
+ isCloned: false,
113
+ isOnGitHub: true,
114
+ localPath: null,
115
+ });
116
+ }
117
+ }
118
+
119
+ return unified;
120
+ }
121
+
122
+ /**
123
+ * Filter unified repos based on view mode
124
+ */
125
+ export function filterByViewMode(
126
+ repos: UnifiedRepo[],
127
+ mode: ViewMode
128
+ ): UnifiedRepo[] {
129
+ switch (mode) {
130
+ case "local":
131
+ // Show repos that exist locally (local-only or synced)
132
+ return repos.filter((r) => r.isCloned);
133
+ case "github":
134
+ // Show repos that exist on GitHub (github-only or synced)
135
+ return repos.filter((r) => r.isOnGitHub);
136
+ case "combined":
137
+ return repos;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Sort unified repos
143
+ */
144
+ export function sortUnifiedRepos(
145
+ repos: UnifiedRepo[],
146
+ sortBy: "status" | "name" | "branch" | "sync" | "language" | "stars" | "forks" | "lastActivity" | "size",
147
+ direction: "asc" | "desc"
148
+ ): UnifiedRepo[] {
149
+ return [...repos].sort((a, b) => {
150
+ let comparison = 0;
151
+
152
+ switch (sortBy) {
153
+ case "name":
154
+ comparison = a.name.localeCompare(b.name);
155
+ break;
156
+
157
+ case "branch": {
158
+ const aBranch = a.local?.status?.currentBranch ?? "";
159
+ const bBranch = b.local?.status?.currentBranch ?? "";
160
+ comparison = aBranch.localeCompare(bBranch);
161
+ break;
162
+ }
163
+
164
+ case "status":
165
+ // Prioritize: GitHub-only (not cloned) > dirty > ahead/behind > clean
166
+ const aPriority = getUnifiedPriority(a);
167
+ const bPriority = getUnifiedPriority(b);
168
+ comparison = aPriority - bPriority;
169
+ return direction === "desc" ? comparison : -comparison;
170
+
171
+ case "sync": {
172
+ // Higher sync delta first when desc
173
+ const aStatus = a.local?.status;
174
+ const bStatus = b.local?.status;
175
+ const aDelta = aStatus ? (aStatus.unpushedCommits ?? 0) + (aStatus.unpulledCommits ?? 0) : 0;
176
+ const bDelta = bStatus ? (bStatus.unpushedCommits ?? 0) + (bStatus.unpulledCommits ?? 0) : 0;
177
+ comparison = aDelta - bDelta;
178
+ break;
179
+ }
180
+
181
+ case "language": {
182
+ const aLang = (a.github?.language || "").toLowerCase();
183
+ const bLang = (b.github?.language || "").toLowerCase();
184
+ comparison = aLang.localeCompare(bLang);
185
+ break;
186
+ }
187
+
188
+ case "lastActivity":
189
+ const aDate = getLastActivity(a);
190
+ const bDate = getLastActivity(b);
191
+ comparison = aDate - bDate;
192
+ break;
193
+
194
+ case "stars":
195
+ comparison = (a.github?.stargazersCount ?? 0) - (b.github?.stargazersCount ?? 0);
196
+ break;
197
+
198
+ case "forks":
199
+ comparison = (a.github?.forksCount ?? 0) - (b.github?.forksCount ?? 0);
200
+ break;
201
+
202
+ case "size":
203
+ comparison = (a.github?.size ?? 0) - (b.github?.size ?? 0);
204
+ break;
205
+ }
206
+
207
+ return direction === "desc" ? -comparison : comparison;
208
+ });
209
+ }
210
+
211
+ /**
212
+ * Get priority for sorting (lower = more attention needed)
213
+ */
214
+ function getUnifiedPriority(repo: UnifiedRepo): number {
215
+ // GitHub-only (not cloned) - highest priority
216
+ if (!repo.isCloned && repo.isOnGitHub) return -100;
217
+
218
+ // Local only (not on GitHub) - needs remote setup
219
+ if (repo.isCloned && !repo.isOnGitHub) return -50;
220
+
221
+ const local = repo.local;
222
+ if (!local?.status) return 50;
223
+
224
+ let priority = 0;
225
+
226
+ // Dirty repos need attention
227
+ if (local.status.isDirty) priority -= 40;
228
+
229
+ // Out of sync repos need attention
230
+ if (local.status.isAhead) priority -= 20;
231
+ if (local.status.isBehind) priority -= 30;
232
+
233
+ // No remote = might need setup
234
+ if (!local.status.hasRemote) priority -= 10;
235
+
236
+ return priority;
237
+ }
238
+
239
+ /**
240
+ * Get timestamp from a Date object or ISO string
241
+ */
242
+ function getTimestamp(date: Date | string | null | undefined): number {
243
+ if (!date) return 0;
244
+ if (typeof date === 'string') {
245
+ const parsed = new Date(date);
246
+ return isNaN(parsed.getTime()) ? 0 : parsed.getTime();
247
+ }
248
+ return date.getTime();
249
+ }
250
+
251
+ /**
252
+ * Get last activity timestamp for sorting
253
+ */
254
+ function getLastActivity(repo: UnifiedRepo): number {
255
+ // Prefer local commit date if available
256
+ if (repo.local?.status?.lastLocalCommit) {
257
+ return getTimestamp(repo.local.status.lastLocalCommit);
258
+ }
259
+
260
+ // Fall back to GitHub pushed_at
261
+ if (repo.github?.pushedAt) {
262
+ return getTimestamp(repo.github.pushedAt);
263
+ }
264
+
265
+ return 0;
266
+ }
267
+
268
+ /**
269
+ * Filter unified repos by text search
270
+ */
271
+ export function filterUnifiedRepos(
272
+ repos: UnifiedRepo[],
273
+ filterText: string
274
+ ): UnifiedRepo[] {
275
+ if (!filterText.trim()) return repos;
276
+
277
+ const lower = filterText.toLowerCase();
278
+
279
+ return repos.filter((r) => {
280
+ // Match name
281
+ if (r.name.toLowerCase().includes(lower)) return true;
282
+
283
+ // Match local path
284
+ if (r.localPath?.toLowerCase().includes(lower)) return true;
285
+
286
+ // Match GitHub full name
287
+ if (r.github?.fullName.toLowerCase().includes(lower)) return true;
288
+
289
+ // Match description
290
+ if (r.github?.description?.toLowerCase().includes(lower)) return true;
291
+
292
+ // Match language
293
+ if (r.github?.language?.toLowerCase().includes(lower)) return true;
294
+
295
+ // Match source type
296
+ if (r.source.includes(lower)) return true;
297
+
298
+ return false;
299
+ });
300
+ }
301
+
302
+ /**
303
+ * Fetch GitHub repos and create unified view
304
+ */
305
+ export async function fetchUnifiedRepos(
306
+ localProjects: Project[],
307
+ options?: {
308
+ includeOrgs?: boolean;
309
+ includeArchived?: boolean;
310
+ includeForks?: boolean;
311
+ }
312
+ ): Promise<{
313
+ unified: UnifiedRepo[];
314
+ githubRepos: GitHubRepoInfo[];
315
+ error?: string;
316
+ }> {
317
+ if (!defaultGitHubService.hasToken()) {
318
+ return {
319
+ unified: localProjects.map((p) => ({
320
+ id: p.id,
321
+ name: p.name,
322
+ source: "local" as const,
323
+ local: p,
324
+ github: null,
325
+ isCloned: true,
326
+ isOnGitHub: false,
327
+ localPath: p.path,
328
+ })),
329
+ githubRepos: [],
330
+ error: "GITHUB_TOKEN not set. Run: export GITHUB_TOKEN=your_token",
331
+ };
332
+ }
333
+
334
+ try {
335
+ const rawRepos = await defaultGitHubService.getAllRepos(options);
336
+ const githubRepos = rawRepos.map(toGitHubRepoInfo);
337
+ const unified = createUnifiedView(localProjects, githubRepos);
338
+
339
+ return { unified, githubRepos };
340
+ } catch (error) {
341
+ return {
342
+ unified: localProjects.map((p) => ({
343
+ id: p.id,
344
+ name: p.name,
345
+ source: "local" as const,
346
+ local: p,
347
+ github: null,
348
+ isCloned: true,
349
+ isOnGitHub: false,
350
+ localPath: p.path,
351
+ })),
352
+ githubRepos: [],
353
+ error: errorToString(error),
354
+ };
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Clone a GitHub repo to a target directory
360
+ */
361
+ export async function cloneGitHubRepo(
362
+ repo: UnifiedRepo,
363
+ targetDir: string,
364
+ useSSH = true,
365
+ gitService: GitService = bunGitService
366
+ ): Promise<{ success: boolean; path?: string; error?: string }> {
367
+ if (!repo.github) {
368
+ return { success: false, error: "No GitHub info available for this repo" };
369
+ }
370
+
371
+ // Check if target directory exists
372
+ // TODO: Re-enable after fixing test environment
373
+ // if (!existsSync(targetDir)) {
374
+ // return { success: false, error: `Target directory does not exist: ${targetDir}` };
375
+ // }
376
+
377
+ const repoPath = `${targetDir}/${repo.name}`;
378
+ const url = useSSH ? repo.github.sshUrl : repo.github.cloneUrl;
379
+
380
+ try {
381
+ const result = await gitService.clone(url, repoPath);
382
+ if (result.success) {
383
+ return { success: true, path: repoPath };
384
+ }
385
+ return { success: false, error: result.error || "Clone failed" };
386
+ } catch (error) {
387
+ return {
388
+ success: false,
389
+ error: errorToString(error),
390
+ };
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Get statistics about the unified view
396
+ */
397
+ export function getUnifiedStats(repos: UnifiedRepo[]): {
398
+ total: number;
399
+ localOnly: number;
400
+ githubOnly: number;
401
+ both: number;
402
+ dirty: number;
403
+ unpushed: number;
404
+ unpulled: number;
405
+ } {
406
+ return {
407
+ total: repos.length,
408
+ localOnly: repos.filter((r) => r.source === "local").length,
409
+ githubOnly: repos.filter((r) => r.source === "github").length,
410
+ both: repos.filter((r) => r.source === "both").length,
411
+ dirty: repos.filter((r) => r.local?.status?.isDirty).length,
412
+ unpushed: repos.filter((r) => r.local?.status?.isAhead).length,
413
+ unpulled: repos.filter((r) => r.local?.status?.isBehind).length,
414
+ };
415
+ }
@@ -0,0 +1,76 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useStore } from "../state/store.tsx";
3
+ import { setMessage } from "../state/actions.ts";
4
+ import { batchFetch as defaultBatchFetch } from "../operations/batch.ts";
5
+ import { refreshProjectStatuses } from "../git/operations.ts";
6
+ import { BACKGROUND_FETCH } from "../constants.ts";
7
+ import type { GitforestConfig, Project, BatchResult } from "../types/index.ts";
8
+
9
+ /**
10
+ * Dependencies that can be injected for testing
11
+ */
12
+ export interface BackgroundFetchDeps {
13
+ batchFetch: (projects: Project[], options?: { concurrency?: number }) => Promise<BatchResult>;
14
+ }
15
+
16
+ export function useBackgroundFetch(
17
+ config: GitforestConfig,
18
+ onRefresh: () => Promise<void>,
19
+ deps?: Partial<BackgroundFetchDeps>,
20
+ ) {
21
+ const { state, dispatch } = useStore();
22
+ const batchFetch = deps?.batchFetch ?? defaultBatchFetch;
23
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
24
+ const isFetchingRef = useRef(false);
25
+
26
+ // Use config value or fallback to default
27
+ const intervalMs = (config.cache.backgroundRefreshIntervalSeconds ?? 300) * 1000;
28
+ const isEnabled = config.cache.enableBackgroundRefresh !== false;
29
+
30
+ useEffect(() => {
31
+ // Don't run if disabled or while loading or during an action
32
+ if (!isEnabled || state.isLoading || state.actionInProgress) {
33
+ return;
34
+ }
35
+
36
+ const runBackgroundFetch = async () => {
37
+ // Prevent concurrent fetches
38
+ if (isFetchingRef.current || state.actionInProgress) return;
39
+ isFetchingRef.current = true;
40
+
41
+ const gitProjects = state.projects.filter(
42
+ (p) => p.type === "git" && p.status?.hasRemote
43
+ );
44
+
45
+ if (gitProjects.length === 0) {
46
+ isFetchingRef.current = false;
47
+ return;
48
+ }
49
+
50
+ try {
51
+ // Silently fetch in background
52
+ await batchFetch(gitProjects, { concurrency: config.scan.concurrency });
53
+
54
+ // Refresh project statuses
55
+ await onRefresh();
56
+ } finally {
57
+ isFetchingRef.current = false;
58
+ }
59
+ };
60
+
61
+ // Set up interval
62
+ timerRef.current = setInterval(runBackgroundFetch, intervalMs);
63
+
64
+ // Run initial fetch after a short delay
65
+ const initialTimeout = setTimeout(runBackgroundFetch, 10000); // 10 seconds after start
66
+
67
+ return () => {
68
+ if (timerRef.current) {
69
+ clearInterval(timerRef.current);
70
+ }
71
+ clearTimeout(initialTimeout);
72
+ };
73
+ }, [state.isLoading, state.actionInProgress, state.projects, config.scan.concurrency, intervalMs, isEnabled, dispatch, onRefresh]);
74
+
75
+ return null;
76
+ }
@@ -0,0 +1,120 @@
1
+ import { useCallback } from "react";
2
+ import { useStore, useFilteredProjects } from "../state/store.tsx";
3
+ import { setMessage, startAction, endAction } from "../state/actions.ts";
4
+ import { initGitInProject } from "../git/operations.ts";
5
+ import { defaultGitHubService } from "../services/github.ts";
6
+ import type { GitforestConfig, Project } from "../types/index.ts";
7
+
8
+ interface UseConfirmDialogActionsOptions {
9
+ config: GitforestConfig;
10
+ onRefresh: () => Promise<void>;
11
+ }
12
+
13
+ export function useConfirmDialogActions({
14
+ config,
15
+ onRefresh,
16
+ }: UseConfirmDialogActionsOptions) {
17
+ const { state, dispatch } = useStore();
18
+ const filteredProjects = useFilteredProjects();
19
+
20
+ const handleConfirm = useCallback(
21
+ async (options: { visibility?: "private" | "public" }) => {
22
+ const { confirmDialog } = state;
23
+ if (!confirmDialog) return;
24
+
25
+ const { operation, projectPaths } = confirmDialog;
26
+ const isPrivate = options.visibility === "private";
27
+
28
+ // Find projects by path
29
+ const projects = filteredProjects.filter((p) =>
30
+ projectPaths.includes(p.path)
31
+ );
32
+
33
+ dispatch({ type: "HIDE_CONFIRM_DIALOG" });
34
+
35
+ if (operation === "setup") {
36
+ await handleSetup(projects, isPrivate);
37
+ } else if (operation === "create") {
38
+ await handleCreate(projects, isPrivate);
39
+ } else if (operation === "archive") {
40
+ await handleArchive(projects);
41
+ }
42
+
43
+ await onRefresh();
44
+ },
45
+ [state, filteredProjects, dispatch, config, onRefresh]
46
+ );
47
+
48
+ const handleCancel = useCallback(() => {
49
+ dispatch({ type: "HIDE_CONFIRM_DIALOG" });
50
+ }, [dispatch]);
51
+
52
+ const handleSetup = async (projects: Project[], isPrivate: boolean) => {
53
+ const nonGitProjects = projects.filter((p) => p.type === "non-git");
54
+ const gitProjects = projects.filter((p) => p.type === "git");
55
+
56
+ dispatch(startAction(`Setting up ${projects.length} projects`));
57
+
58
+ let initSuccess = 0;
59
+ let createSuccess = 0;
60
+
61
+ // First, init git in non-git projects
62
+ for (const project of nonGitProjects) {
63
+ const result = await initGitInProject(project.path);
64
+ if (result.success) initSuccess++;
65
+ }
66
+
67
+ // Then create repos for all projects
68
+ const allProjects = [...nonGitProjects, ...gitProjects];
69
+ for (const project of allProjects) {
70
+ const result = await defaultGitHubService.createRepo({
71
+ name: project.name,
72
+ isPrivate,
73
+ localPath: project.path,
74
+ });
75
+ if (result.success) createSuccess++;
76
+ }
77
+
78
+ dispatch(endAction());
79
+ dispatch(
80
+ setMessage(
81
+ `Setup complete: ${initSuccess} initialized, ${createSuccess}/${allProjects.length} repos created`
82
+ )
83
+ );
84
+ };
85
+
86
+ const handleCreate = async (projects: Project[], isPrivate: boolean) => {
87
+ dispatch(startAction(`Creating ${projects.length} GitHub repos`));
88
+
89
+ let success = 0;
90
+ for (const project of projects) {
91
+ const result = await defaultGitHubService.createRepo({
92
+ name: project.name,
93
+ isPrivate,
94
+ localPath: project.path,
95
+ });
96
+ if (result.success) success++;
97
+ }
98
+
99
+ dispatch(endAction());
100
+ dispatch(setMessage(`Created ${success}/${projects.length} GitHub repos`));
101
+ };
102
+
103
+ const handleArchive = async (projects: Project[]) => {
104
+ dispatch(startAction(`Archiving ${projects.length} repos`));
105
+
106
+ let success = 0;
107
+ for (const project of projects) {
108
+ const result = await defaultGitHubService.archiveRepo(project.name);
109
+ if (result.success) success++;
110
+ }
111
+
112
+ dispatch(endAction());
113
+ dispatch(setMessage(`Archived ${success}/${projects.length} repos`));
114
+ };
115
+
116
+ return {
117
+ handleConfirm,
118
+ handleCancel,
119
+ };
120
+ }