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