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.
- package/.bunignore +7 -0
- package/.github/workflows/ci.yml +73 -0
- package/CLAUDE.md +111 -0
- package/CONTRIBUTING.md +145 -0
- package/README.md +168 -0
- package/bun.lock +267 -0
- package/bunfig.toml +15 -0
- package/cli +0 -0
- package/config/gitforest.example.yaml +94 -0
- package/docs/ai/IMPROVEMENT_PLAN.md +341 -0
- package/docs/ai/VERIFICATION_REPORT.md +87 -0
- package/docs/ai/architecture.md +169 -0
- package/docs/ai/checks/check-2025-12-02-tests.md +40 -0
- package/docs/ai/checks/check-2025-12-02.md +55 -0
- package/docs/ai/checks/test-verification-report.md +85 -0
- package/docs/ai/implementation-guide.md +776 -0
- package/docs/ai/research/gitty-codebase-analysis.md +221 -0
- package/docs/ai/tickets/GENERAL-sitrep.md +30 -0
- package/docs/ai/tickets/TASK-database-tests-sitrep.md +25 -0
- package/docs/ai/tickets/TASK-deprecated-functions-sitrep.md +28 -0
- package/docs/ai/tickets/TASK-detail-modal-sitrep.md +28 -0
- package/docs/ai/tickets/TASK-filter-overlay-sitrep.md +24 -0
- package/docs/ai/tickets/TASK-github-service-sitrep.md +32 -0
- package/docs/ai/tickets/TASK-github-token-sitrep.md +51 -0
- package/docs/ai/tickets/TASK-hascommits-sitrep.md +35 -0
- package/docs/ai/tickets/TASK-keybindings-sitrep.md +26 -0
- package/docs/ai/tickets/TASK-layout-sitrep.md +25 -0
- package/docs/ai/tickets/TASK-markdown-sitrep.md +28 -0
- package/docs/ai/tickets/TASK-project-item-sitrep.md +79 -0
- package/docs/ai/tickets/TASK-sitrep.md +28 -0
- package/docs/ai/tickets/TASK-state-sitrep.md +26 -0
- package/docs/ai/tickets/TASK-types-sitrep.md +25 -0
- package/docs/ai/tickets/TASK-unified-item-fix-sitrep.md +26 -0
- package/docs/ai/tickets/TKT-001-sitrep.md +24 -0
- package/docs/ai/tickets/TKT-002-sitrep.md +25 -0
- package/docs/ai/tickets/TKT-003-git-service-refactoring-complete.md +46 -0
- package/docs/ai/tickets/TKT-003-git-service-refactoring-plan.md +135 -0
- package/docs/ai/tickets/TKT-003-sitrep.md +26 -0
- package/docs/ai/tickets/TKT-004-sitrep.md +27 -0
- package/docs/ai/tickets/TKT-005-sitrep.md +25 -0
- package/docs/ai/tickets/TKT-006-sitrep.md +26 -0
- package/docs/ai/tickets/TKT-007-sitrep.md +30 -0
- package/docs/ai/tickets/TKT-008-sitrep.md +32 -0
- package/docs/ai/tickets/TKT-009-sitrep.md +27 -0
- package/docs/ai/tickets/TKT-010-sitrep.md +27 -0
- package/docs/ai/tickets/TKT-011-sitrep.md +26 -0
- package/docs/ai/tickets/TKT-012-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-actions-sitrep.md +28 -0
- package/docs/ai/tickets/sitreps/TASK-actions-test-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-app-integration-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-background-fetch-sitrep.md +24 -0
- package/docs/ai/tickets/sitreps/TASK-background-fetch-test-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-batch-tests-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-bun-test-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-cache-tests-sitrep.md +30 -0
- package/docs/ai/tickets/sitreps/TASK-cli-tests-sitrep.md +28 -0
- package/docs/ai/tickets/sitreps/TASK-clone-error-handling-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-commands-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-component-tests-1-sitrep.md +30 -0
- package/docs/ai/tickets/sitreps/TASK-configloader-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-confirm-dialog-test-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-coverage-sitrep.md +95 -0
- package/docs/ai/tickets/sitreps/TASK-database-tests-summary.md +61 -0
- package/docs/ai/tickets/sitreps/TASK-error-boundary-sitrep.md +30 -0
- package/docs/ai/tickets/sitreps/TASK-error-tests-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-errors-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-extract-reducer-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-filter-overlay-test-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-final-verification-sitrep.md +28 -0
- package/docs/ai/tickets/sitreps/TASK-fix-all-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-fix-hooks-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-fix-remaining-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-fix-test-failures-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-fix-tests-sitrep.md +24 -0
- package/docs/ai/tickets/sitreps/TASK-formatters-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-git-timeouts-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-github-cache-test-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-githubcli-tests-sitrep.md +24 -0
- package/docs/ai/tickets/sitreps/TASK-gitstatus-tests-sitrep.md +24 -0
- package/docs/ai/tickets/sitreps/TASK-hooks-isolation-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-keybindings-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-layout-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-mock-factories-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-modal-tests-sitrep.md +32 -0
- package/docs/ai/tickets/sitreps/TASK-processbatch-fix-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-projectlist-tests-sitrep.md +30 -0
- package/docs/ai/tickets/sitreps/TASK-projectutils-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-scanner-tests-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-select-all-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-shell-error-handling-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-store-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-test-fixes-sitrep.md +26 -0
- package/docs/ai/tickets/sitreps/TASK-test-summary-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-test-verification-sitrep.md +27 -0
- package/docs/ai/tickets/sitreps/TASK-testsuite-sitrep.md +75 -0
- package/docs/ai/tickets/sitreps/TASK-unified-reducer-tests-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-unified-repos-test-sitrep.md +29 -0
- package/docs/ai/tickets/sitreps/TASK-unified-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-useprojects-tests-sitrep.md +25 -0
- package/docs/ai/tickets/sitreps/TASK-utility-tests-sitrep.md +32 -0
- package/docs/ai/tickets/sitreps/TKT-003-git-service-refactoring-sitrep.md +64 -0
- package/docs/ai/tkt-001-fix-database-error.md +217 -0
- package/docs/ai/ui-enhancement-plan.md +562 -0
- package/package.json +50 -0
- package/src/app.tsx +43 -0
- package/src/cli/config.ts +94 -0
- package/src/cli/formatters.ts +632 -0
- package/src/cli/index.ts +583 -0
- package/src/components/CloneDialog.tsx +137 -0
- package/src/components/ColumnHeader.tsx +128 -0
- package/src/components/CommandPalette.tsx +120 -0
- package/src/components/ConfirmDialog.tsx +105 -0
- package/src/components/ErrorBoundary.tsx +128 -0
- package/src/components/FilterBar.tsx +71 -0
- package/src/components/FilterOptionsOverlay.tsx +131 -0
- package/src/components/HelpOverlay.tsx +120 -0
- package/src/components/Layout.tsx +379 -0
- package/src/components/MarkdownRenderer.tsx +127 -0
- package/src/components/ProgressBar.tsx +53 -0
- package/src/components/ProjectItem.tsx +143 -0
- package/src/components/ProjectList.tsx +90 -0
- package/src/components/RepoDetailModal.tsx +367 -0
- package/src/components/StatusBar.tsx +188 -0
- package/src/components/UnifiedProjectItem.tsx +436 -0
- package/src/components/ViewModeIndicator.tsx +37 -0
- package/src/components/onboarding/CompleteStep.tsx +82 -0
- package/src/components/onboarding/DirectoriesStep.test.tsx +52 -0
- package/src/components/onboarding/DirectoriesStep.tsx +847 -0
- package/src/components/onboarding/DirectoriesStep.unit.test.ts +345 -0
- package/src/components/onboarding/GitHubAuthStep.tsx +268 -0
- package/src/components/onboarding/OnboardingWizard.tsx +130 -0
- package/src/components/onboarding/WelcomeStep.tsx +69 -0
- package/src/config/loader.ts +263 -0
- package/src/config/onboarding.ts +67 -0
- package/src/constants.ts +96 -0
- package/src/db/index.ts +147 -0
- package/src/db/schema.ts +70 -0
- package/src/git/commands.ts +283 -0
- package/src/git/index.ts +2 -0
- package/src/git/operations.ts +93 -0
- package/src/git/service.ts +539 -0
- package/src/git/status.ts +84 -0
- package/src/git/types.ts +5 -0
- package/src/github/auth.ts +311 -0
- package/src/github/cache.ts +231 -0
- package/src/github/cli.ts +22 -0
- package/src/github/unified.ts +415 -0
- package/src/hooks/useBackgroundFetch.ts +76 -0
- package/src/hooks/useConfirmDialogActions.ts +120 -0
- package/src/hooks/useKeyBindings.ts +656 -0
- package/src/hooks/useProjects.ts +47 -0
- package/src/hooks/useUnifiedRepos.ts +317 -0
- package/src/index.tsx +494 -0
- package/src/operations/batch.ts +280 -0
- package/src/operations/commands.ts +140 -0
- package/src/operations/index.ts +37 -0
- package/src/scanner/index.ts +424 -0
- package/src/scanner/markers.ts +43 -0
- package/src/scanner/submodules.ts +61 -0
- package/src/services/git.ts +484 -0
- package/src/services/github.ts +676 -0
- package/src/services/index.ts +28 -0
- package/src/services/types.ts +99 -0
- package/src/state/actions.ts +175 -0
- package/src/state/reducer.ts +294 -0
- package/src/state/store.tsx +216 -0
- package/src/state/types.ts +8 -0
- package/src/types/index.ts +383 -0
- package/src/ui/theme.ts +44 -0
- package/src/utils/array.ts +14 -0
- package/src/utils/debug.ts +38 -0
- package/src/utils/errors.ts +17 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/markdown.ts +230 -0
- package/src/utils/project-utils.ts +129 -0
- package/src/utils/rate-limiter.ts +134 -0
- package/src/utils/retry.ts +147 -0
- package/src/utils/timeout.ts +56 -0
- package/test/integration/app.isolated.tsx +240 -0
- package/test/integration/cli-commands.test.ts +287 -0
- package/test/integration/cli-validation.test.ts +264 -0
- package/test/integration/git-operations.test.ts +218 -0
- package/test/integration/scanner.test.ts +228 -0
- package/test/preload.ts +18 -0
- package/test/unit/cli/commands.test.ts +13 -0
- package/test/unit/cli/formatters.test.ts +1116 -0
- package/test/unit/cli/github-commands.test.ts +12 -0
- package/test/unit/components/CloneDialog.test.tsx +240 -0
- package/test/unit/components/ColumnHeader.test.tsx +128 -0
- package/test/unit/components/CommandPalette.test.tsx +355 -0
- package/test/unit/components/ConfirmDialog.test.tsx +111 -0
- package/test/unit/components/ErrorBoundary.test.tsx +139 -0
- package/test/unit/components/FilterBar.test.tsx +43 -0
- package/test/unit/components/FilterOptionsOverlay.test.tsx +197 -0
- package/test/unit/components/HelpOverlay.test.tsx +90 -0
- package/test/unit/components/Layout.test.tsx +328 -0
- package/test/unit/components/MarkdownRenderer.test.tsx +45 -0
- package/test/unit/components/ProgressBar.test.tsx +138 -0
- package/test/unit/components/ProjectItem.test.tsx +182 -0
- package/test/unit/components/ProjectList.test.tsx +311 -0
- package/test/unit/components/RepoDetailModal.test.tsx +445 -0
- package/test/unit/components/StatusBar.test.tsx +112 -0
- package/test/unit/components/UnifiedProjectItem.test.tsx +618 -0
- package/test/unit/components/ViewModeIndicator.test.tsx +137 -0
- package/test/unit/components/test-utils.tsx +63 -0
- package/test/unit/config/loader.test.ts +692 -0
- package/test/unit/db/database.test.ts +978 -0
- package/test/unit/db/index.test.ts +314 -0
- package/test/unit/fixtures/setup.ts +186 -0
- package/test/unit/git/commands-untested.test.ts +205 -0
- package/test/unit/git/commands.test.ts +269 -0
- package/test/unit/git/operations.test.ts +322 -0
- package/test/unit/git/status.test.ts +219 -0
- package/test/unit/github/auth.test.ts +317 -0
- package/test/unit/github/cache.test.ts +1028 -0
- package/test/unit/github/cli.test.ts +135 -0
- package/test/unit/github/unified.test.ts +1201 -0
- package/test/unit/graceful-shutdown.test.ts +83 -0
- package/test/unit/hooks/useBackgroundFetch.test.tsx +239 -0
- package/test/unit/hooks/useConfirmDialogActions.test.tsx +81 -0
- package/test/unit/hooks/useKeyBindings.isolated.ts +715 -0
- package/test/unit/hooks/useProjects.test.tsx +186 -0
- package/test/unit/hooks/useUnifiedRepos-simple.test.tsx +115 -0
- package/test/unit/hooks/useUnifiedRepos.test.tsx +177 -0
- package/test/unit/mocks/config.ts +109 -0
- package/test/unit/mocks/git-service.ts +274 -0
- package/test/unit/mocks/github-service.ts +250 -0
- package/test/unit/mocks/index.ts +72 -0
- package/test/unit/mocks/project.ts +148 -0
- package/test/unit/mocks/state-mocks.ts +187 -0
- package/test/unit/mocks/unified.ts +169 -0
- package/test/unit/operations/batch.test.ts +216 -0
- package/test/unit/operations/commands.test.ts +550 -0
- package/test/unit/scanner/errors.test.ts +297 -0
- package/test/unit/scanner/index.test.ts +1011 -0
- package/test/unit/scanner/markers.test.ts +150 -0
- package/test/unit/scanner/submodules.test.ts +99 -0
- package/test/unit/services/git-errors.test.ts +190 -0
- package/test/unit/services/git.test.ts +442 -0
- package/test/unit/services/github-errors.test.ts +293 -0
- package/test/unit/services/github.test.ts +200 -0
- package/test/unit/state/actions.test.ts +217 -0
- package/test/unit/state/reducer.test.ts +745 -0
- package/test/unit/state/store.test.tsx +711 -0
- package/test/unit/types/commands.test.ts +220 -0
- package/test/unit/types/schema.test.ts +179 -0
- package/test/unit/utils/array.test.ts +73 -0
- package/test/unit/utils/debug.test.ts +23 -0
- package/test/unit/utils/errors.test.ts +295 -0
- package/test/unit/utils/markdown.test.ts +163 -0
- package/test/unit/utils/project-utils.test.ts +756 -0
- package/test/unit/utils/rate-limiter.test.ts +256 -0
- package/test/unit/utils/retry.test.ts +165 -0
- package/test/unit/utils/strip-ansi.ts +13 -0
- package/test/unit/utils/timeout.test.ts +93 -0
- package/tsconfig.json +29 -0
package/src/db/index.ts
ADDED
|
@@ -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 };
|
package/src/db/schema.ts
ADDED
|
@@ -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
|
+
}
|
package/src/git/index.ts
ADDED
|
@@ -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
|
+
}
|