|
2 | 2 | * Unit tests for src/db.js — build_meta helpers included |
3 | 3 | */ |
4 | 4 |
|
| 5 | +// Note: due to vi.mock hoisting, this resolves to the spy (which delegates |
| 6 | +// to the real impl by default). Safe for setup calls before mockImplementationOnce. |
| 7 | +import { execFileSync as execFileSyncForSetup } from 'node:child_process'; |
5 | 8 | import fs from 'node:fs'; |
6 | 9 | import os from 'node:os'; |
7 | 10 | import path from 'node:path'; |
8 | 11 | import Database from 'better-sqlite3'; |
9 | | -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; |
| 12 | +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; |
| 13 | + |
| 14 | +const execFileSyncSpy = vi.hoisted(() => vi.fn()); |
| 15 | + |
| 16 | +vi.mock('node:child_process', async (importOriginal) => { |
| 17 | + const mod = await importOriginal(); |
| 18 | + execFileSyncSpy.mockImplementation(mod.execFileSync); |
| 19 | + return { ...mod, execFileSync: execFileSyncSpy }; |
| 20 | +}); |
| 21 | + |
| 22 | +import { _resetRepoRootCache } from '../../src/db/connection.js'; |
10 | 23 | import { |
11 | 24 | closeDb, |
12 | 25 | findDbPath, |
| 26 | + findRepoRoot, |
13 | 27 | getBuildMeta, |
14 | 28 | initSchema, |
15 | 29 | MIGRATIONS, |
@@ -131,24 +145,30 @@ describe('findDbPath', () => { |
131 | 145 | const origCwd = process.cwd; |
132 | 146 | process.cwd = () => deepDir; |
133 | 147 | try { |
| 148 | + _resetRepoRootCache(); |
134 | 149 | const result = findDbPath(); |
135 | 150 | expect(result).toContain('.codegraph'); |
136 | 151 | expect(result).toContain('graph.db'); |
137 | 152 | } finally { |
138 | 153 | process.cwd = origCwd; |
| 154 | + _resetRepoRootCache(); |
139 | 155 | } |
140 | 156 | }); |
141 | 157 |
|
142 | 158 | it('returns default path when no DB found', () => { |
143 | 159 | const emptyDir = fs.mkdtempSync(path.join(tmpDir, 'empty-')); |
144 | 160 | const origCwd = process.cwd; |
145 | 161 | process.cwd = () => emptyDir; |
| 162 | + _resetRepoRootCache(); |
| 163 | + execFileSyncSpy.mockImplementationOnce(() => { |
| 164 | + throw new Error('not a git repo'); |
| 165 | + }); |
146 | 166 | try { |
147 | 167 | const result = findDbPath(); |
148 | | - expect(result).toContain('.codegraph'); |
149 | | - expect(result).toContain('graph.db'); |
| 168 | + expect(result).toBe(path.join(emptyDir, '.codegraph', 'graph.db')); |
150 | 169 | } finally { |
151 | 170 | process.cwd = origCwd; |
| 171 | + _resetRepoRootCache(); |
152 | 172 | } |
153 | 173 | }); |
154 | 174 | }); |
@@ -194,6 +214,143 @@ describe('build_meta', () => { |
194 | 214 | }); |
195 | 215 | }); |
196 | 216 |
|
| 217 | +describe('findRepoRoot', () => { |
| 218 | + beforeEach(() => { |
| 219 | + _resetRepoRootCache(); |
| 220 | + }); |
| 221 | + |
| 222 | + afterEach(() => { |
| 223 | + _resetRepoRootCache(); |
| 224 | + }); |
| 225 | + |
| 226 | + it('returns normalized git toplevel for the current repo', () => { |
| 227 | + _resetRepoRootCache(); |
| 228 | + const root = findRepoRoot(); |
| 229 | + expect(root).toBeTruthy(); |
| 230 | + expect(path.isAbsolute(root)).toBe(true); |
| 231 | + // Should contain a .git entry at the root |
| 232 | + expect(fs.existsSync(path.join(root, '.git'))).toBe(true); |
| 233 | + }); |
| 234 | + |
| 235 | + it('returns null when not in a git repo', () => { |
| 236 | + execFileSyncSpy.mockImplementationOnce(() => { |
| 237 | + throw new Error('not a git repo'); |
| 238 | + }); |
| 239 | + const root = findRepoRoot(os.tmpdir()); |
| 240 | + expect(root).toBeNull(); |
| 241 | + }); |
| 242 | + |
| 243 | + it('caches results when called without arguments', () => { |
| 244 | + _resetRepoRootCache(); |
| 245 | + execFileSyncSpy.mockClear(); |
| 246 | + const first = findRepoRoot(); |
| 247 | + const second = findRepoRoot(); |
| 248 | + expect(first).toBe(second); |
| 249 | + expect(execFileSyncSpy).toHaveBeenCalledTimes(1); |
| 250 | + }); |
| 251 | + |
| 252 | + it('bypasses cache when called with explicit dir', () => { |
| 253 | + _resetRepoRootCache(); |
| 254 | + execFileSyncSpy.mockClear(); |
| 255 | + const fromCwd = findRepoRoot(); |
| 256 | + const fromExplicit = findRepoRoot(process.cwd()); |
| 257 | + expect(fromExplicit).toBe(fromCwd); |
| 258 | + // First call populates cache, second call with explicit dir must call again |
| 259 | + expect(execFileSyncSpy).toHaveBeenCalledTimes(2); |
| 260 | + }); |
| 261 | +}); |
| 262 | + |
| 263 | +describe('findDbPath with git ceiling', () => { |
| 264 | + let outerDir; |
| 265 | + let worktreeRoot; |
| 266 | + let innerDir; |
| 267 | + |
| 268 | + beforeAll(() => { |
| 269 | + // Simulate a worktree-inside-repo layout: |
| 270 | + // outerDir/.codegraph/graph.db (parent repo DB — should NOT be found) |
| 271 | + // outerDir/worktree/ (git init here — acts as ceiling) |
| 272 | + // outerDir/worktree/sub/ (cwd inside worktree) |
| 273 | + outerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ceiling-')); |
| 274 | + worktreeRoot = path.join(outerDir, 'worktree'); |
| 275 | + fs.mkdirSync(path.join(outerDir, '.codegraph'), { recursive: true }); |
| 276 | + fs.writeFileSync(path.join(outerDir, '.codegraph', 'graph.db'), ''); |
| 277 | + fs.mkdirSync(path.join(worktreeRoot, 'sub'), { recursive: true }); |
| 278 | + // Initialize a real git repo at the worktree root so findRepoRoot returns it |
| 279 | + execFileSyncForSetup('git', ['init'], { cwd: worktreeRoot, stdio: 'pipe' }); |
| 280 | + // Resolve symlinks (macOS /var → /private/var) and 8.3 short names |
| 281 | + // (Windows RUNNER~1 → runneradmin) so test paths match findRepoRoot output. |
| 282 | + outerDir = fs.realpathSync(outerDir); |
| 283 | + worktreeRoot = fs.realpathSync(worktreeRoot); |
| 284 | + innerDir = path.join(worktreeRoot, 'sub'); |
| 285 | + }); |
| 286 | + |
| 287 | + afterAll(() => { |
| 288 | + fs.rmSync(outerDir, { recursive: true, force: true }); |
| 289 | + }); |
| 290 | + |
| 291 | + afterEach(() => { |
| 292 | + _resetRepoRootCache(); |
| 293 | + }); |
| 294 | + |
| 295 | + it('stops at git ceiling and does not find parent DB', () => { |
| 296 | + // No DB inside the worktree — the only DB is in outerDir (beyond the ceiling). |
| 297 | + // Without the ceiling fix, findDbPath would walk up and find outerDir's DB. |
| 298 | + const origCwd = process.cwd; |
| 299 | + process.cwd = () => innerDir; |
| 300 | + try { |
| 301 | + _resetRepoRootCache(); |
| 302 | + // Use findRepoRoot() for the expected ceiling — git may resolve 8.3 short |
| 303 | + // names (Windows RUNNER~1 → runneradmin) or symlinks (macOS /tmp → /private/tmp) |
| 304 | + // differently than fs.realpathSync on the test's worktreeRoot. |
| 305 | + const ceiling = findRepoRoot(); |
| 306 | + const result = findDbPath(); |
| 307 | + // Should return default path at the ceiling root, NOT the outer DB |
| 308 | + expect(result).toBe(path.join(ceiling, '.codegraph', 'graph.db')); |
| 309 | + expect(result).not.toContain(`${path.basename(outerDir)}${path.sep}.codegraph`); |
| 310 | + } finally { |
| 311 | + process.cwd = origCwd; |
| 312 | + } |
| 313 | + }); |
| 314 | + |
| 315 | + it('finds DB within the ceiling boundary', () => { |
| 316 | + // Create a DB inside the worktree — should be found normally |
| 317 | + fs.mkdirSync(path.join(worktreeRoot, '.codegraph'), { recursive: true }); |
| 318 | + fs.writeFileSync(path.join(worktreeRoot, '.codegraph', 'graph.db'), ''); |
| 319 | + const origCwd = process.cwd; |
| 320 | + process.cwd = () => innerDir; |
| 321 | + try { |
| 322 | + _resetRepoRootCache(); |
| 323 | + const result = findDbPath(); |
| 324 | + // Verify the DB was found (file exists) and is the worktree DB, not the outer one |
| 325 | + expect(fs.existsSync(result)).toBe(true); |
| 326 | + expect(result).toMatch(/\.codegraph[/\\]graph\.db$/); |
| 327 | + // The outer DB is at outerDir/.codegraph — verify we didn't find that one |
| 328 | + expect(result).not.toContain(`${path.basename(outerDir)}${path.sep}.codegraph`); |
| 329 | + } finally { |
| 330 | + process.cwd = origCwd; |
| 331 | + fs.rmSync(path.join(worktreeRoot, '.codegraph'), { recursive: true, force: true }); |
| 332 | + } |
| 333 | + }); |
| 334 | + |
| 335 | + it('falls back gracefully when not in a git repo', () => { |
| 336 | + const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-nogit-')); |
| 337 | + const origCwd = process.cwd; |
| 338 | + process.cwd = () => emptyDir; |
| 339 | + _resetRepoRootCache(); |
| 340 | + execFileSyncSpy.mockImplementationOnce(() => { |
| 341 | + throw new Error('not a git repo'); |
| 342 | + }); |
| 343 | + try { |
| 344 | + const result = findDbPath(); |
| 345 | + // Should return default path at cwd since there's no git ceiling |
| 346 | + expect(result).toBe(path.join(emptyDir, '.codegraph', 'graph.db')); |
| 347 | + } finally { |
| 348 | + process.cwd = origCwd; |
| 349 | + fs.rmSync(emptyDir, { recursive: true, force: true }); |
| 350 | + } |
| 351 | + }); |
| 352 | +}); |
| 353 | + |
197 | 354 | describe('openReadonlyOrFail', () => { |
198 | 355 | it('throws DbError when DB does not exist', () => { |
199 | 356 | expect.assertions(4); |
|
0 commit comments