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,978 @@
1
+ /**
2
+ * Tests for database module
3
+ * Uses in-memory SQLite for testing
4
+ */
5
+
6
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
7
+ import { Database } from "bun:sqlite";
8
+ import { drizzle } from "drizzle-orm/bun-sqlite";
9
+ import { eq, sql } from "drizzle-orm";
10
+ import * as schema from "../../../src/db/schema.ts";
11
+
12
+ describe("Database", () => {
13
+ let sqliteDb: Database;
14
+ let db: ReturnType<typeof drizzle>;
15
+
16
+ beforeEach(() => {
17
+ // Use in-memory database for testing
18
+ sqliteDb = new Database(":memory:");
19
+
20
+ // Enable foreign key constraints
21
+ sqliteDb.run("PRAGMA foreign_keys = ON");
22
+
23
+ // Create tables
24
+ sqliteDb.run(`
25
+ CREATE TABLE IF NOT EXISTS projects (
26
+ id TEXT PRIMARY KEY,
27
+ name TEXT NOT NULL,
28
+ path TEXT NOT NULL UNIQUE,
29
+ type TEXT NOT NULL,
30
+ project_marker TEXT,
31
+ status_json TEXT,
32
+ submodule_json TEXT,
33
+ last_scanned INTEGER
34
+ );
35
+
36
+ CREATE TABLE IF NOT EXISTS remote_status (
37
+ project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
38
+ last_fetched INTEGER,
39
+ unpulled_commits INTEGER,
40
+ remote_last_activity INTEGER,
41
+ remote_url TEXT
42
+ );
43
+
44
+ CREATE TABLE IF NOT EXISTS github_repos (
45
+ id INTEGER PRIMARY KEY,
46
+ name TEXT NOT NULL,
47
+ full_name TEXT NOT NULL UNIQUE,
48
+ owner TEXT NOT NULL,
49
+ description TEXT,
50
+ html_url TEXT,
51
+ ssh_url TEXT,
52
+ clone_url TEXT,
53
+ is_private INTEGER,
54
+ is_archived INTEGER,
55
+ is_fork INTEGER,
56
+ pushed_at INTEGER,
57
+ updated_at INTEGER,
58
+ default_branch TEXT,
59
+ language TEXT,
60
+ size INTEGER,
61
+ stargazers_count INTEGER,
62
+ forks_count INTEGER,
63
+ open_issues_count INTEGER,
64
+ watchers_count INTEGER,
65
+ topics TEXT,
66
+ license TEXT,
67
+ has_issues INTEGER,
68
+ has_wiki INTEGER,
69
+ has_discussions INTEGER,
70
+ last_fetched INTEGER
71
+ );
72
+
73
+ CREATE TABLE IF NOT EXISTS config_cache (
74
+ key TEXT PRIMARY KEY,
75
+ value TEXT,
76
+ updated_at INTEGER
77
+ );
78
+ `);
79
+
80
+ db = drizzle(sqliteDb, { schema });
81
+ });
82
+
83
+ afterEach(() => {
84
+ sqliteDb.close();
85
+ });
86
+
87
+ describe("projects table", () => {
88
+ // Existing tests...
89
+ test("inserts a project", () => {
90
+ const project = {
91
+ id: "test-123",
92
+ name: "my-project",
93
+ path: "/home/user/my-project",
94
+ type: "git",
95
+ projectMarker: null,
96
+ statusJson: JSON.stringify({ isDirty: false }),
97
+ submoduleJson: null,
98
+ lastScanned: new Date(),
99
+ };
100
+
101
+ db.insert(schema.projects).values(project).run();
102
+
103
+ const result = db.select().from(schema.projects).all();
104
+ expect(result).toHaveLength(1);
105
+ expect(result[0]!.name).toBe("my-project");
106
+ expect(result[0]!.path).toBe("/home/user/my-project");
107
+ });
108
+
109
+ test("enforces unique path constraint", () => {
110
+ const project1 = {
111
+ id: "test-1",
112
+ name: "project-1",
113
+ path: "/home/user/project",
114
+ type: "git",
115
+ };
116
+
117
+ const project2 = {
118
+ id: "test-2",
119
+ name: "project-2",
120
+ path: "/home/user/project", // Same path
121
+ type: "git",
122
+ };
123
+
124
+ db.insert(schema.projects).values(project1).run();
125
+
126
+ // Should throw on duplicate path
127
+ expect(() => {
128
+ db.insert(schema.projects).values(project2).run();
129
+ }).toThrow();
130
+ });
131
+
132
+ test("updates a project", () => {
133
+ const project = {
134
+ id: "update-test",
135
+ name: "original-name",
136
+ path: "/home/user/project",
137
+ type: "git",
138
+ };
139
+
140
+ db.insert(schema.projects).values(project).run();
141
+
142
+ db.update(schema.projects)
143
+ .set({ name: "updated-name" })
144
+ .where(eq(schema.projects.id, "update-test"))
145
+ .run();
146
+
147
+ const result = db.select().from(schema.projects).where(eq(schema.projects.id, "update-test")).all();
148
+ expect(result[0]!.name).toBe("updated-name");
149
+ });
150
+
151
+ test("deletes a project", () => {
152
+ const project = {
153
+ id: "delete-test",
154
+ name: "to-delete",
155
+ path: "/home/user/to-delete",
156
+ type: "git",
157
+ };
158
+
159
+ db.insert(schema.projects).values(project).run();
160
+
161
+ // Verify it exists
162
+ let result = db.select().from(schema.projects).all();
163
+ expect(result).toHaveLength(1);
164
+
165
+ // Delete it
166
+ db.delete(schema.projects).where(eq(schema.projects.id, "delete-test")).run();
167
+
168
+ // Verify it's gone
169
+ result = db.select().from(schema.projects).all();
170
+ expect(result).toHaveLength(0);
171
+ });
172
+
173
+ test("stores different project types", () => {
174
+ const projects = [
175
+ { id: "git-1", name: "git-project", path: "/path/git", type: "git" },
176
+ { id: "sub-1", name: "submodule", path: "/path/sub", type: "git-submodule" },
177
+ { id: "non-1", name: "npm-project", path: "/path/npm", type: "non-git", projectMarker: "package.json" },
178
+ ];
179
+
180
+ for (const project of projects) {
181
+ db.insert(schema.projects).values(project).run();
182
+ }
183
+
184
+ const gitProjects = db.select().from(schema.projects).where(eq(schema.projects.type, "git")).all();
185
+ expect(gitProjects).toHaveLength(1);
186
+
187
+ const nonGitProjects = db.select().from(schema.projects).where(eq(schema.projects.type, "non-git")).all();
188
+ expect(nonGitProjects).toHaveLength(1);
189
+ expect(nonGitProjects[0]!.projectMarker).toBe("package.json");
190
+ });
191
+
192
+ test("stores and retrieves status JSON", () => {
193
+ const status = {
194
+ isDirty: true,
195
+ hasUnstagedChanges: true,
196
+ modifiedCount: 5,
197
+ currentBranch: "main",
198
+ };
199
+
200
+ const project = {
201
+ id: "status-test",
202
+ name: "status-project",
203
+ path: "/path/status",
204
+ type: "git",
205
+ statusJson: JSON.stringify(status),
206
+ };
207
+
208
+ db.insert(schema.projects).values(project).run();
209
+
210
+ const result = db.select().from(schema.projects).where(eq(schema.projects.id, "status-test")).all();
211
+ const retrievedStatus = JSON.parse(result[0]!.statusJson!);
212
+
213
+ expect(retrievedStatus.isDirty).toBe(true);
214
+ expect(retrievedStatus.modifiedCount).toBe(5);
215
+ expect(retrievedStatus.currentBranch).toBe("main");
216
+ });
217
+
218
+ // New tests for project cache functionality
219
+ describe("project cache operations", () => {
220
+ test("saves projects to cache", () => {
221
+ const projects = [
222
+ { id: "cache-1", name: "cached-1", path: "/cache/1", type: "git" },
223
+ { id: "cache-2", name: "cached-2", path: "/cache/2", type: "git" },
224
+ ];
225
+
226
+ // Save multiple projects
227
+ for (const project of projects) {
228
+ db.insert(schema.projects).values(project).run();
229
+ }
230
+
231
+ const allProjects = db.select().from(schema.projects).all();
232
+ expect(allProjects).toHaveLength(2);
233
+ expect(allProjects.map(p => p.name)).toEqual(["cached-1", "cached-2"]);
234
+ });
235
+
236
+ test("loads projects from cache", () => {
237
+ // Insert test data
238
+ db.insert(schema.projects).values([
239
+ { id: "load-1", name: "load-test-1", path: "/load/1", type: "git" },
240
+ { id: "load-2", name: "load-test-2", path: "/load/2", type: "non-git" },
241
+ ]).run();
242
+
243
+ // Load all projects
244
+ const allProjects = db.select().from(schema.projects).all();
245
+ expect(allProjects).toHaveLength(2);
246
+
247
+ // Load specific project by ID
248
+ const projectById = db.select().from(schema.projects)
249
+ .where(eq(schema.projects.id, "load-1"))
250
+ .all();
251
+ expect(projectById).toHaveLength(1);
252
+ expect(projectById[0]!.name).toBe("load-test-1");
253
+
254
+ // Load by type
255
+ const gitProjects = db.select().from(schema.projects)
256
+ .where(eq(schema.projects.type, "git"))
257
+ .all();
258
+ expect(gitProjects).toHaveLength(1);
259
+ expect(gitProjects[0]!.id).toBe("load-1");
260
+ });
261
+
262
+ test("updates existing projects (upsert)", () => {
263
+ const initialProject = {
264
+ id: "upsert-test",
265
+ name: "initial-name",
266
+ path: "/upsert/test",
267
+ type: "git",
268
+ lastScanned: new Date("2023-01-01"),
269
+ };
270
+
271
+ // Insert initial project
272
+ db.insert(schema.projects).values(initialProject).run();
273
+
274
+ // Update with upsert pattern
275
+ const updatedProject = {
276
+ id: "upsert-test",
277
+ name: "updated-name",
278
+ path: "/upsert/test",
279
+ type: "git",
280
+ lastScanned: new Date("2023-12-31"),
281
+ };
282
+
283
+ // SQLite doesn't have native upsert, so we delete then insert
284
+ db.delete(schema.projects).where(eq(schema.projects.id, "upsert-test")).run();
285
+ db.insert(schema.projects).values(updatedProject).run();
286
+
287
+ const result = db.select().from(schema.projects)
288
+ .where(eq(schema.projects.id, "upsert-test"))
289
+ .all();
290
+
291
+ expect(result).toHaveLength(1);
292
+ expect(result[0]!.name).toBe("updated-name");
293
+ expect(result[0]!.lastScanned).toEqual(updatedProject.lastScanned);
294
+ });
295
+
296
+ test("cache TTL expiration logic", () => {
297
+ // Insert projects with different lastScanned times
298
+ const now = new Date();
299
+ const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
300
+ const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
301
+
302
+ db.insert(schema.projects).values([
303
+ { id: "fresh", name: "fresh", path: "/fresh", type: "git", lastScanned: now },
304
+ { id: "hour-old", name: "hour-old", path: "/hour", type: "git", lastScanned: oneHourAgo },
305
+ { id: "expired", name: "expired", path: "/expired", type: "git", lastScanned: twoDaysAgo },
306
+ ]).run();
307
+
308
+ // Get all projects and filter manually (simulating TTL check)
309
+ const allProjects = db.select().from(schema.projects).all();
310
+ const oneDayAgo = now.getTime() - (24 * 60 * 60 * 1000);
311
+ const expiredProjects = allProjects.filter(p => {
312
+ if (!p.lastScanned) return false;
313
+ return new Date(p.lastScanned).getTime() < oneDayAgo;
314
+ });
315
+
316
+ expect(expiredProjects).toHaveLength(1);
317
+ expect(expiredProjects[0]!.id).toBe("expired");
318
+ });
319
+ });
320
+ });
321
+
322
+ describe("remote_status table", () => {
323
+ test("inserts remote status for project", () => {
324
+ // First create a project
325
+ db.insert(schema.projects).values({
326
+ id: "project-with-remote",
327
+ name: "remote-project",
328
+ path: "/path/remote",
329
+ type: "git",
330
+ }).run();
331
+
332
+ // Then add remote status
333
+ db.insert(schema.remoteStatus).values({
334
+ projectId: "project-with-remote",
335
+ lastFetched: new Date(),
336
+ unpulledCommits: 3,
337
+ remoteUrl: "git@github.com:user/repo.git",
338
+ }).run();
339
+
340
+ const result = db.select().from(schema.remoteStatus).all();
341
+ expect(result).toHaveLength(1);
342
+ expect(result[0]!.unpulledCommits).toBe(3);
343
+ expect(result[0]!.remoteUrl).toBe("git@github.com:user/repo.git");
344
+ });
345
+
346
+ test("updates remote status", () => {
347
+ db.insert(schema.projects).values({
348
+ id: "update-remote",
349
+ name: "project",
350
+ path: "/path/update-remote",
351
+ type: "git",
352
+ }).run();
353
+
354
+ db.insert(schema.remoteStatus).values({
355
+ projectId: "update-remote",
356
+ unpulledCommits: 1,
357
+ }).run();
358
+
359
+ db.update(schema.remoteStatus)
360
+ .set({ unpulledCommits: 5 })
361
+ .where(eq(schema.remoteStatus.projectId, "update-remote"))
362
+ .run();
363
+
364
+ const result = db.select().from(schema.remoteStatus).where(eq(schema.remoteStatus.projectId, "update-remote")).all();
365
+ expect(result[0]!.unpulledCommits).toBe(5);
366
+ });
367
+
368
+ // New tests for remote status
369
+ describe("remote status operations", () => {
370
+ test("saves remote status", () => {
371
+ // Create project first
372
+ db.insert(schema.projects).values({
373
+ id: "save-remote",
374
+ name: "save-remote-project",
375
+ path: "/save/remote",
376
+ type: "git",
377
+ }).run();
378
+
379
+ const remoteStatus = {
380
+ projectId: "save-remote",
381
+ lastFetched: new Date(),
382
+ unpulledCommits: 5,
383
+ remoteLastActivity: new Date(Date.now() - 3600000), // 1 hour ago
384
+ remoteUrl: "https://github.com/test/repo.git",
385
+ };
386
+
387
+ db.insert(schema.remoteStatus).values(remoteStatus).run();
388
+
389
+ const saved = db.select().from(schema.remoteStatus)
390
+ .where(eq(schema.remoteStatus.projectId, "save-remote"))
391
+ .all();
392
+
393
+ expect(saved).toHaveLength(1);
394
+ expect(saved[0]!.unpulledCommits).toBe(5);
395
+ expect(saved[0]!.remoteUrl).toBe("https://github.com/test/repo.git");
396
+ });
397
+
398
+ test("loads remote status", () => {
399
+ // Create project and remote status
400
+ db.insert(schema.projects).values({
401
+ id: "load-remote",
402
+ name: "load-remote-project",
403
+ path: "/load/remote",
404
+ type: "git",
405
+ }).run();
406
+
407
+ db.insert(schema.remoteStatus).values({
408
+ projectId: "load-remote",
409
+ unpulledCommits: 10,
410
+ remoteUrl: "git@github.com:user/repo.git",
411
+ }).run();
412
+
413
+ // Load by project ID
414
+ const result = db.select().from(schema.remoteStatus)
415
+ .where(eq(schema.remoteStatus.projectId, "load-remote"))
416
+ .all();
417
+
418
+ expect(result).toHaveLength(1);
419
+ expect(result[0]!.unpulledCommits).toBe(10);
420
+ });
421
+
422
+ test("cascade delete when project deleted", () => {
423
+ // Create project
424
+ db.insert(schema.projects).values({
425
+ id: "cascade-test",
426
+ name: "cascade-project",
427
+ path: "/cascade/test",
428
+ type: "git",
429
+ }).run();
430
+
431
+ // Create remote status
432
+ db.insert(schema.remoteStatus).values({
433
+ projectId: "cascade-test",
434
+ unpulledCommits: 2,
435
+ }).run();
436
+
437
+ // Verify both exist
438
+ expect(db.select().from(schema.projects).all()).toHaveLength(1);
439
+ expect(db.select().from(schema.remoteStatus).all()).toHaveLength(1);
440
+
441
+ // Delete project
442
+ db.delete(schema.projects).where(eq(schema.projects.id, "cascade-test")).run();
443
+
444
+ // Verify remote status was also deleted (cascade)
445
+ expect(db.select().from(schema.projects).all()).toHaveLength(0);
446
+ expect(db.select().from(schema.remoteStatus).all()).toHaveLength(0);
447
+ });
448
+ });
449
+ });
450
+
451
+ describe("config_cache table", () => {
452
+ test("stores config values", () => {
453
+ db.insert(schema.configCache).values({
454
+ key: "last_config_hash",
455
+ value: "abc123",
456
+ updatedAt: new Date(),
457
+ }).run();
458
+
459
+ const result = db.select().from(schema.configCache).where(eq(schema.configCache.key, "last_config_hash")).all();
460
+ expect(result).toHaveLength(1);
461
+ expect(result[0]!.value).toBe("abc123");
462
+ });
463
+
464
+ test("updates config values with upsert pattern", () => {
465
+ const key = "test_key";
466
+
467
+ // Insert initial value
468
+ db.insert(schema.configCache).values({
469
+ key,
470
+ value: "initial",
471
+ updatedAt: new Date(),
472
+ }).run();
473
+
474
+ // Update with new value
475
+ db.update(schema.configCache)
476
+ .set({ value: "updated", updatedAt: new Date() })
477
+ .where(eq(schema.configCache.key, key))
478
+ .run();
479
+
480
+ const result = db.select().from(schema.configCache).where(eq(schema.configCache.key, key)).all();
481
+ expect(result[0]!.value).toBe("updated");
482
+ });
483
+
484
+ // New tests for config cache
485
+ describe("config cache operations", () => {
486
+ test("saves config values", () => {
487
+ const configValues = [
488
+ { key: "github_token", value: "ghp_123456", updatedAt: new Date() },
489
+ { key: "theme", value: "dark", updatedAt: new Date() },
490
+ { key: "auto_refresh", value: "true", updatedAt: new Date() },
491
+ ];
492
+
493
+ for (const config of configValues) {
494
+ db.insert(schema.configCache).values(config).run();
495
+ }
496
+
497
+ const allConfigs = db.select().from(schema.configCache).all();
498
+ expect(allConfigs).toHaveLength(3);
499
+ expect(allConfigs.map(c => c.key).sort()).toEqual(["auto_refresh", "github_token", "theme"]);
500
+ });
501
+
502
+ test("loads config values", () => {
503
+ // Insert test configs
504
+ db.insert(schema.configCache).values([
505
+ { key: "load_test_1", value: "value1", updatedAt: new Date("2023-01-01") },
506
+ { key: "load_test_2", value: "value2", updatedAt: new Date("2023-12-31") },
507
+ ]).run();
508
+
509
+ // Load single config
510
+ const config1 = db.select().from(schema.configCache)
511
+ .where(eq(schema.configCache.key, "load_test_1"))
512
+ .all();
513
+ expect(config1).toHaveLength(1);
514
+ expect(config1[0]!.value).toBe("value1");
515
+
516
+ // Load all configs
517
+ const allConfigs = db.select().from(schema.configCache).all();
518
+ expect(allConfigs).toHaveLength(2);
519
+ });
520
+
521
+ test("updates config values", () => {
522
+ const key = "update_test";
523
+ const initialDate = new Date("2023-01-01");
524
+ const updatedDate = new Date("2023-12-31");
525
+
526
+ // Initial insert
527
+ db.insert(schema.configCache).values({
528
+ key,
529
+ value: "initial_value",
530
+ updatedAt: initialDate,
531
+ }).run();
532
+
533
+ // Update
534
+ db.update(schema.configCache)
535
+ .set({
536
+ value: "updated_value",
537
+ updatedAt: updatedDate,
538
+ })
539
+ .where(eq(schema.configCache.key, key))
540
+ .run();
541
+
542
+ const result = db.select().from(schema.configCache)
543
+ .where(eq(schema.configCache.key, key))
544
+ .all();
545
+
546
+ expect(result).toHaveLength(1);
547
+ expect(result[0]!.value).toBe("updated_value");
548
+ expect(new Date(result[0]!.updatedAt!)).toEqual(updatedDate);
549
+ });
550
+ });
551
+ });
552
+
553
+ describe("github_repos table", () => {
554
+ describe("GitHub repos cache operations", () => {
555
+ test("saves github repos", () => {
556
+ const repos = [
557
+ {
558
+ id: 1,
559
+ name: "repo1",
560
+ fullName: "user1/repo1",
561
+ owner: "user1",
562
+ description: "Test repo 1",
563
+ htmlUrl: "https://github.com/user1/repo1",
564
+ isPrivate: false,
565
+ isArchived: false,
566
+ isFork: false,
567
+ pushedAt: new Date("2023-12-01"),
568
+ updatedAt: new Date("2023-12-15"),
569
+ defaultBranch: "main",
570
+ language: "TypeScript",
571
+ size: 1024,
572
+ stargazersCount: 100,
573
+ forksCount: 20,
574
+ openIssuesCount: 5,
575
+ watchersCount: 50,
576
+ topics: JSON.stringify(["typescript", "react"]),
577
+ license: "MIT",
578
+ hasIssues: true,
579
+ hasWiki: true,
580
+ hasDiscussions: false,
581
+ lastFetched: new Date(),
582
+ },
583
+ {
584
+ id: 2,
585
+ name: "repo2",
586
+ fullName: "user2/repo2",
587
+ owner: "user2",
588
+ description: "Test repo 2",
589
+ htmlUrl: "https://github.com/user2/repo2",
590
+ isPrivate: true,
591
+ isArchived: false,
592
+ isFork: true,
593
+ pushedAt: new Date("2023-11-15"),
594
+ updatedAt: new Date("2023-11-20"),
595
+ defaultBranch: "master",
596
+ language: "JavaScript",
597
+ size: 2048,
598
+ stargazersCount: 50,
599
+ forksCount: 10,
600
+ openIssuesCount: 3,
601
+ watchersCount: 25,
602
+ topics: JSON.stringify(["javascript", "node"]),
603
+ license: "Apache-2.0",
604
+ hasIssues: true,
605
+ hasWiki: false,
606
+ hasDiscussions: true,
607
+ lastFetched: new Date(),
608
+ },
609
+ ];
610
+
611
+ for (const repo of repos) {
612
+ db.insert(schema.githubRepos).values(repo).run();
613
+ }
614
+
615
+ const allRepos = db.select().from(schema.githubRepos).all();
616
+ expect(allRepos).toHaveLength(2);
617
+ expect(allRepos[0]!.fullName).toBe("user1/repo1");
618
+ expect(allRepos[1]!.fullName).toBe("user2/repo2");
619
+ });
620
+
621
+ test("loads github repos", () => {
622
+ // Insert test repos
623
+ db.insert(schema.githubRepos).values([
624
+ {
625
+ id: 101,
626
+ name: "load-test",
627
+ fullName: "testuser/load-test",
628
+ owner: "testuser",
629
+ description: "Load test repo",
630
+ lastFetched: new Date(),
631
+ },
632
+ {
633
+ id: 102,
634
+ name: "another-test",
635
+ fullName: "anotheruser/another-test",
636
+ owner: "anotheruser",
637
+ description: "Another test repo",
638
+ lastFetched: new Date(),
639
+ },
640
+ ]).run();
641
+
642
+ // Load all repos
643
+ const allRepos = db.select().from(schema.githubRepos).all();
644
+ expect(allRepos).toHaveLength(2);
645
+
646
+ // Load by ID
647
+ const repoById = db.select().from(schema.githubRepos)
648
+ .where(eq(schema.githubRepos.id, 101))
649
+ .all();
650
+ expect(repoById).toHaveLength(1);
651
+ expect(repoById[0]!.name).toBe("load-test");
652
+ });
653
+
654
+ test("filters by owner", () => {
655
+ // Insert repos with different owners
656
+ db.insert(schema.githubRepos).values([
657
+ {
658
+ id: 201,
659
+ name: "repo1",
660
+ fullName: "owner1/repo1",
661
+ owner: "owner1",
662
+ lastFetched: new Date(),
663
+ },
664
+ {
665
+ id: 202,
666
+ name: "repo2",
667
+ fullName: "owner1/repo2",
668
+ owner: "owner1",
669
+ lastFetched: new Date(),
670
+ },
671
+ {
672
+ id: 203,
673
+ name: "repo3",
674
+ fullName: "owner2/repo3",
675
+ owner: "owner2",
676
+ lastFetched: new Date(),
677
+ },
678
+ ]).run();
679
+
680
+ // Filter by owner1
681
+ const owner1Repos = db.select().from(schema.githubRepos)
682
+ .where(eq(schema.githubRepos.owner, "owner1"))
683
+ .all();
684
+
685
+ expect(owner1Repos).toHaveLength(2);
686
+ expect(owner1Repos.every(r => r.owner === "owner1")).toBe(true);
687
+
688
+ // Filter by owner2
689
+ const owner2Repos = db.select().from(schema.githubRepos)
690
+ .where(eq(schema.githubRepos.owner, "owner2"))
691
+ .all();
692
+
693
+ expect(owner2Repos).toHaveLength(1);
694
+ expect(owner2Repos[0]!.name).toBe("repo3");
695
+ });
696
+
697
+ test("last_fetched timestamp", () => {
698
+ // Insert repos with different fetch times
699
+ const now = new Date();
700
+ const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
701
+ const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
702
+
703
+ db.insert(schema.githubRepos).values([
704
+ {
705
+ id: 301,
706
+ name: "fresh",
707
+ fullName: "test/fresh",
708
+ owner: "test",
709
+ lastFetched: now,
710
+ },
711
+ {
712
+ id: 302,
713
+ name: "hour-old",
714
+ fullName: "test/hour-old",
715
+ owner: "test",
716
+ lastFetched: oneHourAgo,
717
+ },
718
+ {
719
+ id: 303,
720
+ name: "stale",
721
+ fullName: "test/stale",
722
+ owner: "test",
723
+ lastFetched: yesterday,
724
+ },
725
+ ]).run();
726
+
727
+ // Get all repos and filter manually (simulating stale check)
728
+ const allRepos = db.select().from(schema.githubRepos).all();
729
+ const twoHoursAgo = now.getTime() - (2 * 60 * 60 * 1000);
730
+ const staleRepos = allRepos.filter(r => {
731
+ if (!r.lastFetched) return false;
732
+ return new Date(r.lastFetched).getTime() < twoHoursAgo;
733
+ });
734
+
735
+ expect(staleRepos).toHaveLength(1);
736
+ expect(staleRepos[0]!.name).toBe("stale");
737
+ });
738
+ });
739
+ });
740
+
741
+ describe("database operations", () => {
742
+ test("clears all projects", () => {
743
+ // Insert some projects
744
+ db.insert(schema.projects).values([
745
+ { id: "p1", name: "project-1", path: "/path/1", type: "git" },
746
+ { id: "p2", name: "project-2", path: "/path/2", type: "git" },
747
+ { id: "p3", name: "project-3", path: "/path/3", type: "non-git" },
748
+ ]).run();
749
+
750
+ // Verify they exist
751
+ let result = db.select().from(schema.projects).all();
752
+ expect(result).toHaveLength(3);
753
+
754
+ // Clear all
755
+ db.delete(schema.projects).run();
756
+
757
+ // Verify empty
758
+ result = db.select().from(schema.projects).all();
759
+ expect(result).toHaveLength(0);
760
+ });
761
+
762
+ test("filters by type", () => {
763
+ db.insert(schema.projects).values([
764
+ { id: "g1", name: "git-1", path: "/git/1", type: "git" },
765
+ { id: "g2", name: "git-2", path: "/git/2", type: "git" },
766
+ { id: "n1", name: "non-1", path: "/non/1", type: "non-git" },
767
+ { id: "s1", name: "sub-1", path: "/sub/1", type: "git-submodule" },
768
+ ]).run();
769
+
770
+ const gitOnly = db.select()
771
+ .from(schema.projects)
772
+ .where(eq(schema.projects.type, "git"))
773
+ .all();
774
+
775
+ expect(gitOnly).toHaveLength(2);
776
+ expect(gitOnly.every(p => p.type === "git")).toBe(true);
777
+ });
778
+ });
779
+
780
+ describe("error handling", () => {
781
+ test("handles database initialization errors", () => {
782
+ // This would be tested in the actual initDb function
783
+ // For now, we can simulate with a bad query
784
+ expect(() => {
785
+ sqliteDb.run("INVALID SQL");
786
+ }).toThrow();
787
+ });
788
+
789
+ test("handles write errors", () => {
790
+ // Try to insert duplicate primary key
791
+ db.insert(schema.projects).values({
792
+ id: "duplicate",
793
+ name: "project-1",
794
+ path: "/path/1",
795
+ type: "git",
796
+ }).run();
797
+
798
+ expect(() => {
799
+ db.insert(schema.projects).values({
800
+ id: "duplicate", // Same ID
801
+ name: "project-2",
802
+ path: "/path/2",
803
+ type: "git",
804
+ }).run();
805
+ }).toThrow();
806
+ });
807
+
808
+ test("handles read errors on non-existent data", () => {
809
+ // Query non-existent data
810
+ const result = db.select().from(schema.projects)
811
+ .where(eq(schema.projects.id, "non-existent"))
812
+ .all();
813
+
814
+ expect(result).toHaveLength(0);
815
+ });
816
+
817
+ test("handles database cleanup", () => {
818
+ // Insert some data
819
+ db.insert(schema.projects).values({
820
+ id: "cleanup-test",
821
+ name: "cleanup",
822
+ path: "/cleanup",
823
+ type: "git",
824
+ }).run();
825
+
826
+ // Verify data exists
827
+ expect(db.select().from(schema.projects).all()).toHaveLength(1);
828
+
829
+ // Close database (simulating cleanup)
830
+ sqliteDb.close();
831
+
832
+ // Attempting to use closed database should throw
833
+ expect(() => {
834
+ sqliteDb.run("SELECT 1");
835
+ }).toThrow();
836
+ });
837
+ });
838
+
839
+ describe("edge cases", () => {
840
+ test("handles empty database operations", () => {
841
+ // Verify empty database
842
+ expect(db.select().from(schema.projects).all()).toHaveLength(0);
843
+ expect(db.select().from(schema.remoteStatus).all()).toHaveLength(0);
844
+ expect(db.select().from(schema.githubRepos).all()).toHaveLength(0);
845
+ expect(db.select().from(schema.configCache).all()).toHaveLength(0);
846
+ });
847
+
848
+ test("handles large batch inserts", () => {
849
+ const batchSize = 100;
850
+ const projects = Array.from({ length: batchSize }, (_, i) => ({
851
+ id: `batch-${i}`,
852
+ name: `Batch Project ${i}`,
853
+ path: `/batch/${i}`,
854
+ type: i % 3 === 0 ? "git" : i % 3 === 1 ? "non-git" : "git-submodule",
855
+ }));
856
+
857
+ // Insert large batch
858
+ const start = Date.now();
859
+ for (const project of projects) {
860
+ db.insert(schema.projects).values(project).run();
861
+ }
862
+ const duration = Date.now() - start;
863
+
864
+ // Verify all inserted
865
+ const allProjects = db.select().from(schema.projects).all();
866
+ expect(allProjects).toHaveLength(batchSize);
867
+
868
+ // Performance check - should complete reasonably fast
869
+ expect(duration).toBeLessThan(5000); // 5 seconds max
870
+ });
871
+
872
+ test("handles special characters in data", () => {
873
+ const specialProject = {
874
+ id: "special-chars",
875
+ name: "Project with 'quotes' and \"double quotes\"",
876
+ path: "/path/with spaces and 'quotes'",
877
+ type: "git",
878
+ statusJson: JSON.stringify({
879
+ message: "Status with \"quotes\" and 'apostrophes'",
880
+ path: "/path/with/unicode/字符/🔥",
881
+ }),
882
+ };
883
+
884
+ db.insert(schema.projects).values(specialProject).run();
885
+
886
+ const result = db.select().from(schema.projects)
887
+ .where(eq(schema.projects.id, "special-chars"))
888
+ .all();
889
+
890
+ expect(result).toHaveLength(1);
891
+ const retrievedStatus = JSON.parse(result[0]!.statusJson!);
892
+ expect(retrievedStatus.message).toBe("Status with \"quotes\" and 'apostrophes'");
893
+ expect(retrievedStatus.path).toBe("/path/with/unicode/字符/🔥");
894
+ });
895
+
896
+ test("handles concurrent-like access patterns", () => {
897
+ // Simulate rapid inserts and updates
898
+ const operations = Array.from({ length: 50 }, (_, i) => {
899
+ const id = `concurrent-${i}`;
900
+ return {
901
+ id,
902
+ name: `Concurrent ${i}`,
903
+ path: `/concurrent/${i}`,
904
+ type: "git",
905
+ };
906
+ });
907
+
908
+ // Perform all inserts
909
+ for (const op of operations) {
910
+ db.insert(schema.projects).values(op).run();
911
+ }
912
+
913
+ // Perform updates
914
+ for (let i = 0; i < 25; i++) {
915
+ db.update(schema.projects)
916
+ .set({ name: `Updated ${i}` })
917
+ .where(eq(schema.projects.id, `concurrent-${i}`))
918
+ .run();
919
+ }
920
+
921
+ // Verify results
922
+ const allProjects = db.select().from(schema.projects).all();
923
+ expect(allProjects).toHaveLength(50);
924
+
925
+ const updatedProjects = db.select().from(schema.projects)
926
+ .where(sql`${schema.projects.name} LIKE 'Updated %'`)
927
+ .all();
928
+ expect(updatedProjects).toHaveLength(25);
929
+ });
930
+ });
931
+
932
+ describe("clearCache function", () => {
933
+ test("clears all cached data", () => {
934
+ // Insert data into all tables
935
+ db.insert(schema.projects).values([
936
+ { id: "clear-1", name: "Clear 1", path: "/clear/1", type: "git" },
937
+ { id: "clear-2", name: "Clear 2", path: "/clear/2", type: "non-git" },
938
+ ]).run();
939
+
940
+ db.insert(schema.remoteStatus).values({
941
+ projectId: "clear-1",
942
+ unpulledCommits: 5,
943
+ }).run();
944
+
945
+ db.insert(schema.githubRepos).values({
946
+ id: 401,
947
+ name: "clear-repo",
948
+ fullName: "clear/clear-repo",
949
+ owner: "clear",
950
+ lastFetched: new Date(),
951
+ }).run();
952
+
953
+ db.insert(schema.configCache).values({
954
+ key: "clear_test",
955
+ value: "test_value",
956
+ updatedAt: new Date(),
957
+ }).run();
958
+
959
+ // Verify data exists
960
+ expect(db.select().from(schema.projects).all()).toHaveLength(2);
961
+ expect(db.select().from(schema.remoteStatus).all()).toHaveLength(1);
962
+ expect(db.select().from(schema.githubRepos).all()).toHaveLength(1);
963
+ expect(db.select().from(schema.configCache).all()).toHaveLength(1);
964
+
965
+ // Clear cache
966
+ db.delete(schema.projects).run();
967
+ db.delete(schema.remoteStatus).run();
968
+
969
+ // Verify projects and remote status are cleared
970
+ expect(db.select().from(schema.projects).all()).toHaveLength(0);
971
+ expect(db.select().from(schema.remoteStatus).all()).toHaveLength(0);
972
+
973
+ // Other tables remain intact
974
+ expect(db.select().from(schema.githubRepos).all()).toHaveLength(1);
975
+ expect(db.select().from(schema.configCache).all()).toHaveLength(1);
976
+ });
977
+ });
978
+ });