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,424 @@
1
+ import { existsSync } from "fs";
2
+ import { readdir, stat } from "fs/promises";
3
+ import { join, basename } from "path";
4
+ import { createHash } from "crypto";
5
+ import type { Project, ProjectType, GitforestConfig, GitStatus, SubmoduleInfo } from "../types/index.ts";
6
+ import type { GitService } from "../services/git.ts";
7
+ import { bunGitService } from "../services/git.ts";
8
+ import { getGitStatus } from "../git/status.ts";
9
+ import { detectProjectMarker } from "./markers.ts";
10
+ import { getSubmoduleInfo, findSubmodules } from "./submodules.ts";
11
+ import { initDb, schema, clearCache as clearDbCache } from "../db/index.ts";
12
+ import { SCANNER } from "../constants.ts";
13
+
14
+ /**
15
+ * Get the most recent file modification time in a directory (non-recursive, top-level only)
16
+ */
17
+ async function getLatestModificationTime(dirPath: string): Promise<Date | null> {
18
+ try {
19
+ const entries = await readdir(dirPath);
20
+ let latestTime: Date | null = null;
21
+
22
+ for (const entry of entries) {
23
+ // Skip hidden files and common non-source directories
24
+ if (entry.startsWith('.') || entry === 'node_modules' || entry === 'dist' || entry === 'build') {
25
+ continue;
26
+ }
27
+
28
+ try {
29
+ const entryPath = join(dirPath, entry);
30
+ const entryStat = await stat(entryPath);
31
+ const mtime = entryStat.mtime;
32
+
33
+ if (!latestTime || mtime > latestTime) {
34
+ latestTime = mtime;
35
+ }
36
+ } catch {
37
+ // Skip files we can't stat
38
+ }
39
+ }
40
+
41
+ return latestTime;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ // Re-export from utils to maintain backward compatibility
48
+ export { sortProjects, filterProjects } from "../utils/project-utils.ts";
49
+
50
+ /**
51
+ * Generate a unique ID for a project path
52
+ */
53
+ function generateProjectId(path: string): string {
54
+ return createHash("md5").update(path).digest("hex").slice(0, SCANNER.PROJECT_ID_LENGTH);
55
+ }
56
+
57
+ /**
58
+ * Check if a directory should be ignored
59
+ */
60
+ function shouldIgnore(name: string, ignorePatterns: string[], includeHidden: boolean): boolean {
61
+ // Always ignore these
62
+ if (name === "." || name === "..") return true;
63
+
64
+ // Ignore hidden directories unless configured
65
+ if (!includeHidden && name.startsWith(".")) return true;
66
+
67
+ // Check ignore patterns
68
+ return ignorePatterns.includes(name);
69
+ }
70
+
71
+ /**
72
+ * Scan a single directory for projects
73
+ */
74
+ async function scanDirectory(
75
+ dirPath: string,
76
+ config: GitforestConfig,
77
+ depth: number,
78
+ maxDepth: number,
79
+ foundProjects: Project[],
80
+ processedPaths: Set<string>,
81
+ gitService: GitService
82
+ ): Promise<void> {
83
+ // Don't scan beyond max depth
84
+ if (depth > maxDepth) return;
85
+
86
+ // Skip if already processed
87
+ if (processedPaths.has(dirPath)) return;
88
+ processedPaths.add(dirPath);
89
+
90
+ // Check if this directory exists
91
+ if (!existsSync(dirPath)) return;
92
+
93
+ // Check if this is a git repository
94
+ const isGit = await gitService.isGitRepo(dirPath);
95
+
96
+ if (isGit) {
97
+ // This is a git repository - add it as a project
98
+ const project = await createProject(dirPath, "git");
99
+ foundProjects.push(project);
100
+
101
+ // Check for submodules if configured
102
+ if (config.display.showSubmodules) {
103
+ const submodulePaths = await findSubmodules(dirPath, gitService);
104
+ for (const subPath of submodulePaths) {
105
+ if (!processedPaths.has(subPath)) {
106
+ const subProject = await createProject(subPath, "git-submodule", undefined, gitService);
107
+ foundProjects.push(subProject);
108
+ processedPaths.add(subPath);
109
+ }
110
+ }
111
+ }
112
+
113
+ // Don't recurse into git repositories (except for submodules which we handle above)
114
+ return;
115
+ }
116
+
117
+ // Check if this is a project without git
118
+ const marker = await detectProjectMarker(dirPath);
119
+ if (marker) {
120
+ // Only add non-git projects if configured to show them
121
+ if (config.display.showNonGitProjects) {
122
+ const project = await createProject(dirPath, "non-git", marker, gitService);
123
+ foundProjects.push(project);
124
+ }
125
+ // Don't recurse into non-git projects (even if not showing them)
126
+ return;
127
+ }
128
+
129
+ // This is just a directory - scan children
130
+ try {
131
+ const entries = await readdir(dirPath);
132
+
133
+ for (const entry of entries) {
134
+ if (shouldIgnore(entry, config.scan.ignore, config.scan.includeHidden)) {
135
+ continue;
136
+ }
137
+
138
+ const entryPath = join(dirPath, entry);
139
+
140
+ try {
141
+ const entryStat = await stat(entryPath);
142
+ if (entryStat.isDirectory()) {
143
+ await scanDirectory(
144
+ entryPath,
145
+ config,
146
+ depth + 1,
147
+ maxDepth,
148
+ foundProjects,
149
+ processedPaths,
150
+ gitService
151
+ );
152
+ }
153
+ } catch {
154
+ // Skip entries we can't stat
155
+ }
156
+ }
157
+ } catch {
158
+ // Skip directories we can't read
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Create a Project object from a path
164
+ */
165
+ async function createProject(
166
+ path: string,
167
+ type: ProjectType,
168
+ marker?: string | null,
169
+ gitService: GitService = bunGitService
170
+ ): Promise<Project> {
171
+ const id = generateProjectId(path);
172
+ const name = basename(path);
173
+
174
+ // Get project marker if not provided and not a git repo
175
+ const projectMarker = marker ?? (type === "non-git" ? await detectProjectMarker(path) : null);
176
+
177
+ // Get git status if this is a git project
178
+ let status = null;
179
+ if (type === "git" || type === "git-submodule") {
180
+ try {
181
+ status = await getGitStatus(path, gitService);
182
+ } catch {
183
+ // Status unavailable
184
+ }
185
+ }
186
+
187
+ // Get submodule info if applicable
188
+ let submodule = null;
189
+ if (type === "git-submodule") {
190
+ submodule = await getSubmoduleInfo(path, gitService);
191
+ }
192
+
193
+ // Get last modified time for non-git projects (or git projects without commits)
194
+ let lastModified: Date | null = null;
195
+ if (type === "non-git" || (status && !status.hasCommits)) {
196
+ lastModified = await getLatestModificationTime(path);
197
+ }
198
+
199
+ return {
200
+ id,
201
+ name,
202
+ path,
203
+ type,
204
+ projectMarker,
205
+ status,
206
+ submodule,
207
+ lastScanned: new Date(),
208
+ lastModified,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Scan all configured directories for projects
214
+ *
215
+ * Recursively scans directories looking for git repositories, submodules,
216
+ * and non-git projects (identified by marker files like package.json).
217
+ *
218
+ * @param config - The gitforest configuration object containing directories to scan
219
+ * @param options - Options for scanning
220
+ * @param options.onProgress - Optional callback for progress updates
221
+ * @param options.gitService - Git service implementation (defaults to bunGitService)
222
+ * @returns Promise resolving to array of discovered projects
223
+ *
224
+ * @example
225
+ * ```typescript
226
+ * const projects = await scanAllDirectories(config, {
227
+ * onProgress: (scanned, found) => {
228
+ * console.log(`Scanned ${scanned} dirs, found ${found} projects`);
229
+ * }
230
+ * });
231
+ * ```
232
+ */
233
+ export async function scanAllDirectories(
234
+ config: GitforestConfig,
235
+ options: {
236
+ onProgress?: (scanned: number, found: number) => void;
237
+ gitService?: GitService;
238
+ } = {}
239
+ ): Promise<Project[]> {
240
+ const { onProgress, gitService = bunGitService } = options;
241
+ const foundProjects: Project[] = [];
242
+ const processedPaths = new Set<string>();
243
+
244
+ for (const dirConfig of config.directories) {
245
+ await scanDirectory(
246
+ dirConfig.path,
247
+ config,
248
+ 0,
249
+ dirConfig.maxDepth,
250
+ foundProjects,
251
+ processedPaths,
252
+ gitService
253
+ );
254
+
255
+ onProgress?.(processedPaths.size, foundProjects.length);
256
+ }
257
+
258
+ return foundProjects;
259
+ }
260
+
261
+ /**
262
+ * Convert a database row to a Project object
263
+ */
264
+ function dbRowToProject(row: typeof schema.projects.$inferSelect): Project {
265
+ return {
266
+ id: row.id,
267
+ name: row.name,
268
+ path: row.path,
269
+ type: row.type as ProjectType,
270
+ projectMarker: row.projectMarker,
271
+ status: row.statusJson ? JSON.parse(row.statusJson) as GitStatus : null,
272
+ submodule: row.submoduleJson ? JSON.parse(row.submoduleJson) as SubmoduleInfo : null,
273
+ lastScanned: row.lastScanned ?? new Date(),
274
+ lastModified: (row as { lastModified?: Date }).lastModified ?? null,
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Convert a Project to database row format
280
+ */
281
+ function projectToDbRow(project: Project) {
282
+ return {
283
+ id: project.id,
284
+ name: project.name,
285
+ path: project.path,
286
+ type: project.type,
287
+ projectMarker: project.projectMarker,
288
+ statusJson: project.status ? JSON.stringify(project.status) : null,
289
+ submoduleJson: project.submodule ? JSON.stringify(project.submodule) : null,
290
+ lastScanned: project.lastScanned,
291
+ lastModified: project.lastModified,
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Save projects to the cache database
297
+ */
298
+ async function saveToCache(projects: Project[]): Promise<void> {
299
+ const db = await initDb();
300
+
301
+ for (const project of projects) {
302
+ const row = projectToDbRow(project);
303
+ await db
304
+ .insert(schema.projects)
305
+ .values(row)
306
+ .onConflictDoUpdate({
307
+ target: schema.projects.path,
308
+ set: row,
309
+ });
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Load all projects from cache
315
+ */
316
+ async function loadFromCache(): Promise<Project[]> {
317
+ const db = await initDb();
318
+ const rows = await db.select().from(schema.projects).all();
319
+ return rows.map(dbRowToProject);
320
+ }
321
+
322
+ /**
323
+ * Check if cache is fresh based on TTL
324
+ */
325
+ function isCacheFresh(projects: Project[], ttlSeconds: number): boolean {
326
+ if (projects.length === 0) return false;
327
+
328
+ const now = Date.now();
329
+ const ttlMs = ttlSeconds * 1000;
330
+
331
+ // Cache is fresh if most recent scan is within TTL
332
+ const mostRecent = Math.max(...projects.map(p => p.lastScanned.getTime()));
333
+ return now - mostRecent < ttlMs;
334
+ }
335
+
336
+ /**
337
+ * Scan directories with caching support
338
+ *
339
+ * Uses cached data if fresh (within TTL), otherwise performs full scan.
340
+ * Results are automatically cached for subsequent calls.
341
+ *
342
+ * @param config - The gitforest configuration object
343
+ * @param options - Scanning options
344
+ * @param options.forceRefresh - Force a fresh scan even if cache is valid
345
+ * @param options.onProgress - Callback for scan progress updates
346
+ * @param options.gitService - Git service implementation (defaults to bunGitService)
347
+ * @returns Promise resolving to array of projects (cached or fresh)
348
+ *
349
+ * @example
350
+ * ```typescript
351
+ * // Use cache if available
352
+ * const projects = await scanWithCache(config);
353
+ *
354
+ * // Force fresh scan
355
+ * const freshProjects = await scanWithCache(config, { forceRefresh: true });
356
+ * ```
357
+ */
358
+ export async function scanWithCache(
359
+ config: GitforestConfig,
360
+ options: {
361
+ forceRefresh?: boolean;
362
+ onProgress?: (scanned: number, found: number) => void;
363
+ gitService?: GitService;
364
+ } = {}
365
+ ): Promise<Project[]> {
366
+ const { forceRefresh = false, onProgress, gitService = bunGitService } = options;
367
+
368
+ // Try to load from cache first
369
+ if (!forceRefresh) {
370
+ try {
371
+ const cached = await loadFromCache();
372
+ if (isCacheFresh(cached, config.cache.ttlSeconds)) {
373
+ return cached;
374
+ }
375
+ } catch {
376
+ // Cache read failed, continue with fresh scan
377
+ }
378
+ }
379
+
380
+ // Perform full scan
381
+ const projects = await scanAllDirectories(config, { onProgress, gitService });
382
+
383
+ // Save to cache
384
+ try {
385
+ await saveToCache(projects);
386
+ } catch {
387
+ // Cache write failed, but we still have the projects
388
+ }
389
+
390
+ return projects;
391
+ }
392
+
393
+ /**
394
+ * Clear the project cache
395
+ */
396
+ export async function clearCache(): Promise<void> {
397
+ await clearDbCache();
398
+ }
399
+
400
+ /**
401
+ * Get cache statistics
402
+ */
403
+ export async function getCacheStats(): Promise<{
404
+ projectCount: number;
405
+ oldestScan: Date | null;
406
+ newestScan: Date | null;
407
+ }> {
408
+ const db = await initDb();
409
+ const rows = await db.select().from(schema.projects).all();
410
+
411
+ if (rows.length === 0) {
412
+ return { projectCount: 0, oldestScan: null, newestScan: null };
413
+ }
414
+
415
+ const timestamps = rows
416
+ .map(r => r.lastScanned?.getTime() ?? 0)
417
+ .filter(t => t > 0);
418
+
419
+ return {
420
+ projectCount: rows.length,
421
+ oldestScan: timestamps.length > 0 ? new Date(Math.min(...timestamps)) : null,
422
+ newestScan: timestamps.length > 0 ? new Date(Math.max(...timestamps)) : null,
423
+ };
424
+ }
@@ -0,0 +1,43 @@
1
+ import { existsSync } from "fs";
2
+ import { join } from "path";
3
+ import { PROJECT_MARKERS } from "../types/index.ts";
4
+
5
+ /**
6
+ * Detect the project type based on marker files
7
+ */
8
+ export async function detectProjectMarker(path: string): Promise<string | null> {
9
+ for (const marker of Object.keys(PROJECT_MARKERS)) {
10
+ const markerPath = join(path, marker);
11
+ if (existsSync(markerPath)) {
12
+ return marker;
13
+ }
14
+ }
15
+ return null;
16
+ }
17
+
18
+ /**
19
+ * Get the human-readable project type
20
+ */
21
+ export function getProjectTypeName(marker: string | null): string {
22
+ if (!marker) return "Unknown";
23
+ return PROJECT_MARKERS[marker] ?? "Unknown";
24
+ }
25
+
26
+ /**
27
+ * Check if a directory is likely a project (has marker files or is a git repo)
28
+ */
29
+ export async function isLikelyProject(path: string): Promise<boolean> {
30
+ // Check for .git directory
31
+ if (existsSync(join(path, ".git"))) {
32
+ return true;
33
+ }
34
+
35
+ // Check for project markers
36
+ for (const marker of Object.keys(PROJECT_MARKERS)) {
37
+ if (existsSync(join(path, marker))) {
38
+ return true;
39
+ }
40
+ }
41
+
42
+ return false;
43
+ }
@@ -0,0 +1,61 @@
1
+ import { existsSync } from "fs";
2
+ import { join, relative } from "path";
3
+ import type { SubmoduleInfo } from "../types/index.ts";
4
+ import type { GitService } from "../services/git.ts";
5
+ import { bunGitService } from "../services/git.ts";
6
+
7
+ /**
8
+ * Get submodule information for a path
9
+ */
10
+ export async function getSubmoduleInfo(
11
+ path: string,
12
+ gitService: GitService = bunGitService
13
+ ): Promise<SubmoduleInfo | null> {
14
+ const isSub = await gitService.isSubmodule(path);
15
+ if (!isSub) return null;
16
+
17
+ const parentPath = await gitService.getSubmoduleParent(path);
18
+ if (!parentPath) return null;
19
+
20
+ // Get configured commit from parent's submodule list
21
+ const submodules = await gitService.listSubmodules(parentPath);
22
+ const relativePath = relative(parentPath, path);
23
+
24
+ const subInfo = submodules.find((s) => s.path === relativePath);
25
+
26
+ return {
27
+ parentPath,
28
+ relativePath,
29
+ configuredCommit: subInfo?.commit ?? "",
30
+ currentCommit: subInfo?.commit ?? "",
31
+ isInitialized: subInfo?.status !== "-",
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Find all submodules in a git repository
37
+ */
38
+ export async function findSubmodules(
39
+ repoPath: string,
40
+ gitService: GitService = bunGitService
41
+ ): Promise<string[]> {
42
+ // Check if .gitmodules exists
43
+ if (!existsSync(join(repoPath, ".gitmodules"))) {
44
+ return [];
45
+ }
46
+
47
+ const submodules = await gitService.listSubmodules(repoPath);
48
+ return submodules.map((s) => join(repoPath, s.path));
49
+ }
50
+
51
+ /**
52
+ * Check if a path is a submodule of another repository
53
+ */
54
+ export async function isSubmoduleOf(
55
+ path: string,
56
+ parentPath: string,
57
+ gitService: GitService = bunGitService
58
+ ): Promise<boolean> {
59
+ const submodules = await findSubmodules(parentPath, gitService);
60
+ return submodules.includes(path);
61
+ }