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,147 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { drizzle } from "drizzle-orm/bun-sqlite";
3
+ import { join } from "path";
4
+ import { homedir } from "os";
5
+ import * as schema from "./schema.ts";
6
+
7
+ /**
8
+ * Database instance type for dependency injection
9
+ */
10
+ export type DbInstance = ReturnType<typeof drizzle>;
11
+
12
+ let db: DbInstance | null = null;
13
+ let sqliteDb: Database | null = null;
14
+
15
+ /**
16
+ * Get the database file path
17
+ */
18
+ export function getDbPath(): string {
19
+ const xdg = process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
20
+ return join(xdg, "gitforest", "cache.db");
21
+ }
22
+
23
+ /**
24
+ * Initialize the database connection and create tables if needed
25
+ */
26
+ export async function initDb(): Promise<ReturnType<typeof drizzle>> {
27
+ if (db) return db;
28
+
29
+ const dbPath = getDbPath();
30
+ const dbDir = join(dbPath, "..");
31
+
32
+ // Ensure directory exists
33
+ await Bun.$`mkdir -p ${dbDir}`.quiet();
34
+
35
+ // Create SQLite connection
36
+ sqliteDb = new Database(dbPath);
37
+
38
+ // Enable WAL mode for better concurrent access
39
+ sqliteDb.exec("PRAGMA journal_mode = WAL");
40
+
41
+ // Create tables if they don't exist
42
+ sqliteDb.exec(`
43
+ CREATE TABLE IF NOT EXISTS projects (
44
+ id TEXT PRIMARY KEY,
45
+ name TEXT NOT NULL,
46
+ path TEXT NOT NULL UNIQUE,
47
+ type TEXT NOT NULL,
48
+ project_marker TEXT,
49
+ status_json TEXT,
50
+ submodule_json TEXT,
51
+ last_scanned INTEGER
52
+ );
53
+
54
+ CREATE TABLE IF NOT EXISTS remote_status (
55
+ project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
56
+ last_fetched INTEGER,
57
+ unpulled_commits INTEGER,
58
+ remote_last_activity INTEGER,
59
+ remote_url TEXT
60
+ );
61
+
62
+ CREATE TABLE IF NOT EXISTS github_repos (
63
+ id INTEGER PRIMARY KEY,
64
+ name TEXT NOT NULL,
65
+ full_name TEXT NOT NULL UNIQUE,
66
+ owner TEXT NOT NULL,
67
+ description TEXT,
68
+ html_url TEXT,
69
+ ssh_url TEXT,
70
+ clone_url TEXT,
71
+ is_private INTEGER,
72
+ is_archived INTEGER,
73
+ is_fork INTEGER,
74
+ pushed_at INTEGER,
75
+ updated_at INTEGER,
76
+ default_branch TEXT,
77
+ language TEXT,
78
+ size INTEGER,
79
+ stargazers_count INTEGER,
80
+ forks_count INTEGER,
81
+ open_issues_count INTEGER,
82
+ watchers_count INTEGER,
83
+ topics TEXT,
84
+ license TEXT,
85
+ has_issues INTEGER,
86
+ has_wiki INTEGER,
87
+ has_discussions INTEGER,
88
+ last_fetched INTEGER
89
+ );
90
+
91
+ CREATE TABLE IF NOT EXISTS config_cache (
92
+ key TEXT PRIMARY KEY,
93
+ value TEXT,
94
+ updated_at INTEGER
95
+ );
96
+
97
+ CREATE INDEX IF NOT EXISTS idx_projects_path ON projects(path);
98
+ CREATE INDEX IF NOT EXISTS idx_projects_type ON projects(type);
99
+ CREATE INDEX IF NOT EXISTS idx_github_repos_owner ON github_repos(owner);
100
+ CREATE INDEX IF NOT EXISTS idx_github_repos_fetched ON github_repos(last_fetched);
101
+ `);
102
+
103
+ // Create Drizzle instance
104
+ db = drizzle(sqliteDb, { schema });
105
+
106
+ return db;
107
+ }
108
+
109
+ /**
110
+ * Close the database connection
111
+ */
112
+ export function closeDb(): void {
113
+ if (sqliteDb) {
114
+ sqliteDb.close();
115
+ sqliteDb = null;
116
+ db = null;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Get the database instance (must call initDb first)
122
+ */
123
+ export function getDb(): ReturnType<typeof drizzle> {
124
+ if (!db) {
125
+ throw new Error("Database not initialized. Call initDb() first.");
126
+ }
127
+ return db;
128
+ }
129
+
130
+ /**
131
+ * Clear all cached data
132
+ */
133
+ export async function clearCache(): Promise<void> {
134
+ try {
135
+ const database = await initDb();
136
+
137
+ // Run both deletes in parallel for better performance
138
+ await Promise.all([
139
+ database.delete(schema.projects).run(),
140
+ database.delete(schema.remoteStatus).run()
141
+ ]);
142
+ } catch (error) {
143
+ throw new Error(`Failed to clear cache: ${error instanceof Error ? error.message : String(error)}`);
144
+ }
145
+ }
146
+
147
+ export { schema };
@@ -0,0 +1,70 @@
1
+ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
2
+
3
+ /**
4
+ * Projects table - caches scanned project information
5
+ */
6
+ export const projects = sqliteTable("projects", {
7
+ id: text("id").primaryKey(),
8
+ name: text("name").notNull(),
9
+ path: text("path").notNull().unique(),
10
+ type: text("type").notNull(), // 'git' | 'git-submodule' | 'non-git'
11
+ projectMarker: text("project_marker"), // e.g., 'package.json', 'Cargo.toml'
12
+ statusJson: text("status_json"), // JSON serialized GitStatus
13
+ submoduleJson: text("submodule_json"), // JSON serialized SubmoduleInfo
14
+ lastScanned: integer("last_scanned", { mode: "timestamp" }),
15
+ });
16
+
17
+ /**
18
+ * Remote status table - caches GitHub/remote info separately
19
+ * (updated less frequently via background fetch)
20
+ */
21
+ export const remoteStatus = sqliteTable("remote_status", {
22
+ projectId: text("project_id")
23
+ .primaryKey()
24
+ .references(() => projects.id, { onDelete: "cascade" }),
25
+ lastFetched: integer("last_fetched", { mode: "timestamp" }),
26
+ unpulledCommits: integer("unpulled_commits"),
27
+ remoteLastActivity: integer("remote_last_activity", { mode: "timestamp" }),
28
+ remoteUrl: text("remote_url"),
29
+ });
30
+
31
+ /**
32
+ * GitHub repos cache - stores fetched GitHub repository data
33
+ */
34
+ export const githubRepos = sqliteTable("github_repos", {
35
+ id: integer("id").primaryKey(),
36
+ name: text("name").notNull(),
37
+ fullName: text("full_name").notNull().unique(),
38
+ owner: text("owner").notNull(),
39
+ description: text("description"),
40
+ htmlUrl: text("html_url"),
41
+ sshUrl: text("ssh_url"),
42
+ cloneUrl: text("clone_url"),
43
+ isPrivate: integer("is_private", { mode: "boolean" }),
44
+ isArchived: integer("is_archived", { mode: "boolean" }),
45
+ isFork: integer("is_fork", { mode: "boolean" }),
46
+ pushedAt: integer("pushed_at", { mode: "timestamp" }),
47
+ updatedAt: integer("updated_at", { mode: "timestamp" }),
48
+ defaultBranch: text("default_branch"),
49
+ language: text("language"),
50
+ size: integer("size"),
51
+ stargazersCount: integer("stargazers_count"),
52
+ forksCount: integer("forks_count"),
53
+ openIssuesCount: integer("open_issues_count"),
54
+ watchersCount: integer("watchers_count"),
55
+ topics: text("topics"), // JSON array
56
+ license: text("license"), // License name or null
57
+ hasIssues: integer("has_issues", { mode: "boolean" }),
58
+ hasWiki: integer("has_wiki", { mode: "boolean" }),
59
+ hasDiscussions: integer("has_discussions", { mode: "boolean" }),
60
+ lastFetched: integer("last_fetched", { mode: "timestamp" }),
61
+ });
62
+
63
+ /**
64
+ * Config cache - stores last known config hash to detect changes
65
+ */
66
+ export const configCache = sqliteTable("config_cache", {
67
+ key: text("key").primaryKey(),
68
+ value: text("value"),
69
+ updatedAt: integer("updated_at", { mode: "timestamp" }),
70
+ });
@@ -0,0 +1,283 @@
1
+ import { $ } from "bun";
2
+ import { debugError } from "../utils/debug.ts";
3
+
4
+ /**
5
+ * Check if a directory is a git repository
6
+ */
7
+ export async function isGitRepo(path: string): Promise<boolean> {
8
+ try {
9
+ await $`git -C ${path} rev-parse --is-inside-work-tree`.quiet();
10
+ return true;
11
+ } catch (error) {
12
+ debugError("git/commands", `isGitRepo failed for ${path}`, error);
13
+ return false;
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Get the root directory of a git repository
19
+ */
20
+ export async function getGitRoot(path: string): Promise<string | null> {
21
+ try {
22
+ const result = await $`git -C ${path} rev-parse --show-toplevel`.quiet().text();
23
+ return result.trim();
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Get porcelain status output (machine-parseable)
31
+ */
32
+ export async function getGitStatusPorcelain(path: string): Promise<string> {
33
+ try {
34
+ return await $`git -C ${path} status --porcelain`.quiet().text();
35
+ } catch {
36
+ return "";
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Get the current branch name
42
+ */
43
+ export async function getCurrentBranch(path: string): Promise<string> {
44
+ try {
45
+ const result = await $`git -C ${path} rev-parse --abbrev-ref HEAD`.quiet().text();
46
+ return result.trim();
47
+ } catch {
48
+ return "unknown";
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Get the tracking branch (upstream)
54
+ */
55
+ export async function getTrackingBranch(path: string): Promise<string | null> {
56
+ try {
57
+ const result = await $`git -C ${path} rev-parse --abbrev-ref @{u}`.quiet().text();
58
+ return result.trim() || null;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Count unpushed commits
66
+ */
67
+ export async function countUnpushedCommits(path: string): Promise<number> {
68
+ try {
69
+ const result = await $`git -C ${path} rev-list --count @{u}..HEAD`.quiet().text();
70
+ return parseInt(result.trim(), 10) || 0;
71
+ } catch {
72
+ return 0;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Count unpulled commits
78
+ */
79
+ export async function countUnpulledCommits(path: string): Promise<number> {
80
+ try {
81
+ const result = await $`git -C ${path} rev-list --count HEAD..@{u}`.quiet().text();
82
+ return parseInt(result.trim(), 10) || 0;
83
+ } catch {
84
+ return 0;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Get the remote origin URL
90
+ */
91
+ export async function getRemoteUrl(path: string, remote = "origin"): Promise<string | null> {
92
+ try {
93
+ const result = await $`git -C ${path} config --get remote.${remote}.url`.quiet().text();
94
+ return result.trim() || null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Get the last commit date
102
+ */
103
+ export async function getLastCommitDate(path: string): Promise<Date | null> {
104
+ try {
105
+ const result = await $`git -C ${path} log -1 --format=%ai`.quiet().text();
106
+ const dateStr = result.trim();
107
+ if (!dateStr) return null;
108
+ return new Date(dateStr);
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Get the last commit date from a remote branch (after fetch)
116
+ */
117
+ export async function getRemoteLastCommitDate(
118
+ path: string,
119
+ remoteBranch = "origin/main"
120
+ ): Promise<Date | null> {
121
+ try {
122
+ const result = await $`git -C ${path} log -1 --format=%ai ${remoteBranch}`.quiet().text();
123
+ const dateStr = result.trim();
124
+ if (!dateStr) return null;
125
+ return new Date(dateStr);
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Initialize a new git repository
133
+ */
134
+ export async function initGit(path: string): Promise<boolean> {
135
+ try {
136
+ await $`git -C ${path} init`.quiet();
137
+ return true;
138
+ } catch (error) {
139
+ debugError("git/commands", `initGit failed for ${path}`, error);
140
+ return false;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Add a remote
146
+ */
147
+ export async function addRemote(path: string, url: string, name = "origin"): Promise<boolean> {
148
+ try {
149
+ await $`git -C ${path} remote add ${name} ${url}`.quiet();
150
+ return true;
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Fetch from remote
158
+ */
159
+ export async function fetchRemote(path: string, remote = "origin"): Promise<boolean> {
160
+ try {
161
+ await $`git -C ${path} fetch ${remote}`.quiet();
162
+ return true;
163
+ } catch {
164
+ return false;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Fetch all remotes
170
+ */
171
+ export async function fetchAll(path: string): Promise<boolean> {
172
+ try {
173
+ await $`git -C ${path} fetch --all`.quiet();
174
+ return true;
175
+ } catch (error) {
176
+ debugError("git/commands", `fetchAll failed for ${path}`, error);
177
+ return false;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Pull from remote (fast-forward only)
183
+ */
184
+ export async function pull(path: string): Promise<boolean> {
185
+ try {
186
+ await $`git -C ${path} pull --ff-only`.quiet();
187
+ return true;
188
+ } catch (error) {
189
+ debugError("git/commands", `pull failed for ${path}`, error);
190
+ return false;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Push to remote
196
+ */
197
+ export async function push(path: string, setUpstream = false): Promise<boolean> {
198
+ try {
199
+ if (setUpstream) {
200
+ const branch = await getCurrentBranch(path);
201
+ await $`git -C ${path} push -u origin ${branch}`.quiet();
202
+ } else {
203
+ await $`git -C ${path} push`.quiet();
204
+ }
205
+ return true;
206
+ } catch (error) {
207
+ debugError("git/commands", `push failed for ${path}`, error);
208
+ return false;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * List submodules with their status
214
+ */
215
+ export async function listSubmodules(path: string): Promise<
216
+ Array<{
217
+ path: string;
218
+ commit: string;
219
+ status: "-" | "+" | " " | "U";
220
+ }>
221
+ > {
222
+ try {
223
+ const result = await $`git -C ${path} submodule status`.quiet().text();
224
+ const lines = result.trim().split("\n").filter(Boolean);
225
+
226
+ return lines.map((line) => {
227
+ // Format: [-+U ]<commit> <path> [(describe)]
228
+ const statusChar = line[0] as "-" | "+" | " " | "U";
229
+ const rest = line.slice(1).trim();
230
+ const parts = rest.split(" ");
231
+ const commit = parts[0] || "";
232
+ const subPath = parts[1] || "";
233
+
234
+ return {
235
+ path: subPath,
236
+ commit: commit,
237
+ status: statusChar,
238
+ };
239
+ });
240
+ } catch {
241
+ return [];
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Check if path is a submodule
247
+ */
248
+ export async function isSubmodule(path: string): Promise<boolean> {
249
+ try {
250
+ const result = await $`git -C ${path} rev-parse --show-superproject-working-tree`
251
+ .quiet()
252
+ .text();
253
+ return result.trim().length > 0;
254
+ } catch {
255
+ return false;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Get the parent repository path for a submodule
261
+ */
262
+ export async function getSubmoduleParent(path: string): Promise<string | null> {
263
+ try {
264
+ const result = await $`git -C ${path} rev-parse --show-superproject-working-tree`
265
+ .quiet()
266
+ .text();
267
+ return result.trim() || null;
268
+ } catch {
269
+ return null;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Check if repository has any commits
275
+ */
276
+ export async function hasCommits(path: string): Promise<boolean> {
277
+ try {
278
+ const result = await $`git -C ${path} rev-parse HEAD`.quiet();
279
+ return result.exitCode === 0;
280
+ } catch {
281
+ return false;
282
+ }
283
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./types.js";
2
+ export * from "./service.js";
@@ -0,0 +1,93 @@
1
+ import type { OperationResult, BatchResult, Project } from "../types/index.ts";
2
+ import type { GitService } from "../services/git.ts";
3
+ import { bunGitService } from "../services/git.ts";
4
+ import { getGitStatus } from "./status.ts";
5
+ import { errorToString } from "../utils/errors.ts";
6
+ import { chunk } from "../utils/array.ts";
7
+
8
+ /**
9
+ * Initialize git in a directory
10
+ */
11
+ export async function initGitInProject(
12
+ path: string,
13
+ gitService: GitService = bunGitService
14
+ ): Promise<OperationResult> {
15
+ return await gitService.init(path);
16
+ }
17
+
18
+ /**
19
+ * Pull changes for a single project
20
+ */
21
+ export async function pullProject(
22
+ path: string,
23
+ gitService: GitService = bunGitService
24
+ ): Promise<OperationResult> {
25
+ return await gitService.pull(path);
26
+ }
27
+
28
+ /**
29
+ * Push changes for a single project
30
+ */
31
+ export async function pushProject(
32
+ path: string,
33
+ setUpstream = false,
34
+ gitService: GitService = bunGitService
35
+ ): Promise<OperationResult> {
36
+ return await gitService.push(path, setUpstream);
37
+ }
38
+
39
+ /**
40
+ * Fetch all remotes for a single project
41
+ */
42
+ export async function fetchProject(
43
+ path: string,
44
+ gitService: GitService = bunGitService
45
+ ): Promise<OperationResult> {
46
+ return await gitService.fetchAll(path);
47
+ }
48
+
49
+ /**
50
+ * Add a remote to a project
51
+ */
52
+ export async function addRemoteToProject(
53
+ path: string,
54
+ url: string,
55
+ name = "origin",
56
+ gitService: GitService = bunGitService
57
+ ): Promise<OperationResult> {
58
+ return await gitService.addRemote(path, url, name);
59
+ }
60
+
61
+
62
+ /**
63
+ * Refresh status for multiple projects
64
+ */
65
+ export async function refreshProjectStatuses(
66
+ projects: Project[],
67
+ concurrency = 10,
68
+ gitService: GitService = bunGitService
69
+ ): Promise<Map<string, Project>> {
70
+ const gitProjects = projects.filter((p) => p.type !== "non-git");
71
+ const results = new Map<string, Project>();
72
+
73
+ const batches = chunk(gitProjects, concurrency);
74
+
75
+ for (const batch of batches) {
76
+ const updates = await Promise.all(
77
+ batch.map(async (p) => {
78
+ try {
79
+ const status = await getGitStatus(p.path, gitService);
80
+ return { ...p, status, lastScanned: new Date() };
81
+ } catch {
82
+ return p;
83
+ }
84
+ })
85
+ );
86
+
87
+ for (const p of updates) {
88
+ results.set(p.id, p);
89
+ }
90
+ }
91
+
92
+ return results;
93
+ }