gitforest 0.1.0 → 1.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/LICENSE +21 -0
- package/package.json +24 -4
- package/src/components/onboarding/DirectoriesStep.tsx +19 -19
- package/src/github/auth.ts +3 -3
- package/src/utils/debug.ts +4 -4
- package/.bunignore +0 -7
- package/.github/workflows/ci.yml +0 -73
- package/CLAUDE.md +0 -111
- package/CONTRIBUTING.md +0 -145
- package/bun.lock +0 -267
- package/bunfig.toml +0 -15
- package/cli +0 -0
- package/docs/ai/IMPROVEMENT_PLAN.md +0 -341
- package/docs/ai/VERIFICATION_REPORT.md +0 -87
- package/docs/ai/architecture.md +0 -169
- package/docs/ai/checks/check-2025-12-02-tests.md +0 -40
- package/docs/ai/checks/check-2025-12-02.md +0 -55
- package/docs/ai/checks/test-verification-report.md +0 -85
- package/docs/ai/implementation-guide.md +0 -776
- package/docs/ai/research/gitty-codebase-analysis.md +0 -221
- package/docs/ai/tickets/GENERAL-sitrep.md +0 -30
- package/docs/ai/tickets/TASK-database-tests-sitrep.md +0 -25
- package/docs/ai/tickets/TASK-deprecated-functions-sitrep.md +0 -28
- package/docs/ai/tickets/TASK-detail-modal-sitrep.md +0 -28
- package/docs/ai/tickets/TASK-filter-overlay-sitrep.md +0 -24
- package/docs/ai/tickets/TASK-github-service-sitrep.md +0 -32
- package/docs/ai/tickets/TASK-github-token-sitrep.md +0 -51
- package/docs/ai/tickets/TASK-hascommits-sitrep.md +0 -35
- package/docs/ai/tickets/TASK-keybindings-sitrep.md +0 -26
- package/docs/ai/tickets/TASK-layout-sitrep.md +0 -25
- package/docs/ai/tickets/TASK-markdown-sitrep.md +0 -28
- package/docs/ai/tickets/TASK-project-item-sitrep.md +0 -79
- package/docs/ai/tickets/TASK-sitrep.md +0 -28
- package/docs/ai/tickets/TASK-state-sitrep.md +0 -26
- package/docs/ai/tickets/TASK-types-sitrep.md +0 -25
- package/docs/ai/tickets/TASK-unified-item-fix-sitrep.md +0 -26
- package/docs/ai/tickets/TKT-001-sitrep.md +0 -24
- package/docs/ai/tickets/TKT-002-sitrep.md +0 -25
- package/docs/ai/tickets/TKT-003-git-service-refactoring-complete.md +0 -46
- package/docs/ai/tickets/TKT-003-git-service-refactoring-plan.md +0 -135
- package/docs/ai/tickets/TKT-003-sitrep.md +0 -26
- package/docs/ai/tickets/TKT-004-sitrep.md +0 -27
- package/docs/ai/tickets/TKT-005-sitrep.md +0 -25
- package/docs/ai/tickets/TKT-006-sitrep.md +0 -26
- package/docs/ai/tickets/TKT-007-sitrep.md +0 -30
- package/docs/ai/tickets/TKT-008-sitrep.md +0 -32
- package/docs/ai/tickets/TKT-009-sitrep.md +0 -27
- package/docs/ai/tickets/TKT-010-sitrep.md +0 -27
- package/docs/ai/tickets/TKT-011-sitrep.md +0 -26
- package/docs/ai/tickets/TKT-012-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-actions-sitrep.md +0 -28
- package/docs/ai/tickets/sitreps/TASK-actions-test-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-app-integration-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-background-fetch-sitrep.md +0 -24
- package/docs/ai/tickets/sitreps/TASK-background-fetch-test-sitrep.md +0 -29
- package/docs/ai/tickets/sitreps/TASK-batch-tests-sitrep.md +0 -29
- package/docs/ai/tickets/sitreps/TASK-bun-test-sitrep.md +0 -26
- package/docs/ai/tickets/sitreps/TASK-cache-tests-sitrep.md +0 -30
- package/docs/ai/tickets/sitreps/TASK-cli-tests-sitrep.md +0 -28
- package/docs/ai/tickets/sitreps/TASK-clone-error-handling-sitrep.md +0 -26
- package/docs/ai/tickets/sitreps/TASK-commands-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-component-tests-1-sitrep.md +0 -30
- package/docs/ai/tickets/sitreps/TASK-configloader-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-confirm-dialog-test-sitrep.md +0 -29
- package/docs/ai/tickets/sitreps/TASK-coverage-sitrep.md +0 -95
- package/docs/ai/tickets/sitreps/TASK-database-tests-summary.md +0 -61
- package/docs/ai/tickets/sitreps/TASK-error-boundary-sitrep.md +0 -30
- package/docs/ai/tickets/sitreps/TASK-error-tests-sitrep.md +0 -27
- package/docs/ai/tickets/sitreps/TASK-errors-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-extract-reducer-sitrep.md +0 -27
- package/docs/ai/tickets/sitreps/TASK-filter-overlay-test-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-final-verification-sitrep.md +0 -28
- package/docs/ai/tickets/sitreps/TASK-fix-all-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-fix-hooks-sitrep.md +0 -26
- package/docs/ai/tickets/sitreps/TASK-fix-remaining-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-fix-test-failures-sitrep.md +0 -26
- package/docs/ai/tickets/sitreps/TASK-fix-tests-sitrep.md +0 -24
- package/docs/ai/tickets/sitreps/TASK-formatters-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-git-timeouts-sitrep.md +0 -29
- package/docs/ai/tickets/sitreps/TASK-github-cache-test-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-githubcli-tests-sitrep.md +0 -24
- package/docs/ai/tickets/sitreps/TASK-gitstatus-tests-sitrep.md +0 -24
- package/docs/ai/tickets/sitreps/TASK-hooks-isolation-sitrep.md +0 -27
- package/docs/ai/tickets/sitreps/TASK-keybindings-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-layout-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-mock-factories-sitrep.md +0 -27
- package/docs/ai/tickets/sitreps/TASK-modal-tests-sitrep.md +0 -32
- package/docs/ai/tickets/sitreps/TASK-processbatch-fix-sitrep.md +0 -27
- package/docs/ai/tickets/sitreps/TASK-projectlist-tests-sitrep.md +0 -30
- package/docs/ai/tickets/sitreps/TASK-projectutils-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-scanner-tests-sitrep.md +0 -29
- package/docs/ai/tickets/sitreps/TASK-select-all-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-shell-error-handling-sitrep.md +0 -27
- package/docs/ai/tickets/sitreps/TASK-store-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-test-fixes-sitrep.md +0 -26
- package/docs/ai/tickets/sitreps/TASK-test-summary-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-test-verification-sitrep.md +0 -27
- package/docs/ai/tickets/sitreps/TASK-testsuite-sitrep.md +0 -75
- package/docs/ai/tickets/sitreps/TASK-unified-reducer-tests-sitrep.md +0 -29
- package/docs/ai/tickets/sitreps/TASK-unified-repos-test-sitrep.md +0 -29
- package/docs/ai/tickets/sitreps/TASK-unified-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-useprojects-tests-sitrep.md +0 -25
- package/docs/ai/tickets/sitreps/TASK-utility-tests-sitrep.md +0 -32
- package/docs/ai/tickets/sitreps/TKT-003-git-service-refactoring-sitrep.md +0 -64
- package/docs/ai/tkt-001-fix-database-error.md +0 -217
- package/docs/ai/ui-enhancement-plan.md +0 -562
- package/test/integration/app.isolated.tsx +0 -240
- package/test/integration/cli-commands.test.ts +0 -287
- package/test/integration/cli-validation.test.ts +0 -264
- package/test/integration/git-operations.test.ts +0 -218
- package/test/integration/scanner.test.ts +0 -228
- package/test/preload.ts +0 -18
- package/test/unit/cli/commands.test.ts +0 -13
- package/test/unit/cli/formatters.test.ts +0 -1116
- package/test/unit/cli/github-commands.test.ts +0 -12
- package/test/unit/components/CloneDialog.test.tsx +0 -240
- package/test/unit/components/ColumnHeader.test.tsx +0 -128
- package/test/unit/components/CommandPalette.test.tsx +0 -355
- package/test/unit/components/ConfirmDialog.test.tsx +0 -111
- package/test/unit/components/ErrorBoundary.test.tsx +0 -139
- package/test/unit/components/FilterBar.test.tsx +0 -43
- package/test/unit/components/FilterOptionsOverlay.test.tsx +0 -197
- package/test/unit/components/HelpOverlay.test.tsx +0 -90
- package/test/unit/components/Layout.test.tsx +0 -328
- package/test/unit/components/MarkdownRenderer.test.tsx +0 -45
- package/test/unit/components/ProgressBar.test.tsx +0 -138
- package/test/unit/components/ProjectItem.test.tsx +0 -182
- package/test/unit/components/ProjectList.test.tsx +0 -311
- package/test/unit/components/RepoDetailModal.test.tsx +0 -445
- package/test/unit/components/StatusBar.test.tsx +0 -112
- package/test/unit/components/UnifiedProjectItem.test.tsx +0 -618
- package/test/unit/components/ViewModeIndicator.test.tsx +0 -137
- package/test/unit/components/test-utils.tsx +0 -63
- package/test/unit/config/loader.test.ts +0 -692
- package/test/unit/db/database.test.ts +0 -978
- package/test/unit/db/index.test.ts +0 -314
- package/test/unit/fixtures/setup.ts +0 -186
- package/test/unit/git/commands-untested.test.ts +0 -205
- package/test/unit/git/commands.test.ts +0 -269
- package/test/unit/git/operations.test.ts +0 -322
- package/test/unit/git/status.test.ts +0 -219
- package/test/unit/github/auth.test.ts +0 -317
- package/test/unit/github/cache.test.ts +0 -1028
- package/test/unit/github/cli.test.ts +0 -135
- package/test/unit/github/unified.test.ts +0 -1201
- package/test/unit/graceful-shutdown.test.ts +0 -83
- package/test/unit/hooks/useBackgroundFetch.test.tsx +0 -239
- package/test/unit/hooks/useConfirmDialogActions.test.tsx +0 -81
- package/test/unit/hooks/useKeyBindings.isolated.ts +0 -715
- package/test/unit/hooks/useProjects.test.tsx +0 -186
- package/test/unit/hooks/useUnifiedRepos-simple.test.tsx +0 -115
- package/test/unit/hooks/useUnifiedRepos.test.tsx +0 -177
- package/test/unit/mocks/config.ts +0 -109
- package/test/unit/mocks/git-service.ts +0 -274
- package/test/unit/mocks/github-service.ts +0 -250
- package/test/unit/mocks/index.ts +0 -72
- package/test/unit/mocks/project.ts +0 -148
- package/test/unit/mocks/state-mocks.ts +0 -187
- package/test/unit/mocks/unified.ts +0 -169
- package/test/unit/operations/batch.test.ts +0 -216
- package/test/unit/operations/commands.test.ts +0 -550
- package/test/unit/scanner/errors.test.ts +0 -297
- package/test/unit/scanner/index.test.ts +0 -1011
- package/test/unit/scanner/markers.test.ts +0 -150
- package/test/unit/scanner/submodules.test.ts +0 -99
- package/test/unit/services/git-errors.test.ts +0 -190
- package/test/unit/services/git.test.ts +0 -442
- package/test/unit/services/github-errors.test.ts +0 -293
- package/test/unit/services/github.test.ts +0 -200
- package/test/unit/state/actions.test.ts +0 -217
- package/test/unit/state/reducer.test.ts +0 -745
- package/test/unit/state/store.test.tsx +0 -711
- package/test/unit/types/commands.test.ts +0 -220
- package/test/unit/types/schema.test.ts +0 -179
- package/test/unit/utils/array.test.ts +0 -73
- package/test/unit/utils/debug.test.ts +0 -23
- package/test/unit/utils/errors.test.ts +0 -295
- package/test/unit/utils/markdown.test.ts +0 -163
- package/test/unit/utils/project-utils.test.ts +0 -756
- package/test/unit/utils/rate-limiter.test.ts +0 -256
- package/test/unit/utils/retry.test.ts +0 -165
- package/test/unit/utils/strip-ansi.ts +0 -13
- package/test/unit/utils/timeout.test.ts +0 -93
- package/tsconfig.json +0 -29
|
@@ -1,1011 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
scanAllDirectories,
|
|
4
|
-
scanWithCache,
|
|
5
|
-
clearCache,
|
|
6
|
-
getCacheStats,
|
|
7
|
-
sortProjects,
|
|
8
|
-
filterProjects,
|
|
9
|
-
} from "../../../src/scanner/index.ts";
|
|
10
|
-
import { closeDb, initDb, getDbPath } from "../../../src/db/index.ts";
|
|
11
|
-
import type { Project, GitforestConfig } from "../../../src/types/index.ts";
|
|
12
|
-
import {
|
|
13
|
-
createTempDir,
|
|
14
|
-
cleanupTempDir,
|
|
15
|
-
createMockGitRepo,
|
|
16
|
-
createMockProject,
|
|
17
|
-
createMockRepoWithSubmodule,
|
|
18
|
-
} from "../fixtures/setup.ts";
|
|
19
|
-
import { createMockConfig } from "../mocks/config.ts";
|
|
20
|
-
import { mkdir } from "fs/promises";
|
|
21
|
-
import { join } from "path";
|
|
22
|
-
|
|
23
|
-
function createTestConfig(tempDir: string, overrides?: Partial<GitforestConfig>): GitforestConfig {
|
|
24
|
-
return createMockConfig({
|
|
25
|
-
directories: [{ path: tempDir, maxDepth: 2 }],
|
|
26
|
-
...overrides,
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
describe("scanAllDirectories", () => {
|
|
31
|
-
let tempDir: string;
|
|
32
|
-
|
|
33
|
-
beforeEach(() => {
|
|
34
|
-
tempDir = createTempDir("scanner-test");
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
afterEach(() => {
|
|
38
|
-
cleanupTempDir(tempDir);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("finds git repositories", async () => {
|
|
42
|
-
await createMockGitRepo(tempDir, "repo1");
|
|
43
|
-
await createMockGitRepo(tempDir, "repo2");
|
|
44
|
-
|
|
45
|
-
const config = createTestConfig(tempDir);
|
|
46
|
-
const projects = await scanAllDirectories(config);
|
|
47
|
-
|
|
48
|
-
expect(projects.length).toBeGreaterThanOrEqual(2);
|
|
49
|
-
const gitProjects = projects.filter((p) => p.type === "git");
|
|
50
|
-
expect(gitProjects.length).toBeGreaterThanOrEqual(2);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("finds non-git projects with markers", async () => {
|
|
54
|
-
createMockProject(tempDir, "node-project", "package.json");
|
|
55
|
-
createMockProject(tempDir, "rust-project", "Cargo.toml");
|
|
56
|
-
|
|
57
|
-
const config = createTestConfig(tempDir);
|
|
58
|
-
const projects = await scanAllDirectories(config);
|
|
59
|
-
|
|
60
|
-
const nonGitProjects = projects.filter((p) => p.type === "non-git");
|
|
61
|
-
expect(nonGitProjects.length).toBeGreaterThanOrEqual(2);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("finds mixed projects", async () => {
|
|
65
|
-
await createMockGitRepo(tempDir, "git-repo");
|
|
66
|
-
createMockProject(tempDir, "node-project", "package.json");
|
|
67
|
-
|
|
68
|
-
const config = createTestConfig(tempDir);
|
|
69
|
-
const projects = await scanAllDirectories(config);
|
|
70
|
-
|
|
71
|
-
expect(projects.some((p) => p.type === "git")).toBe(true);
|
|
72
|
-
expect(projects.some((p) => p.type === "non-git")).toBe(true);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("respects maxDepth", async () => {
|
|
76
|
-
const { mkdirSync } = await import("fs");
|
|
77
|
-
const { join } = await import("path");
|
|
78
|
-
|
|
79
|
-
// Create nested structure
|
|
80
|
-
const level1 = join(tempDir, "level1");
|
|
81
|
-
const level2 = join(level1, "level2");
|
|
82
|
-
const level3 = join(level2, "level3");
|
|
83
|
-
mkdirSync(level3, { recursive: true });
|
|
84
|
-
|
|
85
|
-
await createMockGitRepo(level3, "deep-repo");
|
|
86
|
-
|
|
87
|
-
// With maxDepth 2, shouldn't find level3 repo
|
|
88
|
-
const config = createTestConfig(tempDir, {
|
|
89
|
-
directories: [{ path: tempDir, maxDepth: 2 }],
|
|
90
|
-
});
|
|
91
|
-
const projects = await scanAllDirectories(config);
|
|
92
|
-
|
|
93
|
-
const deepRepo = projects.find((p) => p.name === "deep-repo");
|
|
94
|
-
expect(deepRepo).toBeUndefined();
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("respects ignore patterns", async () => {
|
|
98
|
-
const { mkdirSync } = await import("fs");
|
|
99
|
-
const { join } = await import("path");
|
|
100
|
-
|
|
101
|
-
const nodeModulesDir = join(tempDir, "node_modules");
|
|
102
|
-
mkdirSync(nodeModulesDir);
|
|
103
|
-
await createMockGitRepo(nodeModulesDir, "ignored-repo");
|
|
104
|
-
|
|
105
|
-
const config = createTestConfig(tempDir);
|
|
106
|
-
const projects = await scanAllDirectories(config);
|
|
107
|
-
|
|
108
|
-
const ignoredProject = projects.find((p) => p.name === "ignored-repo");
|
|
109
|
-
expect(ignoredProject).toBeUndefined();
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("includes git status for git repos", async () => {
|
|
113
|
-
await createMockGitRepo(tempDir, "repo-with-status", {
|
|
114
|
-
withChanges: true,
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
const config = createTestConfig(tempDir);
|
|
118
|
-
const projects = await scanAllDirectories(config);
|
|
119
|
-
|
|
120
|
-
const repo = projects.find((p) => p.name === "repo-with-status");
|
|
121
|
-
expect(repo).toBeDefined();
|
|
122
|
-
expect(repo?.status).not.toBeNull();
|
|
123
|
-
expect(repo?.status?.isDirty).toBe(true);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test("sets correct project marker for non-git projects", async () => {
|
|
127
|
-
createMockProject(tempDir, "node-project", "package.json");
|
|
128
|
-
|
|
129
|
-
const config = createTestConfig(tempDir);
|
|
130
|
-
const projects = await scanAllDirectories(config);
|
|
131
|
-
|
|
132
|
-
const nodeProject = projects.find((p) => p.name === "node-project");
|
|
133
|
-
expect(nodeProject?.projectMarker).toBe("package.json");
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test("generates unique IDs for each project", async () => {
|
|
137
|
-
await createMockGitRepo(tempDir, "repo1");
|
|
138
|
-
await createMockGitRepo(tempDir, "repo2");
|
|
139
|
-
|
|
140
|
-
const config = createTestConfig(tempDir);
|
|
141
|
-
const projects = await scanAllDirectories(config);
|
|
142
|
-
|
|
143
|
-
const ids = projects.map((p) => p.id);
|
|
144
|
-
const uniqueIds = new Set(ids);
|
|
145
|
-
expect(uniqueIds.size).toBe(ids.length);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
test("sets lastScanned date", async () => {
|
|
149
|
-
await createMockGitRepo(tempDir, "dated-repo");
|
|
150
|
-
|
|
151
|
-
const config = createTestConfig(tempDir);
|
|
152
|
-
const projects = await scanAllDirectories(config);
|
|
153
|
-
|
|
154
|
-
const repo = projects.find((p) => p.name === "dated-repo");
|
|
155
|
-
expect(repo?.lastScanned).toBeInstanceOf(Date);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
test("respects showNonGitProjects config when true", async () => {
|
|
159
|
-
createMockProject(tempDir, "node-project", "package.json");
|
|
160
|
-
createMockProject(tempDir, "rust-project", "Cargo.toml");
|
|
161
|
-
|
|
162
|
-
const config = createTestConfig(tempDir, {
|
|
163
|
-
display: { showSubmodules: true, showNonGitProjects: true, sortBy: "status", sortDirection: "desc" }
|
|
164
|
-
});
|
|
165
|
-
const projects = await scanAllDirectories(config);
|
|
166
|
-
|
|
167
|
-
const nonGitProjects = projects.filter((p) => p.type === "non-git");
|
|
168
|
-
expect(nonGitProjects.length).toBe(2);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
test("respects showNonGitProjects config when false", async () => {
|
|
172
|
-
createMockProject(tempDir, "node-project", "package.json");
|
|
173
|
-
createMockProject(tempDir, "rust-project", "Cargo.toml");
|
|
174
|
-
|
|
175
|
-
const config = createTestConfig(tempDir, {
|
|
176
|
-
display: { showSubmodules: true, showNonGitProjects: false, sortBy: "status", sortDirection: "desc" }
|
|
177
|
-
});
|
|
178
|
-
const projects = await scanAllDirectories(config);
|
|
179
|
-
|
|
180
|
-
const nonGitProjects = projects.filter((p) => p.type === "non-git");
|
|
181
|
-
expect(nonGitProjects.length).toBe(0);
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
describe("sortProjects", () => {
|
|
186
|
-
const createMockProjects = (): Project[] => [
|
|
187
|
-
{
|
|
188
|
-
id: "1",
|
|
189
|
-
name: "zebra",
|
|
190
|
-
path: "/zebra",
|
|
191
|
-
type: "git",
|
|
192
|
-
projectMarker: null,
|
|
193
|
-
status: {
|
|
194
|
-
isDirty: false,
|
|
195
|
-
hasUnstagedChanges: false,
|
|
196
|
-
hasStagedChanges: false,
|
|
197
|
-
hasUntrackedFiles: false,
|
|
198
|
-
modifiedCount: 0,
|
|
199
|
-
stagedCount: 0,
|
|
200
|
-
untrackedCount: 0,
|
|
201
|
-
currentBranch: "main",
|
|
202
|
-
trackingBranch: null,
|
|
203
|
-
unpushedCommits: 0,
|
|
204
|
-
unpulledCommits: 0,
|
|
205
|
-
hasRemote: true,
|
|
206
|
-
remoteUrl: "https://github.com/test/zebra",
|
|
207
|
-
lastLocalCommit: new Date("2024-01-01"),
|
|
208
|
-
lastRemoteActivity: null,
|
|
209
|
-
hasCommits: true,
|
|
210
|
-
isAhead: false,
|
|
211
|
-
isBehind: false,
|
|
212
|
-
isOutOfSync: false,
|
|
213
|
-
},
|
|
214
|
-
submodule: null,
|
|
215
|
-
lastScanned: new Date(),
|
|
216
|
-
lastModified: null,
|
|
217
|
-
},
|
|
218
|
-
{
|
|
219
|
-
id: "2",
|
|
220
|
-
name: "alpha",
|
|
221
|
-
path: "/alpha",
|
|
222
|
-
type: "git",
|
|
223
|
-
projectMarker: null,
|
|
224
|
-
status: {
|
|
225
|
-
isDirty: true,
|
|
226
|
-
hasUnstagedChanges: true,
|
|
227
|
-
hasStagedChanges: false,
|
|
228
|
-
hasUntrackedFiles: false,
|
|
229
|
-
modifiedCount: 2,
|
|
230
|
-
stagedCount: 0,
|
|
231
|
-
untrackedCount: 0,
|
|
232
|
-
currentBranch: "main",
|
|
233
|
-
trackingBranch: null,
|
|
234
|
-
unpushedCommits: 3,
|
|
235
|
-
unpulledCommits: 0,
|
|
236
|
-
hasRemote: true,
|
|
237
|
-
remoteUrl: "https://github.com/test/alpha",
|
|
238
|
-
lastLocalCommit: new Date("2024-06-15"),
|
|
239
|
-
lastRemoteActivity: null,
|
|
240
|
-
hasCommits: true,
|
|
241
|
-
isAhead: true,
|
|
242
|
-
isBehind: false,
|
|
243
|
-
isOutOfSync: true,
|
|
244
|
-
},
|
|
245
|
-
submodule: null,
|
|
246
|
-
lastScanned: new Date(),
|
|
247
|
-
lastModified: null,
|
|
248
|
-
},
|
|
249
|
-
{
|
|
250
|
-
id: "3",
|
|
251
|
-
name: "middle",
|
|
252
|
-
path: "/middle",
|
|
253
|
-
type: "non-git",
|
|
254
|
-
projectMarker: "package.json",
|
|
255
|
-
status: null,
|
|
256
|
-
submodule: null,
|
|
257
|
-
lastScanned: new Date(),
|
|
258
|
-
lastModified: null,
|
|
259
|
-
},
|
|
260
|
-
];
|
|
261
|
-
|
|
262
|
-
test("sorts by name ascending", () => {
|
|
263
|
-
const projects = createMockProjects();
|
|
264
|
-
const sorted = sortProjects(projects, "name", "asc");
|
|
265
|
-
|
|
266
|
-
expect(sorted[0]?.name).toBe("alpha");
|
|
267
|
-
expect(sorted[1]?.name).toBe("middle");
|
|
268
|
-
expect(sorted[2]?.name).toBe("zebra");
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
test("sorts by name descending", () => {
|
|
272
|
-
const projects = createMockProjects();
|
|
273
|
-
const sorted = sortProjects(projects, "name", "desc");
|
|
274
|
-
|
|
275
|
-
expect(sorted[0]?.name).toBe("zebra");
|
|
276
|
-
expect(sorted[1]?.name).toBe("middle");
|
|
277
|
-
expect(sorted[2]?.name).toBe("alpha");
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
test("sorts by status (dirty first)", () => {
|
|
281
|
-
const projects = createMockProjects();
|
|
282
|
-
const sorted = sortProjects(projects, "status", "desc");
|
|
283
|
-
|
|
284
|
-
// Dirty projects should come first (higher priority)
|
|
285
|
-
const dirtyIndex = sorted.findIndex((p) => p.name === "alpha");
|
|
286
|
-
const cleanIndex = sorted.findIndex((p) => p.name === "zebra");
|
|
287
|
-
expect(dirtyIndex).toBeLessThan(cleanIndex);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
test("sorts by lastActivity descending (most recent first)", () => {
|
|
291
|
-
const projects = createMockProjects();
|
|
292
|
-
const sorted = sortProjects(projects, "lastActivity", "desc");
|
|
293
|
-
|
|
294
|
-
// alpha (2024-06-15) should come before zebra (2024-01-01)
|
|
295
|
-
const alphaIndex = sorted.findIndex((p) => p.name === "alpha");
|
|
296
|
-
const zebraIndex = sorted.findIndex((p) => p.name === "zebra");
|
|
297
|
-
expect(alphaIndex).toBeLessThan(zebraIndex);
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
test("sorts by lastActivity ascending (oldest first)", () => {
|
|
301
|
-
const projects = createMockProjects();
|
|
302
|
-
const sorted = sortProjects(projects, "lastActivity", "asc");
|
|
303
|
-
|
|
304
|
-
// zebra (2024-01-01) should come before alpha (2024-06-15)
|
|
305
|
-
const alphaIndex = sorted.findIndex((p) => p.name === "alpha");
|
|
306
|
-
const zebraIndex = sorted.findIndex((p) => p.name === "zebra");
|
|
307
|
-
expect(zebraIndex).toBeLessThan(alphaIndex);
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
test("does not mutate original array", () => {
|
|
311
|
-
const projects = createMockProjects();
|
|
312
|
-
const originalFirst = projects[0]?.name;
|
|
313
|
-
sortProjects(projects, "name", "asc");
|
|
314
|
-
expect(projects[0]?.name).toBe(originalFirst);
|
|
315
|
-
});
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
describe("filterProjects", () => {
|
|
319
|
-
const createMockProjects = (): Project[] => [
|
|
320
|
-
{
|
|
321
|
-
id: "1",
|
|
322
|
-
name: "my-awesome-project",
|
|
323
|
-
path: "/projects/my-awesome-project",
|
|
324
|
-
type: "git",
|
|
325
|
-
projectMarker: null,
|
|
326
|
-
status: null,
|
|
327
|
-
submodule: null,
|
|
328
|
-
lastScanned: new Date(),
|
|
329
|
-
lastModified: null,
|
|
330
|
-
},
|
|
331
|
-
{
|
|
332
|
-
id: "2",
|
|
333
|
-
name: "node-api",
|
|
334
|
-
path: "/work/node-api",
|
|
335
|
-
type: "git",
|
|
336
|
-
projectMarker: "package.json",
|
|
337
|
-
status: null,
|
|
338
|
-
submodule: null,
|
|
339
|
-
lastScanned: new Date(),
|
|
340
|
-
lastModified: null,
|
|
341
|
-
},
|
|
342
|
-
{
|
|
343
|
-
id: "3",
|
|
344
|
-
name: "rust-lib",
|
|
345
|
-
path: "/home/rust-lib",
|
|
346
|
-
type: "non-git",
|
|
347
|
-
projectMarker: "Cargo.toml",
|
|
348
|
-
status: null,
|
|
349
|
-
submodule: null,
|
|
350
|
-
lastScanned: new Date(),
|
|
351
|
-
lastModified: null,
|
|
352
|
-
},
|
|
353
|
-
];
|
|
354
|
-
|
|
355
|
-
test("returns all projects when filter is empty", () => {
|
|
356
|
-
const projects = createMockProjects();
|
|
357
|
-
const filtered = filterProjects(projects, "");
|
|
358
|
-
expect(filtered).toHaveLength(3);
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
test("returns all projects when filter is whitespace", () => {
|
|
362
|
-
const projects = createMockProjects();
|
|
363
|
-
const filtered = filterProjects(projects, " ");
|
|
364
|
-
expect(filtered).toHaveLength(3);
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
test("filters by project name", () => {
|
|
368
|
-
const projects = createMockProjects();
|
|
369
|
-
const filtered = filterProjects(projects, "awesome");
|
|
370
|
-
expect(filtered).toHaveLength(1);
|
|
371
|
-
expect(filtered[0]?.name).toBe("my-awesome-project");
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
test("filters by path", () => {
|
|
375
|
-
const projects = createMockProjects();
|
|
376
|
-
const filtered = filterProjects(projects, "work");
|
|
377
|
-
expect(filtered).toHaveLength(1);
|
|
378
|
-
expect(filtered[0]?.name).toBe("node-api");
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
test("filters by project marker", () => {
|
|
382
|
-
const projects = createMockProjects();
|
|
383
|
-
const filtered = filterProjects(projects, "Cargo");
|
|
384
|
-
expect(filtered).toHaveLength(1);
|
|
385
|
-
expect(filtered[0]?.name).toBe("rust-lib");
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
test("filters by type", () => {
|
|
389
|
-
const projects = createMockProjects();
|
|
390
|
-
const filtered = filterProjects(projects, "non-git");
|
|
391
|
-
expect(filtered).toHaveLength(1);
|
|
392
|
-
expect(filtered[0]?.type).toBe("non-git");
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
test("filter is case-insensitive", () => {
|
|
396
|
-
const projects = createMockProjects();
|
|
397
|
-
const filtered = filterProjects(projects, "AWESOME");
|
|
398
|
-
expect(filtered).toHaveLength(1);
|
|
399
|
-
expect(filtered[0]?.name).toBe("my-awesome-project");
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
test("returns empty array when no matches", () => {
|
|
403
|
-
const projects = createMockProjects();
|
|
404
|
-
const filtered = filterProjects(projects, "nonexistent");
|
|
405
|
-
expect(filtered).toHaveLength(0);
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
test("does not mutate original array", () => {
|
|
409
|
-
const projects = createMockProjects();
|
|
410
|
-
filterProjects(projects, "awesome");
|
|
411
|
-
expect(projects).toHaveLength(3);
|
|
412
|
-
});
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
describe("scanAllDirectories - Additional Tests", () => {
|
|
416
|
-
let tempDir: string;
|
|
417
|
-
|
|
418
|
-
beforeEach(() => {
|
|
419
|
-
tempDir = createTempDir("scanner-test-additional");
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
afterEach(() => {
|
|
423
|
-
cleanupTempDir(tempDir);
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
test("handles hidden directories when includeHidden is true", async () => {
|
|
427
|
-
const hiddenDir = join(tempDir, ".hidden-repo");
|
|
428
|
-
await mkdir(hiddenDir);
|
|
429
|
-
await createMockGitRepo(hiddenDir, "git-repo");
|
|
430
|
-
|
|
431
|
-
const config = createTestConfig(tempDir, {
|
|
432
|
-
scan: {
|
|
433
|
-
includeHidden: true,
|
|
434
|
-
ignore: ["node_modules", ".git", "vendor"],
|
|
435
|
-
concurrency: 5
|
|
436
|
-
},
|
|
437
|
-
});
|
|
438
|
-
const projects = await scanAllDirectories(config);
|
|
439
|
-
|
|
440
|
-
expect(projects.some(p => p.name === "git-repo")).toBe(true);
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
test("ignores hidden directories when includeHidden is false", async () => {
|
|
444
|
-
const hiddenDir = join(tempDir, ".hidden-repo");
|
|
445
|
-
await mkdir(hiddenDir);
|
|
446
|
-
await createMockGitRepo(hiddenDir, "git-repo");
|
|
447
|
-
|
|
448
|
-
const config = createTestConfig(tempDir, {
|
|
449
|
-
scan: {
|
|
450
|
-
includeHidden: false,
|
|
451
|
-
ignore: ["node_modules", ".git", "vendor"],
|
|
452
|
-
concurrency: 5
|
|
453
|
-
},
|
|
454
|
-
});
|
|
455
|
-
const projects = await scanAllDirectories(config);
|
|
456
|
-
|
|
457
|
-
expect(projects.some(p => p.name === "git-repo")).toBe(false);
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
test("finds submodules when showSubmodules is true", async () => {
|
|
461
|
-
const { parentPath } = await createMockRepoWithSubmodule(tempDir, "parent", "submodule");
|
|
462
|
-
|
|
463
|
-
const config = createTestConfig(parentPath, {
|
|
464
|
-
display: {
|
|
465
|
-
showSubmodules: true,
|
|
466
|
-
showNonGitProjects: true,
|
|
467
|
-
sortBy: "status",
|
|
468
|
-
sortDirection: "desc"
|
|
469
|
-
},
|
|
470
|
-
});
|
|
471
|
-
const projects = await scanAllDirectories(config);
|
|
472
|
-
|
|
473
|
-
expect(projects.some(p => p.name === "parent")).toBe(true);
|
|
474
|
-
expect(projects.some(p => p.name === "submodule" && p.type === "git-submodule")).toBe(true);
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
test("ignores submodules when showSubmodules is false", async () => {
|
|
478
|
-
const { parentPath } = await createMockRepoWithSubmodule(tempDir, "parent", "submodule");
|
|
479
|
-
|
|
480
|
-
const config = createTestConfig(parentPath, {
|
|
481
|
-
display: {
|
|
482
|
-
showSubmodules: false,
|
|
483
|
-
showNonGitProjects: true,
|
|
484
|
-
sortBy: "status",
|
|
485
|
-
sortDirection: "desc"
|
|
486
|
-
},
|
|
487
|
-
});
|
|
488
|
-
const projects = await scanAllDirectories(config);
|
|
489
|
-
|
|
490
|
-
expect(projects.some(p => p.name === "parent")).toBe(true);
|
|
491
|
-
expect(projects.some(p => p.name === "submodule" && p.type === "git-submodule")).toBe(false);
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
test("calls onProgress callback", async () => {
|
|
495
|
-
await createMockGitRepo(tempDir, "repo1");
|
|
496
|
-
await createMockGitRepo(tempDir, "repo2");
|
|
497
|
-
createMockProject(tempDir, "node-project", "package.json");
|
|
498
|
-
|
|
499
|
-
const progressCalls: Array<{ scanned: number; found: number }> = [];
|
|
500
|
-
const config = createTestConfig(tempDir);
|
|
501
|
-
|
|
502
|
-
await scanAllDirectories(config, {
|
|
503
|
-
onProgress: (scanned, found) => {
|
|
504
|
-
progressCalls.push({ scanned, found });
|
|
505
|
-
}
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
expect(progressCalls.length).toBeGreaterThan(0);
|
|
509
|
-
expect(progressCalls[progressCalls.length - 1]?.found).toBe(3);
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
test("handles non-existent directories gracefully", async () => {
|
|
513
|
-
const config = createMockConfig({
|
|
514
|
-
directories: [{ path: "/non/existent/path", maxDepth: 2 }],
|
|
515
|
-
});
|
|
516
|
-
|
|
517
|
-
const projects = await scanAllDirectories(config);
|
|
518
|
-
expect(projects).toEqual([]);
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
test("generates unique project IDs consistently", async () => {
|
|
522
|
-
await createMockGitRepo(tempDir, "repo1");
|
|
523
|
-
|
|
524
|
-
const config = createTestConfig(tempDir);
|
|
525
|
-
const projects1 = await scanAllDirectories(config);
|
|
526
|
-
const projects2 = await scanAllDirectories(config);
|
|
527
|
-
|
|
528
|
-
expect(projects1.length).toBeGreaterThan(0);
|
|
529
|
-
expect(projects2.length).toBeGreaterThan(0);
|
|
530
|
-
expect(projects1[0]?.id).toBe(projects2[0]?.id);
|
|
531
|
-
expect(projects1[0]?.id).toHaveLength(12); // MD5 hash first 12 chars
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
test("handles directories with read permissions issues", async () => {
|
|
535
|
-
// Create a nested structure
|
|
536
|
-
const subDir = join(tempDir, "subdir");
|
|
537
|
-
await mkdir(subDir);
|
|
538
|
-
await createMockGitRepo(subDir, "accessible-repo");
|
|
539
|
-
|
|
540
|
-
// Create a directory that we can't read (simulate permission denied)
|
|
541
|
-
const protectedDir = join(tempDir, "protected");
|
|
542
|
-
await mkdir(protectedDir);
|
|
543
|
-
|
|
544
|
-
const config = createTestConfig(tempDir);
|
|
545
|
-
const projects = await scanAllDirectories(config);
|
|
546
|
-
|
|
547
|
-
// Should still find the accessible repo
|
|
548
|
-
expect(projects.some(p => p.name === "accessible-repo")).toBe(true);
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
test("respects custom ignore patterns", async () => {
|
|
552
|
-
const customDir = join(tempDir, "custom-ignore");
|
|
553
|
-
await mkdir(customDir);
|
|
554
|
-
await createMockGitRepo(customDir, "ignored-repo");
|
|
555
|
-
|
|
556
|
-
const config = createTestConfig(tempDir, {
|
|
557
|
-
scan: {
|
|
558
|
-
ignore: ["custom-ignore", "node_modules", ".git"],
|
|
559
|
-
includeHidden: false,
|
|
560
|
-
concurrency: 5
|
|
561
|
-
},
|
|
562
|
-
});
|
|
563
|
-
const projects = await scanAllDirectories(config);
|
|
564
|
-
|
|
565
|
-
expect(projects.some(p => p.name === "ignored-repo")).toBe(false);
|
|
566
|
-
});
|
|
567
|
-
|
|
568
|
-
test("finds projects at exact maxDepth", async () => {
|
|
569
|
-
const level1 = join(tempDir, "level1");
|
|
570
|
-
const level2 = join(level1, "level2");
|
|
571
|
-
await mkdir(level2, { recursive: true });
|
|
572
|
-
|
|
573
|
-
await createMockGitRepo(level2, "exact-depth-repo");
|
|
574
|
-
|
|
575
|
-
// maxDepth of 2 should find the repo at level 2 (root is level 0, level1 is level 1, level2 is level 2)
|
|
576
|
-
const config = createTestConfig(tempDir, {
|
|
577
|
-
directories: [{ path: tempDir, maxDepth: 3 }], // Need maxDepth 3 to reach level 2
|
|
578
|
-
});
|
|
579
|
-
const projects = await scanAllDirectories(config);
|
|
580
|
-
|
|
581
|
-
expect(projects.some(p => p.name === "exact-depth-repo")).toBe(true);
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
test("finds multiple project types with different markers", async () => {
|
|
585
|
-
createMockProject(tempDir, "node-project", "package.json");
|
|
586
|
-
createMockProject(tempDir, "rust-project", "Cargo.toml");
|
|
587
|
-
createMockProject(tempDir, "python-project", "pyproject.toml");
|
|
588
|
-
createMockProject(tempDir, "go-project", "go.mod");
|
|
589
|
-
|
|
590
|
-
const config = createTestConfig(tempDir);
|
|
591
|
-
const projects = await scanAllDirectories(config);
|
|
592
|
-
|
|
593
|
-
expect(projects.find(p => p.name === "node-project")?.projectMarker).toBe("package.json");
|
|
594
|
-
expect(projects.find(p => p.name === "rust-project")?.projectMarker).toBe("Cargo.toml");
|
|
595
|
-
expect(projects.find(p => p.name === "python-project")?.projectMarker).toBe("pyproject.toml");
|
|
596
|
-
expect(projects.find(p => p.name === "go-project")?.projectMarker).toBe("go.mod");
|
|
597
|
-
});
|
|
598
|
-
});
|
|
599
|
-
|
|
600
|
-
describe("scanWithCache", () => {
|
|
601
|
-
let tempDir: string;
|
|
602
|
-
let originalDbPath: string | undefined;
|
|
603
|
-
|
|
604
|
-
beforeEach(() => {
|
|
605
|
-
tempDir = createTempDir("scanner-cache-test");
|
|
606
|
-
// Use a temporary database for each test
|
|
607
|
-
originalDbPath = process.env.XDG_DATA_HOME;
|
|
608
|
-
process.env.XDG_DATA_HOME = join(tempDir, ".local", "share");
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
afterEach(async () => {
|
|
612
|
-
cleanupTempDir(tempDir);
|
|
613
|
-
// Restore original database path (close DB after restoring path to avoid conflicts)
|
|
614
|
-
if (originalDbPath !== undefined) {
|
|
615
|
-
process.env.XDG_DATA_HOME = originalDbPath;
|
|
616
|
-
} else {
|
|
617
|
-
delete process.env.XDG_DATA_HOME;
|
|
618
|
-
}
|
|
619
|
-
// Close database connection after restoring path
|
|
620
|
-
closeDb();
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
test("returns cached data when fresh", async () => {
|
|
624
|
-
// Ensure cache is clear before test
|
|
625
|
-
await clearCache();
|
|
626
|
-
|
|
627
|
-
// Create a dedicated subdirectory for this test
|
|
628
|
-
const testDir = join(tempDir, "cache-test");
|
|
629
|
-
await mkdir(testDir);
|
|
630
|
-
|
|
631
|
-
await createMockGitRepo(testDir, "repo1");
|
|
632
|
-
|
|
633
|
-
const config = createMockConfig({
|
|
634
|
-
directories: [{ path: testDir, maxDepth: 2 }],
|
|
635
|
-
cache: {
|
|
636
|
-
ttlSeconds: 300,
|
|
637
|
-
githubTtlSeconds: 600,
|
|
638
|
-
enableBackgroundRefresh: true,
|
|
639
|
-
backgroundRefreshIntervalSeconds: 300
|
|
640
|
-
},
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
// First scan - should hit the filesystem
|
|
644
|
-
const projects1 = await scanWithCache(config);
|
|
645
|
-
expect(projects1).toHaveLength(1);
|
|
646
|
-
|
|
647
|
-
// Add another repo
|
|
648
|
-
await createMockGitRepo(testDir, "repo2");
|
|
649
|
-
|
|
650
|
-
// Second scan - should return cached data (only repo1)
|
|
651
|
-
const projects2 = await scanWithCache(config);
|
|
652
|
-
expect(projects2).toHaveLength(1);
|
|
653
|
-
expect(projects2[0]?.name).toBe("repo1");
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
test("performs fresh scan when cache is stale", async () => {
|
|
657
|
-
await createMockGitRepo(tempDir, "repo1");
|
|
658
|
-
|
|
659
|
-
const config = createTestConfig(tempDir, {
|
|
660
|
-
cache: {
|
|
661
|
-
ttlSeconds: 1, // Very short TTL to force fresh scan
|
|
662
|
-
githubTtlSeconds: 600,
|
|
663
|
-
enableBackgroundRefresh: true,
|
|
664
|
-
backgroundRefreshIntervalSeconds: 300
|
|
665
|
-
},
|
|
666
|
-
});
|
|
667
|
-
|
|
668
|
-
// First scan
|
|
669
|
-
const projects1 = await scanWithCache(config);
|
|
670
|
-
expect(projects1).toHaveLength(1);
|
|
671
|
-
|
|
672
|
-
// Add another repo
|
|
673
|
-
await createMockGitRepo(tempDir, "repo2");
|
|
674
|
-
|
|
675
|
-
// Wait for cache to become stale (TTL is 1 second)
|
|
676
|
-
await new Promise(resolve => setTimeout(resolve, 1100));
|
|
677
|
-
|
|
678
|
-
// Second scan - should find both repos (fresh scan)
|
|
679
|
-
const projects2 = await scanWithCache(config);
|
|
680
|
-
expect(projects2).toHaveLength(2);
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
test("performs fresh scan when forceRefresh is true", async () => {
|
|
684
|
-
await createMockGitRepo(tempDir, "repo1");
|
|
685
|
-
|
|
686
|
-
const config = createTestConfig(tempDir, {
|
|
687
|
-
cache: {
|
|
688
|
-
ttlSeconds: 300,
|
|
689
|
-
githubTtlSeconds: 600,
|
|
690
|
-
enableBackgroundRefresh: true,
|
|
691
|
-
backgroundRefreshIntervalSeconds: 300
|
|
692
|
-
},
|
|
693
|
-
});
|
|
694
|
-
|
|
695
|
-
// First scan
|
|
696
|
-
const projects1 = await scanWithCache(config);
|
|
697
|
-
expect(projects1).toHaveLength(1);
|
|
698
|
-
|
|
699
|
-
// Add another repo
|
|
700
|
-
await createMockGitRepo(tempDir, "repo2");
|
|
701
|
-
|
|
702
|
-
// Force refresh - should find both repos
|
|
703
|
-
const projects2 = await scanWithCache(config, { forceRefresh: true });
|
|
704
|
-
expect(projects2).toHaveLength(2);
|
|
705
|
-
});
|
|
706
|
-
|
|
707
|
-
test("saves results to cache after scan", async () => {
|
|
708
|
-
await createMockGitRepo(tempDir, "repo1");
|
|
709
|
-
|
|
710
|
-
const config = createTestConfig(tempDir);
|
|
711
|
-
|
|
712
|
-
// Clear any existing cache
|
|
713
|
-
await clearCache();
|
|
714
|
-
|
|
715
|
-
// Verify cache is empty
|
|
716
|
-
const initialStats = await getCacheStats();
|
|
717
|
-
expect(initialStats.projectCount).toBe(0);
|
|
718
|
-
|
|
719
|
-
// Scan
|
|
720
|
-
await scanWithCache(config);
|
|
721
|
-
|
|
722
|
-
// Verify cache now has the project
|
|
723
|
-
const finalStats = await getCacheStats();
|
|
724
|
-
expect(finalStats.projectCount).toBe(1);
|
|
725
|
-
});
|
|
726
|
-
|
|
727
|
-
test("handles cache read failures gracefully", async () => {
|
|
728
|
-
await createMockGitRepo(tempDir, "repo1");
|
|
729
|
-
|
|
730
|
-
const config = createTestConfig(tempDir);
|
|
731
|
-
|
|
732
|
-
// Mock a cache read failure by corrupting the database
|
|
733
|
-
// This is a bit hacky but tests the error handling
|
|
734
|
-
const projects = await scanWithCache(config);
|
|
735
|
-
expect(projects).toHaveLength(1);
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
test("handles cache write failures gracefully", async () => {
|
|
739
|
-
// Create read-only directory to simulate write failure
|
|
740
|
-
const readOnlyDir = join(tempDir, "readonly");
|
|
741
|
-
await mkdir(readOnlyDir);
|
|
742
|
-
await createMockGitRepo(readOnlyDir, "repo1");
|
|
743
|
-
|
|
744
|
-
const config = createTestConfig(readOnlyDir);
|
|
745
|
-
|
|
746
|
-
// Should still return projects even if cache write fails
|
|
747
|
-
const projects = await scanWithCache(config);
|
|
748
|
-
expect(projects).toHaveLength(1);
|
|
749
|
-
});
|
|
750
|
-
|
|
751
|
-
test("calls onProgress callback during fresh scan", async () => {
|
|
752
|
-
await createMockGitRepo(tempDir, "repo1");
|
|
753
|
-
await createMockGitRepo(tempDir, "repo2");
|
|
754
|
-
|
|
755
|
-
const progressCalls: Array<{ scanned: number; found: number }> = [];
|
|
756
|
-
const config = createTestConfig(tempDir, {
|
|
757
|
-
cache: {
|
|
758
|
-
ttlSeconds: 1, // Very short TTL to force fresh scan
|
|
759
|
-
githubTtlSeconds: 600,
|
|
760
|
-
enableBackgroundRefresh: true,
|
|
761
|
-
backgroundRefreshIntervalSeconds: 300
|
|
762
|
-
},
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
await scanWithCache(config, {
|
|
766
|
-
onProgress: (scanned, found) => {
|
|
767
|
-
progressCalls.push({ scanned, found });
|
|
768
|
-
}
|
|
769
|
-
});
|
|
770
|
-
|
|
771
|
-
expect(progressCalls.length).toBeGreaterThan(0);
|
|
772
|
-
const lastCall = progressCalls[progressCalls.length - 1];
|
|
773
|
-
if (lastCall) {
|
|
774
|
-
expect(lastCall.found).toBe(2);
|
|
775
|
-
}
|
|
776
|
-
});
|
|
777
|
-
|
|
778
|
-
test("does not call onProgress when returning cached data", async () => {
|
|
779
|
-
await createMockGitRepo(tempDir, "repo1");
|
|
780
|
-
|
|
781
|
-
const config = createTestConfig(tempDir, {
|
|
782
|
-
cache: {
|
|
783
|
-
ttlSeconds: 1, // Very short TTL to force fresh scan
|
|
784
|
-
githubTtlSeconds: 600,
|
|
785
|
-
enableBackgroundRefresh: true,
|
|
786
|
-
backgroundRefreshIntervalSeconds: 300
|
|
787
|
-
},
|
|
788
|
-
});
|
|
789
|
-
|
|
790
|
-
// First scan to populate cache
|
|
791
|
-
await scanWithCache(config);
|
|
792
|
-
|
|
793
|
-
// Second scan should use cache
|
|
794
|
-
let progressCalled = false;
|
|
795
|
-
await scanWithCache(config, {
|
|
796
|
-
onProgress: () => {
|
|
797
|
-
progressCalled = true;
|
|
798
|
-
}
|
|
799
|
-
});
|
|
800
|
-
|
|
801
|
-
expect(progressCalled).toBe(false);
|
|
802
|
-
});
|
|
803
|
-
});
|
|
804
|
-
|
|
805
|
-
describe("getCacheStats", () => {
|
|
806
|
-
let originalDbPath: string | undefined;
|
|
807
|
-
|
|
808
|
-
beforeEach(async () => {
|
|
809
|
-
// Use a temporary database for each test
|
|
810
|
-
originalDbPath = process.env.XDG_DATA_HOME;
|
|
811
|
-
process.env.XDG_DATA_HOME = join(createTempDir("cache-stats-db"), ".local", "share");
|
|
812
|
-
});
|
|
813
|
-
|
|
814
|
-
afterEach(async () => {
|
|
815
|
-
// Close database connection to prevent disk I/O errors
|
|
816
|
-
closeDb();
|
|
817
|
-
|
|
818
|
-
// Restore original database path
|
|
819
|
-
if (originalDbPath !== undefined) {
|
|
820
|
-
process.env.XDG_DATA_HOME = originalDbPath;
|
|
821
|
-
} else {
|
|
822
|
-
delete process.env.XDG_DATA_HOME;
|
|
823
|
-
}
|
|
824
|
-
});
|
|
825
|
-
|
|
826
|
-
test("returns correct project count", async () => {
|
|
827
|
-
const tempDir = createTempDir("cache-stats-test");
|
|
828
|
-
|
|
829
|
-
try {
|
|
830
|
-
// Create multiple projects
|
|
831
|
-
await createMockGitRepo(tempDir, "repo1");
|
|
832
|
-
await createMockGitRepo(tempDir, "repo2");
|
|
833
|
-
createMockProject(tempDir, "node-project", "package.json");
|
|
834
|
-
|
|
835
|
-
const config = createMockConfig({
|
|
836
|
-
directories: [{ path: tempDir, maxDepth: 2 }],
|
|
837
|
-
});
|
|
838
|
-
|
|
839
|
-
// Scan to populate cache
|
|
840
|
-
await scanWithCache(config);
|
|
841
|
-
|
|
842
|
-
// Check stats
|
|
843
|
-
const stats = await getCacheStats();
|
|
844
|
-
expect(stats.projectCount).toBe(3);
|
|
845
|
-
} finally {
|
|
846
|
-
cleanupTempDir(tempDir);
|
|
847
|
-
}
|
|
848
|
-
});
|
|
849
|
-
|
|
850
|
-
test("returns oldest and newest scan dates", async () => {
|
|
851
|
-
const tempDir = createTempDir("cache-dates-test");
|
|
852
|
-
|
|
853
|
-
try {
|
|
854
|
-
// Create and scan first project
|
|
855
|
-
await createMockGitRepo(tempDir, "repo1");
|
|
856
|
-
const config = createMockConfig({
|
|
857
|
-
directories: [{ path: tempDir, maxDepth: 2 }],
|
|
858
|
-
cache: {
|
|
859
|
-
ttlSeconds: 1, // Short TTL so cache expires quickly
|
|
860
|
-
githubTtlSeconds: 600,
|
|
861
|
-
enableBackgroundRefresh: true,
|
|
862
|
-
backgroundRefreshIntervalSeconds: 300
|
|
863
|
-
},
|
|
864
|
-
});
|
|
865
|
-
await scanWithCache(config);
|
|
866
|
-
|
|
867
|
-
const stats1 = await getCacheStats();
|
|
868
|
-
const firstScanDate = stats1.newestScan!;
|
|
869
|
-
expect(stats1.projectCount).toBe(1);
|
|
870
|
-
|
|
871
|
-
// Wait for cache to become stale, then add another project and scan
|
|
872
|
-
await new Promise(resolve => setTimeout(resolve, 1100));
|
|
873
|
-
await createMockGitRepo(tempDir, "repo2");
|
|
874
|
-
|
|
875
|
-
// Second scan without forceRefresh - repo1 is stale so will be rescanned,
|
|
876
|
-
// repo2 is new so will be scanned fresh
|
|
877
|
-
await scanWithCache(config);
|
|
878
|
-
|
|
879
|
-
const stats2 = await getCacheStats();
|
|
880
|
-
expect(stats2.projectCount).toBe(2);
|
|
881
|
-
|
|
882
|
-
// Both projects now have recent scan times
|
|
883
|
-
// Just verify we have valid dates (the exact timing is unreliable in tests)
|
|
884
|
-
expect(stats2.oldestScan).not.toBeNull();
|
|
885
|
-
expect(stats2.newestScan).not.toBeNull();
|
|
886
|
-
expect(stats2.newestScan!.getTime()).toBeGreaterThanOrEqual(firstScanDate.getTime());
|
|
887
|
-
} finally {
|
|
888
|
-
cleanupTempDir(tempDir);
|
|
889
|
-
}
|
|
890
|
-
});
|
|
891
|
-
|
|
892
|
-
test("handles empty cache", async () => {
|
|
893
|
-
const stats = await getCacheStats();
|
|
894
|
-
|
|
895
|
-
expect(stats.projectCount).toBe(0);
|
|
896
|
-
expect(stats.oldestScan).toBeNull();
|
|
897
|
-
expect(stats.newestScan).toBeNull();
|
|
898
|
-
});
|
|
899
|
-
|
|
900
|
-
test("returns consistent dates for single project", async () => {
|
|
901
|
-
const tempDir = createTempDir("cache-single-test");
|
|
902
|
-
|
|
903
|
-
try {
|
|
904
|
-
await createMockGitRepo(tempDir, "repo1");
|
|
905
|
-
const config = createMockConfig({
|
|
906
|
-
directories: [{ path: tempDir, maxDepth: 2 }],
|
|
907
|
-
});
|
|
908
|
-
await scanWithCache(config);
|
|
909
|
-
|
|
910
|
-
const stats = await getCacheStats();
|
|
911
|
-
|
|
912
|
-
expect(stats.projectCount).toBe(1);
|
|
913
|
-
expect(stats.oldestScan).toBeInstanceOf(Date);
|
|
914
|
-
expect(stats.newestScan).toBeInstanceOf(Date);
|
|
915
|
-
expect(stats.oldestScan!.getTime()).toBe(stats.newestScan!.getTime());
|
|
916
|
-
} finally {
|
|
917
|
-
cleanupTempDir(tempDir);
|
|
918
|
-
}
|
|
919
|
-
});
|
|
920
|
-
});
|
|
921
|
-
|
|
922
|
-
describe("clearCache", () => {
|
|
923
|
-
let originalDbPath: string | undefined;
|
|
924
|
-
|
|
925
|
-
beforeEach(async () => {
|
|
926
|
-
// Use a temporary database for each test
|
|
927
|
-
originalDbPath = process.env.XDG_DATA_HOME;
|
|
928
|
-
process.env.XDG_DATA_HOME = join(createTempDir("clear-cache-db"), ".local", "share");
|
|
929
|
-
});
|
|
930
|
-
|
|
931
|
-
afterEach(async () => {
|
|
932
|
-
// Close database connection to prevent disk I/O errors
|
|
933
|
-
closeDb();
|
|
934
|
-
|
|
935
|
-
// Restore original database path
|
|
936
|
-
if (originalDbPath !== undefined) {
|
|
937
|
-
process.env.XDG_DATA_HOME = originalDbPath;
|
|
938
|
-
} else {
|
|
939
|
-
delete process.env.XDG_DATA_HOME;
|
|
940
|
-
}
|
|
941
|
-
});
|
|
942
|
-
|
|
943
|
-
test("clears all cached projects", async () => {
|
|
944
|
-
const tempDir = createTempDir("clear-cache-test");
|
|
945
|
-
|
|
946
|
-
try {
|
|
947
|
-
// Populate cache
|
|
948
|
-
await createMockGitRepo(tempDir, "repo1");
|
|
949
|
-
await createMockGitRepo(tempDir, "repo2");
|
|
950
|
-
const config = createMockConfig({
|
|
951
|
-
directories: [{ path: tempDir, maxDepth: 2 }],
|
|
952
|
-
});
|
|
953
|
-
await scanWithCache(config);
|
|
954
|
-
|
|
955
|
-
// Verify cache has projects
|
|
956
|
-
const statsBefore = await getCacheStats();
|
|
957
|
-
expect(statsBefore.projectCount).toBe(2);
|
|
958
|
-
|
|
959
|
-
// Clear cache
|
|
960
|
-
await clearCache();
|
|
961
|
-
|
|
962
|
-
// Verify cache is empty
|
|
963
|
-
const statsAfter = await getCacheStats();
|
|
964
|
-
expect(statsAfter.projectCount).toBe(0);
|
|
965
|
-
expect(statsAfter.oldestScan).toBeNull();
|
|
966
|
-
expect(statsAfter.newestScan).toBeNull();
|
|
967
|
-
} finally {
|
|
968
|
-
cleanupTempDir(tempDir);
|
|
969
|
-
}
|
|
970
|
-
});
|
|
971
|
-
|
|
972
|
-
test("handles clearing empty cache", async () => {
|
|
973
|
-
// Clear any existing cache
|
|
974
|
-
await clearCache();
|
|
975
|
-
|
|
976
|
-
// Clear again - should not throw
|
|
977
|
-
await clearCache();
|
|
978
|
-
|
|
979
|
-
// Verify still empty
|
|
980
|
-
const stats = await getCacheStats();
|
|
981
|
-
expect(stats.projectCount).toBe(0);
|
|
982
|
-
});
|
|
983
|
-
|
|
984
|
-
test("cache is empty after clearing even with stale data", async () => {
|
|
985
|
-
const tempDir = createTempDir("clear-stale-cache-test");
|
|
986
|
-
|
|
987
|
-
try {
|
|
988
|
-
// Add old data to cache with expired TTL
|
|
989
|
-
await createMockGitRepo(tempDir, "old-repo");
|
|
990
|
-
const config = createMockConfig({
|
|
991
|
-
directories: [{ path: tempDir, maxDepth: 2 }],
|
|
992
|
-
cache: {
|
|
993
|
-
ttlSeconds: 1, // Very short TTL
|
|
994
|
-
githubTtlSeconds: 600,
|
|
995
|
-
enableBackgroundRefresh: true,
|
|
996
|
-
backgroundRefreshIntervalSeconds: 300
|
|
997
|
-
},
|
|
998
|
-
});
|
|
999
|
-
await scanWithCache(config);
|
|
1000
|
-
|
|
1001
|
-
// Clear cache
|
|
1002
|
-
await clearCache();
|
|
1003
|
-
|
|
1004
|
-
// Verify completely empty
|
|
1005
|
-
const stats = await getCacheStats();
|
|
1006
|
-
expect(stats.projectCount).toBe(0);
|
|
1007
|
-
} finally {
|
|
1008
|
-
cleanupTempDir(tempDir);
|
|
1009
|
-
}
|
|
1010
|
-
});
|
|
1011
|
-
});
|