gitforest 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +24 -4
  3. package/src/github/auth.ts +3 -3
  4. package/src/utils/debug.ts +4 -4
  5. package/.bunignore +0 -7
  6. package/.github/workflows/ci.yml +0 -73
  7. package/CLAUDE.md +0 -111
  8. package/CONTRIBUTING.md +0 -145
  9. package/bun.lock +0 -267
  10. package/bunfig.toml +0 -15
  11. package/cli +0 -0
  12. package/docs/ai/IMPROVEMENT_PLAN.md +0 -341
  13. package/docs/ai/VERIFICATION_REPORT.md +0 -87
  14. package/docs/ai/architecture.md +0 -169
  15. package/docs/ai/checks/check-2025-12-02-tests.md +0 -40
  16. package/docs/ai/checks/check-2025-12-02.md +0 -55
  17. package/docs/ai/checks/test-verification-report.md +0 -85
  18. package/docs/ai/implementation-guide.md +0 -776
  19. package/docs/ai/research/gitty-codebase-analysis.md +0 -221
  20. package/docs/ai/tickets/GENERAL-sitrep.md +0 -30
  21. package/docs/ai/tickets/TASK-database-tests-sitrep.md +0 -25
  22. package/docs/ai/tickets/TASK-deprecated-functions-sitrep.md +0 -28
  23. package/docs/ai/tickets/TASK-detail-modal-sitrep.md +0 -28
  24. package/docs/ai/tickets/TASK-filter-overlay-sitrep.md +0 -24
  25. package/docs/ai/tickets/TASK-github-service-sitrep.md +0 -32
  26. package/docs/ai/tickets/TASK-github-token-sitrep.md +0 -51
  27. package/docs/ai/tickets/TASK-hascommits-sitrep.md +0 -35
  28. package/docs/ai/tickets/TASK-keybindings-sitrep.md +0 -26
  29. package/docs/ai/tickets/TASK-layout-sitrep.md +0 -25
  30. package/docs/ai/tickets/TASK-markdown-sitrep.md +0 -28
  31. package/docs/ai/tickets/TASK-project-item-sitrep.md +0 -79
  32. package/docs/ai/tickets/TASK-sitrep.md +0 -28
  33. package/docs/ai/tickets/TASK-state-sitrep.md +0 -26
  34. package/docs/ai/tickets/TASK-types-sitrep.md +0 -25
  35. package/docs/ai/tickets/TASK-unified-item-fix-sitrep.md +0 -26
  36. package/docs/ai/tickets/TKT-001-sitrep.md +0 -24
  37. package/docs/ai/tickets/TKT-002-sitrep.md +0 -25
  38. package/docs/ai/tickets/TKT-003-git-service-refactoring-complete.md +0 -46
  39. package/docs/ai/tickets/TKT-003-git-service-refactoring-plan.md +0 -135
  40. package/docs/ai/tickets/TKT-003-sitrep.md +0 -26
  41. package/docs/ai/tickets/TKT-004-sitrep.md +0 -27
  42. package/docs/ai/tickets/TKT-005-sitrep.md +0 -25
  43. package/docs/ai/tickets/TKT-006-sitrep.md +0 -26
  44. package/docs/ai/tickets/TKT-007-sitrep.md +0 -30
  45. package/docs/ai/tickets/TKT-008-sitrep.md +0 -32
  46. package/docs/ai/tickets/TKT-009-sitrep.md +0 -27
  47. package/docs/ai/tickets/TKT-010-sitrep.md +0 -27
  48. package/docs/ai/tickets/TKT-011-sitrep.md +0 -26
  49. package/docs/ai/tickets/TKT-012-sitrep.md +0 -25
  50. package/docs/ai/tickets/sitreps/TASK-actions-sitrep.md +0 -28
  51. package/docs/ai/tickets/sitreps/TASK-actions-test-sitrep.md +0 -25
  52. package/docs/ai/tickets/sitreps/TASK-app-integration-sitrep.md +0 -25
  53. package/docs/ai/tickets/sitreps/TASK-background-fetch-sitrep.md +0 -24
  54. package/docs/ai/tickets/sitreps/TASK-background-fetch-test-sitrep.md +0 -29
  55. package/docs/ai/tickets/sitreps/TASK-batch-tests-sitrep.md +0 -29
  56. package/docs/ai/tickets/sitreps/TASK-bun-test-sitrep.md +0 -26
  57. package/docs/ai/tickets/sitreps/TASK-cache-tests-sitrep.md +0 -30
  58. package/docs/ai/tickets/sitreps/TASK-cli-tests-sitrep.md +0 -28
  59. package/docs/ai/tickets/sitreps/TASK-clone-error-handling-sitrep.md +0 -26
  60. package/docs/ai/tickets/sitreps/TASK-commands-tests-sitrep.md +0 -25
  61. package/docs/ai/tickets/sitreps/TASK-component-tests-1-sitrep.md +0 -30
  62. package/docs/ai/tickets/sitreps/TASK-configloader-tests-sitrep.md +0 -25
  63. package/docs/ai/tickets/sitreps/TASK-confirm-dialog-test-sitrep.md +0 -29
  64. package/docs/ai/tickets/sitreps/TASK-coverage-sitrep.md +0 -95
  65. package/docs/ai/tickets/sitreps/TASK-database-tests-summary.md +0 -61
  66. package/docs/ai/tickets/sitreps/TASK-error-boundary-sitrep.md +0 -30
  67. package/docs/ai/tickets/sitreps/TASK-error-tests-sitrep.md +0 -27
  68. package/docs/ai/tickets/sitreps/TASK-errors-tests-sitrep.md +0 -25
  69. package/docs/ai/tickets/sitreps/TASK-extract-reducer-sitrep.md +0 -27
  70. package/docs/ai/tickets/sitreps/TASK-filter-overlay-test-sitrep.md +0 -25
  71. package/docs/ai/tickets/sitreps/TASK-final-verification-sitrep.md +0 -28
  72. package/docs/ai/tickets/sitreps/TASK-fix-all-tests-sitrep.md +0 -25
  73. package/docs/ai/tickets/sitreps/TASK-fix-hooks-sitrep.md +0 -26
  74. package/docs/ai/tickets/sitreps/TASK-fix-remaining-tests-sitrep.md +0 -25
  75. package/docs/ai/tickets/sitreps/TASK-fix-test-failures-sitrep.md +0 -26
  76. package/docs/ai/tickets/sitreps/TASK-fix-tests-sitrep.md +0 -24
  77. package/docs/ai/tickets/sitreps/TASK-formatters-tests-sitrep.md +0 -25
  78. package/docs/ai/tickets/sitreps/TASK-git-timeouts-sitrep.md +0 -29
  79. package/docs/ai/tickets/sitreps/TASK-github-cache-test-sitrep.md +0 -25
  80. package/docs/ai/tickets/sitreps/TASK-githubcli-tests-sitrep.md +0 -24
  81. package/docs/ai/tickets/sitreps/TASK-gitstatus-tests-sitrep.md +0 -24
  82. package/docs/ai/tickets/sitreps/TASK-hooks-isolation-sitrep.md +0 -27
  83. package/docs/ai/tickets/sitreps/TASK-keybindings-tests-sitrep.md +0 -25
  84. package/docs/ai/tickets/sitreps/TASK-layout-tests-sitrep.md +0 -25
  85. package/docs/ai/tickets/sitreps/TASK-mock-factories-sitrep.md +0 -27
  86. package/docs/ai/tickets/sitreps/TASK-modal-tests-sitrep.md +0 -32
  87. package/docs/ai/tickets/sitreps/TASK-processbatch-fix-sitrep.md +0 -27
  88. package/docs/ai/tickets/sitreps/TASK-projectlist-tests-sitrep.md +0 -30
  89. package/docs/ai/tickets/sitreps/TASK-projectutils-tests-sitrep.md +0 -25
  90. package/docs/ai/tickets/sitreps/TASK-scanner-tests-sitrep.md +0 -29
  91. package/docs/ai/tickets/sitreps/TASK-select-all-sitrep.md +0 -25
  92. package/docs/ai/tickets/sitreps/TASK-shell-error-handling-sitrep.md +0 -27
  93. package/docs/ai/tickets/sitreps/TASK-store-tests-sitrep.md +0 -25
  94. package/docs/ai/tickets/sitreps/TASK-test-fixes-sitrep.md +0 -26
  95. package/docs/ai/tickets/sitreps/TASK-test-summary-sitrep.md +0 -25
  96. package/docs/ai/tickets/sitreps/TASK-test-verification-sitrep.md +0 -27
  97. package/docs/ai/tickets/sitreps/TASK-testsuite-sitrep.md +0 -75
  98. package/docs/ai/tickets/sitreps/TASK-unified-reducer-tests-sitrep.md +0 -29
  99. package/docs/ai/tickets/sitreps/TASK-unified-repos-test-sitrep.md +0 -29
  100. package/docs/ai/tickets/sitreps/TASK-unified-tests-sitrep.md +0 -25
  101. package/docs/ai/tickets/sitreps/TASK-useprojects-tests-sitrep.md +0 -25
  102. package/docs/ai/tickets/sitreps/TASK-utility-tests-sitrep.md +0 -32
  103. package/docs/ai/tickets/sitreps/TKT-003-git-service-refactoring-sitrep.md +0 -64
  104. package/docs/ai/tkt-001-fix-database-error.md +0 -217
  105. package/docs/ai/ui-enhancement-plan.md +0 -562
  106. package/test/integration/app.isolated.tsx +0 -240
  107. package/test/integration/cli-commands.test.ts +0 -287
  108. package/test/integration/cli-validation.test.ts +0 -264
  109. package/test/integration/git-operations.test.ts +0 -218
  110. package/test/integration/scanner.test.ts +0 -228
  111. package/test/preload.ts +0 -18
  112. package/test/unit/cli/commands.test.ts +0 -13
  113. package/test/unit/cli/formatters.test.ts +0 -1116
  114. package/test/unit/cli/github-commands.test.ts +0 -12
  115. package/test/unit/components/CloneDialog.test.tsx +0 -240
  116. package/test/unit/components/ColumnHeader.test.tsx +0 -128
  117. package/test/unit/components/CommandPalette.test.tsx +0 -355
  118. package/test/unit/components/ConfirmDialog.test.tsx +0 -111
  119. package/test/unit/components/ErrorBoundary.test.tsx +0 -139
  120. package/test/unit/components/FilterBar.test.tsx +0 -43
  121. package/test/unit/components/FilterOptionsOverlay.test.tsx +0 -197
  122. package/test/unit/components/HelpOverlay.test.tsx +0 -90
  123. package/test/unit/components/Layout.test.tsx +0 -328
  124. package/test/unit/components/MarkdownRenderer.test.tsx +0 -45
  125. package/test/unit/components/ProgressBar.test.tsx +0 -138
  126. package/test/unit/components/ProjectItem.test.tsx +0 -182
  127. package/test/unit/components/ProjectList.test.tsx +0 -311
  128. package/test/unit/components/RepoDetailModal.test.tsx +0 -445
  129. package/test/unit/components/StatusBar.test.tsx +0 -112
  130. package/test/unit/components/UnifiedProjectItem.test.tsx +0 -618
  131. package/test/unit/components/ViewModeIndicator.test.tsx +0 -137
  132. package/test/unit/components/test-utils.tsx +0 -63
  133. package/test/unit/config/loader.test.ts +0 -692
  134. package/test/unit/db/database.test.ts +0 -978
  135. package/test/unit/db/index.test.ts +0 -314
  136. package/test/unit/fixtures/setup.ts +0 -186
  137. package/test/unit/git/commands-untested.test.ts +0 -205
  138. package/test/unit/git/commands.test.ts +0 -269
  139. package/test/unit/git/operations.test.ts +0 -322
  140. package/test/unit/git/status.test.ts +0 -219
  141. package/test/unit/github/auth.test.ts +0 -317
  142. package/test/unit/github/cache.test.ts +0 -1028
  143. package/test/unit/github/cli.test.ts +0 -135
  144. package/test/unit/github/unified.test.ts +0 -1201
  145. package/test/unit/graceful-shutdown.test.ts +0 -83
  146. package/test/unit/hooks/useBackgroundFetch.test.tsx +0 -239
  147. package/test/unit/hooks/useConfirmDialogActions.test.tsx +0 -81
  148. package/test/unit/hooks/useKeyBindings.isolated.ts +0 -715
  149. package/test/unit/hooks/useProjects.test.tsx +0 -186
  150. package/test/unit/hooks/useUnifiedRepos-simple.test.tsx +0 -115
  151. package/test/unit/hooks/useUnifiedRepos.test.tsx +0 -177
  152. package/test/unit/mocks/config.ts +0 -109
  153. package/test/unit/mocks/git-service.ts +0 -274
  154. package/test/unit/mocks/github-service.ts +0 -250
  155. package/test/unit/mocks/index.ts +0 -72
  156. package/test/unit/mocks/project.ts +0 -148
  157. package/test/unit/mocks/state-mocks.ts +0 -187
  158. package/test/unit/mocks/unified.ts +0 -169
  159. package/test/unit/operations/batch.test.ts +0 -216
  160. package/test/unit/operations/commands.test.ts +0 -550
  161. package/test/unit/scanner/errors.test.ts +0 -297
  162. package/test/unit/scanner/index.test.ts +0 -1011
  163. package/test/unit/scanner/markers.test.ts +0 -150
  164. package/test/unit/scanner/submodules.test.ts +0 -99
  165. package/test/unit/services/git-errors.test.ts +0 -190
  166. package/test/unit/services/git.test.ts +0 -442
  167. package/test/unit/services/github-errors.test.ts +0 -293
  168. package/test/unit/services/github.test.ts +0 -200
  169. package/test/unit/state/actions.test.ts +0 -217
  170. package/test/unit/state/reducer.test.ts +0 -745
  171. package/test/unit/state/store.test.tsx +0 -711
  172. package/test/unit/types/commands.test.ts +0 -220
  173. package/test/unit/types/schema.test.ts +0 -179
  174. package/test/unit/utils/array.test.ts +0 -73
  175. package/test/unit/utils/debug.test.ts +0 -23
  176. package/test/unit/utils/errors.test.ts +0 -295
  177. package/test/unit/utils/markdown.test.ts +0 -163
  178. package/test/unit/utils/project-utils.test.ts +0 -756
  179. package/test/unit/utils/rate-limiter.test.ts +0 -256
  180. package/test/unit/utils/retry.test.ts +0 -165
  181. package/test/unit/utils/strip-ansi.ts +0 -13
  182. package/test/unit/utils/timeout.test.ts +0 -93
  183. package/tsconfig.json +0 -29
@@ -1,692 +0,0 @@
1
- import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
- import { join } from "path";
3
- import { mkdtemp, rm, writeFile } from "fs/promises";
4
- import { tmpdir } from "os";
5
- import { stringify } from "yaml";
6
- import { loadConfig, findConfigPath, applyEnvOverrides, getSupportedEnvVars } from "../../../src/config/loader.ts";
7
- import type { GitforestConfig } from "../../../src/types/index.ts";
8
- import {
9
- createTempDir,
10
- cleanupTempDir,
11
- createMockConfig,
12
- createMockYamlConfig,
13
- } from "../fixtures/setup.ts";
14
-
15
- describe("findConfigPath", () => {
16
- let tempDir: string;
17
-
18
- beforeEach(() => {
19
- tempDir = createTempDir("config-test");
20
- });
21
-
22
- afterEach(() => {
23
- cleanupTempDir(tempDir);
24
- });
25
-
26
- test("finds gitforest.config.yaml in current directory first", () => {
27
- createMockYamlConfig(tempDir, "directories:\n - path: ~/test");
28
- const result = findConfigPath(tempDir);
29
- // Should find the config in specified directory (highest priority)
30
- expect(result).toBe(join(tempDir, "gitforest.config.yaml"));
31
- });
32
-
33
- test("finds gitforest.config.json in current directory", () => {
34
- createMockConfig(tempDir, { directories: [{ path: "~/test" }] });
35
- const result = findConfigPath(tempDir);
36
- expect(result).toBe(join(tempDir, "gitforest.config.json"));
37
- });
38
-
39
- test("prefers yaml over json in current directory", () => {
40
- createMockYamlConfig(tempDir, "directories:\n - path: ~/yaml");
41
- createMockConfig(tempDir, { directories: [{ path: "~/json" }] });
42
- const result = findConfigPath(tempDir);
43
- // YAML should be found first per priority order
44
- expect(result).toBe(join(tempDir, "gitforest.config.yaml"));
45
- });
46
- });
47
-
48
- describe("loadConfig", () => {
49
- let tempDir: string;
50
-
51
- beforeEach(() => {
52
- tempDir = createTempDir("config-load-test");
53
- });
54
-
55
- afterEach(() => {
56
- cleanupTempDir(tempDir);
57
- });
58
-
59
- test("loads valid JSON config", async () => {
60
- const configPath = createMockConfig(tempDir, {
61
- directories: [{ path: "~/projects", maxDepth: 2 }],
62
- });
63
-
64
- const config = await loadConfig(configPath);
65
-
66
- expect(config.directories).toHaveLength(1);
67
- expect(config.directories[0]?.maxDepth).toBe(2);
68
- });
69
-
70
- test("loads valid YAML config", async () => {
71
- const configPath = createMockYamlConfig(
72
- tempDir,
73
- `
74
- directories:
75
- - path: ~/projects
76
- maxDepth: 3
77
- label: Projects
78
- - path: ~/work
79
- maxDepth: 2
80
-
81
- scan:
82
- ignore:
83
- - node_modules
84
- - vendor
85
- includeHidden: false
86
-
87
- github:
88
- defaultVisibility: private
89
- `
90
- );
91
-
92
- const config = await loadConfig(configPath);
93
-
94
- expect(config.directories).toHaveLength(2);
95
- expect(config.directories[0]?.maxDepth).toBe(3);
96
- expect(config.directories[0]?.label).toBe("Projects");
97
- expect(config.scan.ignore).toContain("vendor");
98
- expect(config.github.defaultVisibility).toBe("private");
99
- });
100
-
101
- test("expands tilde in paths", async () => {
102
- const configPath = createMockConfig(tempDir, {
103
- directories: [{ path: "~/projects" }],
104
- });
105
-
106
- const config = await loadConfig(configPath);
107
-
108
- expect(config.directories[0]?.path).not.toContain("~");
109
- expect(config.directories[0]?.path).toContain(process.env.HOME || "");
110
- });
111
-
112
- test("throws error for invalid config", async () => {
113
- const configPath = createMockConfig(tempDir, {
114
- directories: [], // Invalid: empty array
115
- });
116
-
117
- await expect(loadConfig(configPath)).rejects.toThrow();
118
- });
119
-
120
- test("throws error for missing required field", async () => {
121
- const configPath = createMockConfig(tempDir, {
122
- // Missing directories field
123
- scan: { ignore: [] },
124
- });
125
-
126
- await expect(loadConfig(configPath)).rejects.toThrow();
127
- });
128
-
129
- test("throws error when config file does not exist", async () => {
130
- const nonExistentPath = join(tempDir, "non-existent.yaml");
131
- await expect(loadConfig(nonExistentPath)).rejects.toThrow();
132
- });
133
-
134
- test("applies default values for optional fields", async () => {
135
- const configPath = createMockConfig(tempDir, {
136
- directories: [{ path: "~/projects" }],
137
- });
138
-
139
- const config = await loadConfig(configPath);
140
-
141
- expect(config.scan.concurrency).toBe(5);
142
- expect(config.display.sortBy).toBe("status");
143
- expect(config.cache.ttlSeconds).toBe(300);
144
- });
145
- });
146
-
147
- describe("applyEnvOverrides", () => {
148
- // Store original env values
149
- const originalEnv: Record<string, string | undefined> = {};
150
- const envVarsToClean = [
151
- "GITTY_GITHUB_VISIBILITY",
152
- "GITTY_CONCURRENCY",
153
- "GITTY_INCLUDE_HIDDEN",
154
- "GITTY_SORT_BY",
155
- "GITTY_SORT_DIR",
156
- "GITTY_SHOW_SUBMODULES",
157
- "GITTY_CACHE_TTL",
158
- ];
159
-
160
- const createBaseConfig = (): GitforestConfig => ({
161
- directories: [{ path: "/home/user/projects", maxDepth: 2 }],
162
- scan: {
163
- concurrency: 5,
164
- ignore: ["node_modules"],
165
- includeHidden: false,
166
- },
167
- display: {
168
- showSubmodules: true,
169
- showNonGitProjects: true,
170
- sortBy: "status",
171
- sortDirection: "desc",
172
- },
173
- github: {
174
- defaultVisibility: "private",
175
- },
176
- cache: {
177
- ttlSeconds: 300,
178
- githubTtlSeconds: 600,
179
- enableBackgroundRefresh: true,
180
- backgroundRefreshIntervalSeconds: 300,
181
- },
182
- commands: [],
183
- });
184
-
185
- beforeEach(() => {
186
- // Save original values
187
- for (const key of envVarsToClean) {
188
- originalEnv[key] = process.env[key];
189
- delete process.env[key];
190
- }
191
- });
192
-
193
- afterEach(() => {
194
- // Restore original values
195
- for (const key of envVarsToClean) {
196
- if (originalEnv[key] !== undefined) {
197
- process.env[key] = originalEnv[key];
198
- } else {
199
- delete process.env[key];
200
- }
201
- }
202
- });
203
-
204
- test("returns config unchanged when no env vars set", () => {
205
- const config = createBaseConfig();
206
- const result = applyEnvOverrides(config);
207
-
208
- expect(result).toEqual(config);
209
- });
210
-
211
- test("overrides github.defaultVisibility from GITTY_GITHUB_VISIBILITY", () => {
212
- process.env.GITTY_GITHUB_VISIBILITY = "public";
213
- const config = createBaseConfig();
214
-
215
- const result = applyEnvOverrides(config);
216
-
217
- expect(result.github?.defaultVisibility).toBe("public");
218
- });
219
-
220
- test("overrides scan.concurrency from GITTY_CONCURRENCY", () => {
221
- process.env.GITTY_CONCURRENCY = "10";
222
- const config = createBaseConfig();
223
-
224
- const result = applyEnvOverrides(config);
225
-
226
- expect(result.scan?.concurrency).toBe(10);
227
- });
228
-
229
- test("overrides scan.includeHidden from GITTY_INCLUDE_HIDDEN", () => {
230
- process.env.GITTY_INCLUDE_HIDDEN = "true";
231
- const config = createBaseConfig();
232
-
233
- const result = applyEnvOverrides(config);
234
-
235
- expect(result.scan?.includeHidden).toBe(true);
236
- });
237
-
238
- test("overrides display.sortBy from GITTY_SORT_BY", () => {
239
- process.env.GITTY_SORT_BY = "name";
240
- const config = createBaseConfig();
241
-
242
- const result = applyEnvOverrides(config);
243
-
244
- expect(result.display?.sortBy).toBe("name");
245
- });
246
-
247
- test("overrides display.sortDirection from GITTY_SORT_DIR", () => {
248
- process.env.GITTY_SORT_DIR = "asc";
249
- const config = createBaseConfig();
250
-
251
- const result = applyEnvOverrides(config);
252
-
253
- expect(result.display?.sortDirection).toBe("asc");
254
- });
255
-
256
- test("overrides display.showSubmodules from GITTY_SHOW_SUBMODULES", () => {
257
- process.env.GITTY_SHOW_SUBMODULES = "false";
258
- const config = createBaseConfig();
259
-
260
- const result = applyEnvOverrides(config);
261
-
262
- expect(result.display?.showSubmodules).toBe(false);
263
- });
264
-
265
- test("overrides cache.ttlSeconds from GITTY_CACHE_TTL", () => {
266
- process.env.GITTY_CACHE_TTL = "600";
267
- const config = createBaseConfig();
268
-
269
- const result = applyEnvOverrides(config);
270
-
271
- expect(result.cache?.ttlSeconds).toBe(600);
272
- });
273
-
274
- test("applies multiple env overrides at once", () => {
275
- process.env.GITTY_GITHUB_VISIBILITY = "public";
276
- process.env.GITTY_CONCURRENCY = "20";
277
- process.env.GITTY_SORT_BY = "lastActivity";
278
- process.env.GITTY_CACHE_TTL = "1200";
279
- const config = createBaseConfig();
280
-
281
- const result = applyEnvOverrides(config);
282
-
283
- expect(result.github?.defaultVisibility).toBe("public");
284
- expect(result.scan?.concurrency).toBe(20);
285
- expect(result.display?.sortBy).toBe("lastActivity");
286
- expect(result.cache?.ttlSeconds).toBe(1200);
287
- });
288
-
289
- test("does not modify original config", () => {
290
- process.env.GITTY_CONCURRENCY = "15";
291
- const config = createBaseConfig();
292
- const originalConcurrency = config.scan?.concurrency;
293
-
294
- applyEnvOverrides(config);
295
-
296
- expect(config.scan?.concurrency).toBe(originalConcurrency);
297
- });
298
-
299
- test("ignores empty string env values", () => {
300
- process.env.GITTY_GITHUB_VISIBILITY = "";
301
- const config = createBaseConfig();
302
-
303
- const result = applyEnvOverrides(config);
304
-
305
- // Empty env values should be ignored, keeping the original value
306
- expect(result.github?.defaultVisibility).toBe("private");
307
- });
308
-
309
- test("creates missing parent objects", () => {
310
- process.env.GITTY_CACHE_TTL = "900";
311
- // Start with a minimal but complete config
312
- const config: GitforestConfig = {
313
- directories: [{ path: "/test", maxDepth: 1 }],
314
- scan: { ignore: [], includeHidden: false, concurrency: 4 },
315
- github: { defaultVisibility: "private" },
316
- display: { showSubmodules: true, showNonGitProjects: true, sortBy: "status", sortDirection: "desc" },
317
- cache: {
318
- ttlSeconds: 300,
319
- githubTtlSeconds: 600,
320
- enableBackgroundRefresh: true,
321
- backgroundRefreshIntervalSeconds: 300,
322
- },
323
- commands: [],
324
- };
325
-
326
- const result = applyEnvOverrides(config);
327
-
328
- expect(result.cache?.ttlSeconds).toBe(900);
329
- });
330
- });
331
-
332
- describe("getSupportedEnvVars", () => {
333
- test("returns list of supported env vars", () => {
334
- const envVars = getSupportedEnvVars();
335
-
336
- expect(envVars.length).toBeGreaterThan(0);
337
- expect(envVars.some((v) => v.env === "GITTY_GITHUB_VISIBILITY")).toBe(true);
338
- expect(envVars.some((v) => v.env === "GITTY_CONCURRENCY")).toBe(true);
339
- });
340
-
341
- test("each env var has required properties", () => {
342
- const envVars = getSupportedEnvVars();
343
-
344
- for (const envVar of envVars) {
345
- expect(envVar.env).toBeDefined();
346
- expect(envVar.path).toBeDefined();
347
- expect(envVar.description).toBeDefined();
348
- expect(envVar.env.length).toBeGreaterThan(0);
349
- expect(envVar.path.length).toBeGreaterThan(0);
350
- }
351
- });
352
-
353
- test("path format is dot-separated", () => {
354
- const envVars = getSupportedEnvVars();
355
- const visibility = envVars.find((v) => v.env === "GITTY_GITHUB_VISIBILITY");
356
-
357
- expect(visibility?.path).toBe("github.defaultVisibility");
358
- });
359
- });
360
-
361
- describe("loadConfig - comprehensive tests", () => {
362
- let tempDir: string;
363
-
364
- beforeEach(async () => {
365
- tempDir = await mkdtemp(join(tmpdir(), "gitforest-config-test-"));
366
- });
367
-
368
- afterEach(async () => {
369
- await rm(tempDir, { recursive: true, force: true });
370
- });
371
-
372
- describe("loadConfig from default location", () => {
373
- test("loads config from default location when no path provided", async () => {
374
- const configContent = {
375
- directories: [{ path: "~/projects", maxDepth: 2 }]
376
- };
377
- await writeFile(join(tempDir, "gitforest.config.yaml"), stringify(configContent));
378
-
379
- const config = await loadConfig(undefined, tempDir);
380
- expect(config.directories).toHaveLength(1);
381
- expect(config.directories[0]?.path).toContain(process.env.HOME || "");
382
- });
383
-
384
- test("loads config from custom path", async () => {
385
- const configPath = join(tempDir, "custom-config.yaml");
386
- const configContent = {
387
- directories: [{ path: "/test/path", maxDepth: 3 }]
388
- };
389
- await writeFile(configPath, stringify(configContent));
390
-
391
- const config = await loadConfig(configPath);
392
- expect(config.directories[0]?.path).toBe("/test/path");
393
- expect(config.directories[0]?.maxDepth).toBe(3);
394
- });
395
-
396
- test("throws error when specified config file does not exist", async () => {
397
- // Test with a specific path that doesn't exist
398
- const nonExistentPath = join(tempDir, "does-not-exist.yaml");
399
- await expect(loadConfig(nonExistentPath)).rejects.toThrow();
400
- });
401
-
402
- test("handles YAML parse errors gracefully", async () => {
403
- const configPath = join(tempDir, "invalid.yaml");
404
- await writeFile(configPath, "invalid: yaml: content: [");
405
-
406
- await expect(loadConfig(configPath)).rejects.toThrow();
407
- });
408
-
409
- test("handles invalid config structure", async () => {
410
- const configPath = join(tempDir, "invalid-structure.yaml");
411
- const invalidConfig = {
412
- directories: [{ path: 123 }], // path should be string
413
- };
414
- await writeFile(configPath, stringify(invalidConfig));
415
-
416
- await expect(loadConfig(configPath)).rejects.toThrow(/Invalid config file/);
417
- });
418
-
419
- test("expands ~ in directory paths", async () => {
420
- const configPath = join(tempDir, "tilde-config.yaml");
421
- const configContent = {
422
- directories: [
423
- { path: "~/projects" },
424
- { path: "~/work/test" },
425
- { path: "/absolute/path" }
426
- ]
427
- };
428
- await writeFile(configPath, stringify(configContent));
429
-
430
- const config = await loadConfig(configPath);
431
- expect(config.directories[0]?.path).toBe(join(process.env.HOME || "", "projects"));
432
- expect(config.directories[1]?.path).toBe(join(process.env.HOME || "", "work/test"));
433
- expect(config.directories[2]?.path).toBe("/absolute/path");
434
- });
435
-
436
- test("expands environment variables in paths", async () => {
437
- process.env.TEST_PROJECTS_DIR = "/test/projects";
438
- const configPath = join(tempDir, "env-var-config.yaml");
439
- const configContent = {
440
- directories: [
441
- { path: "${TEST_PROJECTS_DIR}/frontend" },
442
- { path: "~/backend" }
443
- ]
444
- };
445
- await writeFile(configPath, stringify(configContent));
446
-
447
- const config = await loadConfig(configPath);
448
- // Note: The current implementation doesn't expand env vars, this is expected behavior
449
- expect(config.directories[0]?.path).toBe("${TEST_PROJECTS_DIR}/frontend");
450
- delete process.env.TEST_PROJECTS_DIR;
451
- });
452
- });
453
-
454
- describe("Validation tests", () => {
455
- test("validates required directories field exists", async () => {
456
- const configPath = join(tempDir, "no-directories.yaml");
457
- const configContent = {
458
- scan: { ignore: ["node_modules"] }
459
- };
460
- await writeFile(configPath, stringify(configContent));
461
-
462
- await expect(loadConfig(configPath)).rejects.toThrow(/Invalid config file/);
463
- });
464
-
465
- test("validates directory path is non-empty", async () => {
466
- const configPath = join(tempDir, "empty-path.yaml");
467
- const configContent = {
468
- directories: [{ path: "" }]
469
- };
470
- await writeFile(configPath, stringify(configContent));
471
-
472
- await expect(loadConfig(configPath)).rejects.toThrow(/Invalid config file/);
473
- });
474
-
475
- test("validates maxDepth is within range (0-10)", async () => {
476
- const configPath = join(tempDir, "invalid-depth.yaml");
477
- const configContent = {
478
- directories: [{ path: "~/test", maxDepth: 15 }]
479
- };
480
- await writeFile(configPath, stringify(configContent));
481
-
482
- await expect(loadConfig(configPath)).rejects.toThrow(/Invalid config file/);
483
- });
484
-
485
- test("validates scan.concurrency is within range (1-20)", async () => {
486
- const configPath = join(tempDir, "invalid-concurrency.yaml");
487
- const configContent = {
488
- directories: [{ path: "~/test" }],
489
- scan: { concurrency: 25 }
490
- };
491
- await writeFile(configPath, stringify(configContent));
492
-
493
- await expect(loadConfig(configPath)).rejects.toThrow(/Invalid config file/);
494
- });
495
-
496
- test("validates command keys are not reserved", async () => {
497
- const configPath = join(tempDir, "reserved-key.yaml");
498
- const configContent = {
499
- directories: [{ path: "~/test" }],
500
- commands: [
501
- { name: "test", key: "j", command: "echo test" } // 'j' is reserved
502
- ]
503
- };
504
- await writeFile(configPath, stringify(configContent));
505
-
506
- await expect(loadConfig(configPath)).rejects.toThrow(/reserved/);
507
- });
508
-
509
- test("validates no duplicate command keys", async () => {
510
- const configPath = join(tempDir, "duplicate-keys.yaml");
511
- const configContent = {
512
- directories: [{ path: "~/test" }],
513
- commands: [
514
- { name: "test1", key: "t", command: "echo test1" },
515
- { name: "test2", key: "t", command: "echo test2" }
516
- ]
517
- };
518
- await writeFile(configPath, stringify(configContent));
519
-
520
- await expect(loadConfig(configPath)).rejects.toThrow(/Duplicate command keys/);
521
- });
522
-
523
- test("validates command key is single character", async () => {
524
- const configPath = join(tempDir, "multi-char-key.yaml");
525
- const configContent = {
526
- directories: [{ path: "~/test" }],
527
- commands: [
528
- { name: "test", key: "abc", command: "echo test" }
529
- ]
530
- };
531
- await writeFile(configPath, stringify(configContent));
532
-
533
- await expect(loadConfig(configPath)).rejects.toThrow(/Invalid config file/);
534
- });
535
- });
536
-
537
- describe("Default values tests", () => {
538
- test("uses default ignore patterns", async () => {
539
- const configPath = join(tempDir, "default-ignore.yaml");
540
- const configContent = {
541
- directories: [{ path: "~/test" }]
542
- };
543
- await writeFile(configPath, stringify(configContent));
544
-
545
- const config = await loadConfig(configPath);
546
- expect(config.scan.ignore).toContain("node_modules");
547
- expect(config.scan.ignore).toContain(".git");
548
- expect(config.scan.ignore).toContain("vendor");
549
- expect(config.scan.ignore).toContain("__pycache__");
550
- expect(config.scan.ignore).toContain("target");
551
- expect(config.scan.ignore).toContain("dist");
552
- expect(config.scan.ignore).toContain("build");
553
- });
554
-
555
- test("uses default cache TTL", async () => {
556
- const configPath = join(tempDir, "default-ttl.yaml");
557
- const configContent = {
558
- directories: [{ path: "~/test" }]
559
- };
560
- await writeFile(configPath, stringify(configContent));
561
-
562
- const config = await loadConfig(configPath);
563
- expect(config.cache.ttlSeconds).toBe(300);
564
- expect(config.cache.githubTtlSeconds).toBe(600);
565
- });
566
-
567
- test("uses default concurrency", async () => {
568
- const configPath = join(tempDir, "default-concurrency.yaml");
569
- const configContent = {
570
- directories: [{ path: "~/test" }]
571
- };
572
- await writeFile(configPath, stringify(configContent));
573
-
574
- const config = await loadConfig(configPath);
575
- expect(config.scan.concurrency).toBe(5);
576
- });
577
-
578
- test("uses default visibility", async () => {
579
- const configPath = join(tempDir, "default-visibility.yaml");
580
- const configContent = {
581
- directories: [{ path: "~/test" }]
582
- };
583
- await writeFile(configPath, stringify(configContent));
584
-
585
- const config = await loadConfig(configPath);
586
- expect(config.github.defaultVisibility).toBe("private");
587
- });
588
-
589
- test("uses default display settings", async () => {
590
- const configPath = join(tempDir, "default-display.yaml");
591
- const configContent = {
592
- directories: [{ path: "~/test" }]
593
- };
594
- await writeFile(configPath, stringify(configContent));
595
-
596
- const config = await loadConfig(configPath);
597
- expect(config.display.showSubmodules).toBe(true);
598
- expect(config.display.showNonGitProjects).toBe(true);
599
- expect(config.display.sortBy).toBe("status");
600
- expect(config.display.sortDirection).toBe("desc");
601
- });
602
- });
603
-
604
- describe("Edge cases", () => {
605
- test("handles empty config file", async () => {
606
- const configPath = join(tempDir, "empty.yaml");
607
- await writeFile(configPath, "");
608
-
609
- await expect(loadConfig(configPath)).rejects.toThrow();
610
- });
611
-
612
- test("handles config with only directories", async () => {
613
- const configPath = join(tempDir, "minimal-config.yaml");
614
- const configContent = {
615
- directories: [{ path: "~/projects" }]
616
- };
617
- await writeFile(configPath, stringify(configContent));
618
-
619
- const config = await loadConfig(configPath);
620
- expect(config.directories).toHaveLength(1);
621
- expect(config.directories[0]?.path).toContain("projects");
622
- // All other fields should have defaults
623
- expect(config.scan).toBeDefined();
624
- expect(config.display).toBeDefined();
625
- expect(config.cache).toBeDefined();
626
- expect(config.github).toBeDefined();
627
- });
628
-
629
- test("handles config with all optional fields", async () => {
630
- const configPath = join(tempDir, "full-config.yaml");
631
- const configContent = {
632
- directories: [
633
- { path: "~/projects", maxDepth: 3, label: "My Projects" },
634
- { path: "~/work", maxDepth: 2, label: "Work Stuff" }
635
- ],
636
- scan: {
637
- ignore: ["custom_ignore"],
638
- includeHidden: true,
639
- concurrency: 10
640
- },
641
- github: {
642
- defaultVisibility: "public" as const
643
- },
644
- display: {
645
- showSubmodules: false,
646
- showNonGitProjects: false,
647
- sortBy: "name" as const,
648
- sortDirection: "asc" as const
649
- },
650
- cache: {
651
- ttlSeconds: 600,
652
- githubTtlSeconds: 1200,
653
- enableBackgroundRefresh: false,
654
- backgroundRefreshIntervalSeconds: 600
655
- },
656
- commands: [
657
- { name: "Custom Command", key: "z", command: "echo 'Custom!'", confirm: true, background: false }
658
- ]
659
- };
660
- await writeFile(configPath, stringify(configContent));
661
-
662
- const config = await loadConfig(configPath);
663
- expect(config.directories).toHaveLength(2);
664
- expect(config.scan.concurrency).toBe(10);
665
- expect(config.github.defaultVisibility).toBe("public");
666
- expect(config.display.sortBy).toBe("name");
667
- expect(config.display.sortDirection).toBe("asc");
668
- expect(config.cache.ttlSeconds).toBe(600);
669
- expect(config.commands).toHaveLength(1);
670
- expect(config.commands[0]?.key).toBe("z");
671
- });
672
-
673
- test("handles JSON config files", async () => {
674
- const configPath = join(tempDir, "config.json");
675
- const configContent = {
676
- directories: [{ path: "~/projects" }]
677
- };
678
- await writeFile(configPath, JSON.stringify(configContent));
679
-
680
- const config = await loadConfig(configPath);
681
- expect(config.directories).toHaveLength(1);
682
- expect(config.directories[0]?.path).toContain("projects");
683
- });
684
-
685
- test("throws error for unknown file format", async () => {
686
- const configPath = join(tempDir, "config.txt");
687
- await writeFile(configPath, "directories: []");
688
-
689
- await expect(loadConfig(configPath)).rejects.toThrow(/Unknown config file format/);
690
- });
691
- });
692
- });