From 2ec83345d271c3fe84bb3b1ffa2dfece15fcb910 Mon Sep 17 00:00:00 2001 From: Siddharth Gelera Date: Thu, 28 May 2026 16:42:35 +0530 Subject: [PATCH 1/2] feat: watch metadata events --- README.md | 8 ++- src/cache.test.ts | 42 +++++++++++++--- src/git-status.ts | 24 +++++++++ src/porcelain.test.ts | 56 ++++++++++++++++++--- src/porcelain.ts | 14 +++++- src/types.ts | 6 ++- src/watcher.test.ts | 51 ++++++++++++++++++- src/watcher.ts | 111 +++++++++++++++++++++++++++++++++--------- 8 files changed, 268 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 76b524b..f322aa3 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ const oneFile = await getFileGitState('/path/to/repo', 'src/app.ts'); ## Watcher API Filesystem events are dirty hints only. The watcher debounces changed paths, runs scoped `git status --porcelain=v2 -z -- `, compares against the cache, and emits `change` only when the computed git state actually changes. +Git metadata (`index`, `HEAD`, `refs`) is watched separately, so commands like `git add`, `git commit`, `git reset`, and checkout also trigger semantic updates. ```ts import { createGitStateWatcher } from 'git-tree-state'; @@ -37,7 +38,7 @@ const unsubscribe = watcher.on('change', (changes) => { } }); -// After branch checkout / reset / index-only updates: +// Force a resync if watcher events may have been dropped: await watcher.refresh(); await watcher.close(); @@ -50,9 +51,10 @@ unsubscribe(); |-----------|------| | Startup | One full `git status --porcelain=v2 -z` | | File edit | Debounced batch of scoped status calls for dirty paths only | +| Git metadata change (`git add`, commit, reset, checkout) | Debounced full status refresh | | Listener | Fires only on semantic `GitState` transitions (including dirty → clean) | -Use `refresh()` when git state may change without a working-tree filesystem event (checkout, reset, stage/unstage). +Use `refresh()` to force a resync after missed watcher events or external operations you do not trust the watcher to catch. ## Types @@ -65,6 +67,8 @@ type GitState = { | 'renamed' | 'copied' | 'unknown'; + staged: boolean; + unstaged: boolean; path: string; oldPath?: string; }; diff --git a/src/cache.test.ts b/src/cache.test.ts index 2bce53a..c52af51 100644 --- a/src/cache.test.ts +++ b/src/cache.test.ts @@ -7,7 +7,7 @@ describe('applyStatesToCache', () => { const cache = new Map(); const changes = applyStatesToCache( cache, - [{ path: 'a.ts', status: 'added' }], + [{ path: 'a.ts', status: 'added', staged: false, unstaged: true }], ['a.ts'], ); @@ -15,30 +15,51 @@ describe('applyStatesToCache', () => { { path: 'a.ts', previous: undefined, - current: { path: 'a.ts', status: 'added' }, + current: { + path: 'a.ts', + status: 'added', + staged: false, + unstaged: true, + }, }, ]); - expect(cache.get('a.ts')).toEqual({ path: 'a.ts', status: 'added' }); + expect(cache.get('a.ts')).toEqual({ + path: 'a.ts', + status: 'added', + staged: false, + unstaged: true, + }); }); it('emits no change when git state is unchanged', () => { const cache = new Map([ - ['a.ts', { path: 'a.ts', status: 'modified' }], + [ + 'a.ts', + { path: 'a.ts', status: 'modified', staged: false, unstaged: true }, + ], ]); const changes = applyStatesToCache( cache, - [{ path: 'a.ts', status: 'modified' }], + [{ path: 'a.ts', status: 'modified', staged: false, unstaged: true }], ['a.ts'], ); expect(changes).toEqual([]); - expect(cache.get('a.ts')).toEqual({ path: 'a.ts', status: 'modified' }); + expect(cache.get('a.ts')).toEqual({ + path: 'a.ts', + status: 'modified', + staged: false, + unstaged: true, + }); }); it('removes clean files from cache', () => { const cache = new Map([ - ['a.ts', { path: 'a.ts', status: 'modified' }], + [ + 'a.ts', + { path: 'a.ts', status: 'modified', staged: false, unstaged: true }, + ], ]); const changes = applyStatesToCache(cache, [], ['a.ts']); @@ -46,7 +67,12 @@ describe('applyStatesToCache', () => { expect(changes).toEqual([ { path: 'a.ts', - previous: { path: 'a.ts', status: 'modified' }, + previous: { + path: 'a.ts', + status: 'modified', + staged: false, + unstaged: true, + }, current: undefined, }, ]); diff --git a/src/git-status.ts b/src/git-status.ts index e9e8362..6040fa1 100644 --- a/src/git-status.ts +++ b/src/git-status.ts @@ -48,6 +48,23 @@ export async function getGitStateForPaths( return readPorcelainStatus(git, normalized); } +export async function getGitMetadataPaths(repoPath: string): Promise { + const git = createGit(repoPath); + const [gitDir, commonGitDir] = await Promise.all([ + git.raw(['rev-parse', '--git-dir']), + git.raw(['rev-parse', '--git-common-dir']), + ]); + + const resolvedGitDir = resolveGitPath(repoPath, gitDir); + const resolvedCommonGitDir = resolveGitPath(repoPath, commonGitDir); + + return [ + resolvedGitDir, + path.join(resolvedCommonGitDir, 'refs'), + path.join(resolvedCommonGitDir, 'packed-refs'), + ]; +} + export function normalizeRepoPath(repoPath: string, targetPath: string): string { const absoluteRepo = path.resolve(repoPath); const absoluteTarget = path.isAbsolute(targetPath) @@ -61,3 +78,10 @@ export function normalizeRepoPath(repoPath: string, targetPath: string): string return relative.split(path.sep).join('/'); } + +function resolveGitPath(repoPath: string, gitPath: string): string { + const trimmed = gitPath.trim(); + return path.isAbsolute(trimmed) + ? path.resolve(trimmed) + : path.resolve(repoPath, trimmed); +} diff --git a/src/porcelain.test.ts b/src/porcelain.test.ts index eb74a6a..241e4c0 100644 --- a/src/porcelain.test.ts +++ b/src/porcelain.test.ts @@ -7,14 +7,26 @@ describe('parsePorcelainV2', () => { '1 .M N... 100644 100644 100644 abc def src/file.ts\0'; const states = parsePorcelainV2(output); expect(states).toEqual([ - { path: 'src/file.ts', status: 'modified' }, + { + path: 'src/file.ts', + status: 'modified', + staged: false, + unstaged: true, + }, ]); }); it('parses untracked file', () => { const output = '? new-file.ts\0'; const states = parsePorcelainV2(output); - expect(states).toEqual([{ path: 'new-file.ts', status: 'added' }]); + expect(states).toEqual([ + { + path: 'new-file.ts', + status: 'added', + staged: false, + unstaged: true, + }, + ]); }); it('parses rename with original path', () => { @@ -26,6 +38,8 @@ describe('parsePorcelainV2', () => { path: 'new/name.ts', oldPath: 'old/name.ts', status: 'renamed', + staged: true, + unstaged: false, }, ]); }); @@ -34,14 +48,28 @@ describe('parsePorcelainV2', () => { const output = '1 .D N... 100644 100644 000000 abc def removed.ts\0'; const states = parsePorcelainV2(output); - expect(states).toEqual([{ path: 'removed.ts', status: 'deleted' }]); + expect(states).toEqual([ + { + path: 'removed.ts', + status: 'deleted', + staged: false, + unstaged: true, + }, + ]); }); it('maps unrecognized XY codes to unknown', () => { const output = '1 .T N... 100644 100644 100644 abc def symlink.ts\0'; const states = parsePorcelainV2(output); - expect(states).toEqual([{ path: 'symlink.ts', status: 'unknown' }]); + expect(states).toEqual([ + { + path: 'symlink.ts', + status: 'unknown', + staged: false, + unstaged: true, + }, + ]); }); }); @@ -49,15 +77,29 @@ describe('statesEqual', () => { it('treats matching states as equal', () => { expect( statesEqual( - { path: 'a.ts', status: 'modified' }, - { path: 'a.ts', status: 'modified' }, + { path: 'a.ts', status: 'modified', staged: false, unstaged: true }, + { path: 'a.ts', status: 'modified', staged: false, unstaged: true }, ), ).toBe(true); }); + it('treats staging transitions as different', () => { + expect( + statesEqual( + { path: 'a.ts', status: 'modified', staged: false, unstaged: true }, + { path: 'a.ts', status: 'modified', staged: true, unstaged: false }, + ), + ).toBe(false); + }); + it('treats missing and present states as different', () => { expect( - statesEqual(undefined, { path: 'a.ts', status: 'added' }), + statesEqual(undefined, { + path: 'a.ts', + status: 'added', + staged: false, + unstaged: true, + }), ).toBe(false); }); }); diff --git a/src/porcelain.ts b/src/porcelain.ts index 4e5dd9d..4d01107 100644 --- a/src/porcelain.ts +++ b/src/porcelain.ts @@ -20,6 +20,8 @@ export function parsePorcelainV2(output: string): GitState[] { states.push({ path: segment.slice(2), status: 'added', + staged: false, + unstaged: true, }); continue; } @@ -42,6 +44,8 @@ export function parsePorcelainV2(output: string): GitState[] { path, oldPath, status: mapXYToStatus(xy), + staged: isChangedStatus(xy[0] ?? '.'), + unstaged: isChangedStatus(xy[1] ?? '.'), }); } } @@ -72,6 +76,10 @@ function mapXYToStatus(xy: string): GitStatus { return 'unknown'; } +function isChangedStatus(status: string): boolean { + return status !== '.'; +} + export function statesEqual(a?: GitState, b?: GitState): boolean { if (!a && !b) { return true; @@ -81,6 +89,10 @@ export function statesEqual(a?: GitState, b?: GitState): boolean { } return ( - a.status === b.status && a.path === b.path && a.oldPath === b.oldPath + a.status === b.status && + a.staged === b.staged && + a.unstaged === b.unstaged && + a.path === b.path && + a.oldPath === b.oldPath ); } diff --git a/src/types.ts b/src/types.ts index c0d09fe..01bd3ec 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,10 @@ export type GitStatus = export type GitState = { status: GitStatus; + /** True when the index has a change for this path. */ + staged: boolean; + /** True when the working tree has an unstaged change for this path. */ + unstaged: boolean; /** Current path in the repo (new path for renames/copies). */ path: string; /** Previous path when status is renamed or copied. */ @@ -25,7 +29,7 @@ export type GitStateWatcherOptions = { debounceMs?: number; /** * Additional chokidar ignore patterns (strings or regexes). - * `.git` and `node_modules` are always ignored. + * `.git` and `node_modules` are always ignored by the working-tree watcher. */ ignored?: Array; }; diff --git a/src/watcher.test.ts b/src/watcher.test.ts index 21c5aa4..20175b8 100644 --- a/src/watcher.test.ts +++ b/src/watcher.test.ts @@ -38,6 +38,11 @@ async function simpleGitCommit(repoPath: string, message: string): Promise await simpleGit(repoPath).commit(message); } +async function simpleGitAdd(repoPath: string, filePath: string): Promise { + const { simpleGit } = await import('simple-git'); + await simpleGit(repoPath).add(filePath); +} + afterEach(async () => { await Promise.all( tempRepos.splice(0).map((repo) => fs.rm(repo, { recursive: true, force: true })), @@ -89,7 +94,51 @@ describe('createGitStateWatcher', () => { expect(changes).toHaveLength(1); expect(changes[0]).toMatchObject({ path: 'watched.ts', - current: { path: 'watched.ts', status: 'modified' }, + current: { + path: 'watched.ts', + status: 'modified', + staged: false, + unstaged: true, + }, + }); + + await watcher.close(); + }); + + it('emits when git metadata changes staged state and clean state', async () => { + const repo = await trackRepo(); + await writeAndAdd(repo, 'tracked.ts', 'v1\n'); + await simpleGitCommit(repo, 'initial'); + await fs.writeFile(path.join(repo, 'tracked.ts'), 'v2\n', 'utf8'); + + const watcher = await createGitStateWatcher(repo, { debounceMs: 75 }); + expect(watcher.getFileState('tracked.ts')).toMatchObject({ + path: 'tracked.ts', + status: 'modified', + staged: false, + unstaged: true, + }); + + const stagedChangesPromise = waitForChanges(watcher); + await simpleGitAdd(repo, 'tracked.ts'); + const stagedChanges = await stagedChangesPromise; + + expect(stagedChanges).toHaveLength(1); + expect(stagedChanges[0]).toMatchObject({ + path: 'tracked.ts', + previous: { staged: false, unstaged: true }, + current: { status: 'modified', staged: true, unstaged: false }, + }); + + const committedChangesPromise = waitForChanges(watcher); + await simpleGitCommit(repo, 'commit tracked change'); + const committedChanges = await committedChangesPromise; + + expect(committedChanges).toHaveLength(1); + expect(committedChanges[0]).toMatchObject({ + path: 'tracked.ts', + previous: { status: 'modified', staged: true, unstaged: false }, + current: undefined, }); await watcher.close(); diff --git a/src/watcher.ts b/src/watcher.ts index d9c994d..1c18dae 100644 --- a/src/watcher.ts +++ b/src/watcher.ts @@ -6,7 +6,12 @@ import { statesToMap, } from './cache.js'; import { statesEqual } from './porcelain.js'; -import { getGitStateForPaths, getRepoGitState, normalizeRepoPath } from './git-status.js'; +import { + getGitMetadataPaths, + getGitStateForPaths, + getRepoGitState, + normalizeRepoPath, +} from './git-status.js'; import type { GitState, GitStateChange, @@ -32,10 +37,11 @@ export async function createGitStateWatcher( const listeners = new Set<(changes: GitStateChange[]) => void>(); const dirtyPaths = new Set(); let debounceTimer: ReturnType | undefined; + let metadataTimer: ReturnType | undefined; let flushPromise: Promise = Promise.resolve(); let closed = false; - const watcher: FSWatcher = chokidar.watch(absoluteRepoPath, { + const workingTreeWatcher: FSWatcher = chokidar.watch(absoluteRepoPath, { ignored: [...DEFAULT_IGNORED, ...(options.ignored ?? [])], ignoreInitial: true, persistent: true, @@ -44,6 +50,19 @@ export async function createGitStateWatcher( pollInterval: 25, }, }); + const workingTreeWatcherReady = waitForWatcherReady(workingTreeWatcher); + const gitMetadataWatcher = chokidar.watch( + await getGitMetadataPaths(absoluteRepoPath), + { + ignoreInitial: true, + persistent: true, + awaitWriteFinish: { + stabilityThreshold: 50, + pollInterval: 25, + }, + }, + ); + const gitMetadataWatcherReady = waitForWatcherReady(gitMetadataWatcher); const emit = (changes: GitStateChange[]): void => { if (changes.length === 0) { @@ -70,6 +89,29 @@ export async function createGitStateWatcher( return changes; }; + const refreshAllInternal = async (): Promise => { + const allStates = await getRepoGitState(absoluteRepoPath); + const previous = new Map(cache); + cache.clear(); + for (const state of allStates) { + cache.set(state.path, state); + } + + const changes: GitStateChange[] = []; + const allPaths = new Set([...previous.keys(), ...cache.keys()]); + + for (const filePath of allPaths) { + const prev = previous.get(filePath); + const curr = cache.get(filePath); + if (!statesEqual(prev, curr)) { + changes.push({ path: filePath, previous: prev, current: curr }); + } + } + + emit(changes); + return changes; + }; + const scheduleFlush = (): void => { if (closed) { return; @@ -96,6 +138,26 @@ export async function createGitStateWatcher( }, debounceMs); }; + const scheduleMetadataRefresh = (): void => { + if (closed) { + return; + } + + if (metadataTimer) { + clearTimeout(metadataTimer); + } + + metadataTimer = setTimeout(() => { + metadataTimer = undefined; + flushPromise = flushPromise + .then(refreshAllInternal) + .then(() => undefined) + .catch(() => { + // Errors are surfaced via refresh(); scheduled flushes stay best-effort. + }); + }, debounceMs); + }; + const markDirty = (absolutePath: string): void => { if (closed) { return; @@ -120,12 +182,20 @@ export async function createGitStateWatcher( scheduleFlush(); }; - watcher.on('all', (event, filePath) => { + workingTreeWatcher.on('all', (event, filePath) => { if (event === 'change' || event === 'add' || event === 'unlink') { markDirty(filePath); } }); + gitMetadataWatcher.on('all', (event) => { + if (event === 'change' || event === 'add' || event === 'unlink') { + scheduleMetadataRefresh(); + } + }); + + await Promise.all([workingTreeWatcherReady, gitMetadataWatcherReady]); + return { getState(): GitState[] { return [...cache.values()]; @@ -146,26 +216,7 @@ export async function createGitStateWatcher( return refreshInternal(normalized); } - const allStates = await getRepoGitState(absoluteRepoPath); - const previous = new Map(cache); - cache.clear(); - for (const state of allStates) { - cache.set(state.path, state); - } - - const changes: GitStateChange[] = []; - const allPaths = new Set([...previous.keys(), ...cache.keys()]); - - for (const filePath of allPaths) { - const prev = previous.get(filePath); - const curr = cache.get(filePath); - if (!statesEqual(prev, curr)) { - changes.push({ path: filePath, previous: prev, current: curr }); - } - } - - emit(changes); - return changes; + return refreshAllInternal(); }, on(event: 'change', listener: (changes: GitStateChange[]) => void): () => void { @@ -189,11 +240,23 @@ export async function createGitStateWatcher( clearTimeout(debounceTimer); debounceTimer = undefined; } + if (metadataTimer) { + clearTimeout(metadataTimer); + metadataTimer = undefined; + } await flushPromise; - await watcher.close(); + await workingTreeWatcher.close(); + await gitMetadataWatcher.close(); listeners.clear(); dirtyPaths.clear(); }, }; } + +function waitForWatcherReady(watcher: FSWatcher): Promise { + return new Promise((resolve, reject) => { + watcher.once('ready', resolve); + watcher.once('error', reject); + }); +} From 822041869011373513cc61f05a45abad362563b4 Mon Sep 17 00:00:00 2001 From: Siddharth Gelera Date: Thu, 28 May 2026 17:27:54 +0530 Subject: [PATCH 2/2] feat: simulator --- README.md | 12 + package-lock.json | 506 ++++++++++++++++++++++++- package.json | 2 + scripts/simulate.mjs | 21 + src/porcelain.test.ts | 2 +- src/porcelain.ts | 13 +- src/simulation.test.ts | 147 +++++++ src/test-helpers.ts | 29 +- src/test-support/repo-fixtures.ts | 32 ++ src/test-support/simulate-cli.ts | 51 +++ src/test-support/simulator.ts | 231 +++++++++++ src/test-support/tree-renderer.test.ts | 44 +++ src/test-support/tree-renderer.ts | 117 ++++++ tsconfig.json | 3 +- 14 files changed, 1181 insertions(+), 29 deletions(-) create mode 100644 scripts/simulate.mjs create mode 100644 src/simulation.test.ts create mode 100644 src/test-support/repo-fixtures.ts create mode 100644 src/test-support/simulate-cli.ts create mode 100644 src/test-support/simulator.ts create mode 100644 src/test-support/tree-renderer.test.ts create mode 100644 src/test-support/tree-renderer.ts diff --git a/README.md b/README.md index f322aa3..6962230 100644 --- a/README.md +++ b/README.md @@ -80,12 +80,24 @@ type GitStateChange = { }; ``` +## Simulation harness + +For scenario-style tests and local inspection, use the test-support simulation harness in [`src/test-support/simulator.ts`](src/test-support/simulator.ts). It runs real git and filesystem operations against `createGitStateWatcher()` and renders a terminal tree with per-file git state badges. + +```bash +npm test -- src/simulation.test.ts +npm run simulate +``` + +Set `GIT_TREE_STATE_SIM_PRINT=1` to print tree snapshots after each step (`npm run simulate` enables this automatically). + ## Development ```bash npm install npm test npm run build +npm run simulate ``` ## CI and release diff --git a/package-lock.json b/package-lock.json index df39a33..b5136f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,12 @@ }, "devDependencies": { "@types/node": "^22.15.21", + "tsx": "^4.20.3", "typescript": "^5.8.3", "vitest": "^3.1.4" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1493,6 +1494,509 @@ "node": ">=14.0.0" } }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 8d5e8a8..a3d811e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "build": "tsc", "test": "vitest run", "test:watch": "vitest", + "simulate": "node scripts/simulate.mjs", "prepublishOnly": "npm run build" }, "keywords": [ @@ -44,6 +45,7 @@ "devDependencies": { "@types/node": "^22.15.21", "typescript": "^5.8.3", + "tsx": "^4.20.3", "vitest": "^3.1.4" }, "engines": { diff --git a/scripts/simulate.mjs b/scripts/simulate.mjs new file mode 100644 index 0000000..fcf468b --- /dev/null +++ b/scripts/simulate.mjs @@ -0,0 +1,21 @@ +import { spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const cliPath = join(root, 'src/test-support/simulate-cli.ts'); + +const result = spawnSync( + 'npx', + ['tsx', cliPath], + { + cwd: root, + stdio: 'inherit', + env: { + ...process.env, + GIT_TREE_STATE_SIM_PRINT: '1', + }, + }, +); + +process.exit(result.status ?? 1); diff --git a/src/porcelain.test.ts b/src/porcelain.test.ts index 241e4c0..42c6f21 100644 --- a/src/porcelain.test.ts +++ b/src/porcelain.test.ts @@ -31,7 +31,7 @@ describe('parsePorcelainV2', () => { it('parses rename with original path', () => { const output = - '2 R. N... 100644 100644 000000 111111 222222 new/name.ts\0old/name.ts\0'; + '2 R. N... 100644 100644 000000 111111 222222 R100 new/name.ts\0old/name.ts\0'; const states = parsePorcelainV2(output); expect(states).toEqual([ { diff --git a/src/porcelain.ts b/src/porcelain.ts index 4d01107..03e3fd2 100644 --- a/src/porcelain.ts +++ b/src/porcelain.ts @@ -29,10 +29,19 @@ export function parsePorcelainV2(output: string): GitState[] { if (segment.startsWith('1 ') || segment.startsWith('2 ')) { const fields = segment.split(' '); const xy = fields[1]; - const path = fields.slice(8).join(' '); + const isRenameOrCopy = segment.startsWith('2 '); + // Porcelain v2 fixed field order (see git-status(1)): + // type 1: ... + // type 2: ... + NUL + // TODO: reaper, might have issues with spaces in file paths, might want to rethink this if + // too many issues come up with the approach + // Paths with spaces are rare; joining from the path field index is sufficient for now. + const path = isRenameOrCopy + ? fields.slice(9).join(' ') + : fields.slice(8).join(' '); let oldPath: string | undefined; - if (segment.startsWith('2 ')) { + if (isRenameOrCopy) { const next = segments[index + 1]; if (next && !RECORD_START.test(next)) { oldPath = next; diff --git a/src/simulation.test.ts b/src/simulation.test.ts new file mode 100644 index 0000000..1dbf29b --- /dev/null +++ b/src/simulation.test.ts @@ -0,0 +1,147 @@ +import { setTimeout as delay } from 'node:timers/promises'; +import { afterEach, describe, expect, it } from 'vitest'; +import { simpleGit } from 'simple-git'; +import { + createTempGitRepo, + writeAndAdd, +} from './test-support/repo-fixtures.js'; +import { createGitStateSimulation } from './test-support/simulator.js'; + +const simulations: Awaited>[] = []; + +afterEach(async () => { + await Promise.all(simulations.splice(0).map((sim) => sim.close())); +}); + +async function trackSimulationWithTrackedFile( + relativePath: string, + contents: string, +) { + const repoPath = await createTempGitRepo(); + await writeAndAdd(repoPath, relativePath, contents); + await simpleGit(repoPath).commit('initial'); + + const sim = await createGitStateSimulation({ repoPath }); + simulations.push(sim); + return sim; +} + +describe('git state simulation scenarios', () => { + it('tracks edit, stage, commit, and clean transitions', async () => { + const sim = await trackSimulationWithTrackedFile('src/app.ts', 'v1\n'); + + const edited = await sim.write('src/app.ts', 'v2\n', { label: 'edit' }); + expect(edited.tree).toContain('[M unstaged]'); + expect(edited.changes[0]?.current).toMatchObject({ + path: 'src/app.ts', + status: 'modified', + staged: false, + unstaged: true, + }); + + const staged = await sim.gitAdd('src/app.ts', { label: 'stage' }); + expect(staged.tree).toContain('[M staged]'); + expect(staged.changes[0]?.current).toMatchObject({ + staged: true, + unstaged: false, + }); + + const committed = await sim.gitCommit('commit app', { label: 'commit' }); + expect(committed.tree).not.toContain('[M'); + expect(committed.changes[0]?.current).toBeUndefined(); + }); + + it('prints staged+unstaged when a staged file is modified again', async () => { + const sim = await trackSimulationWithTrackedFile( + 'src/hunks.ts', + 'line 1\nline 2\n', + ); + + await sim.write('src/hunks.ts', 'line 1 staged\nline 2\n', { + label: 'first edit', + }); + await sim.gitAdd('src/hunks.ts', { label: 'stage first edit' }); + + const modifiedAgain = await sim.write( + 'src/hunks.ts', + 'line 1 staged\nline 2 unstaged\n', + { label: 'second edit after staging' }, + ); + + expect(modifiedAgain.tree).toContain('hunks.ts [M staged+unstaged]'); + expect(modifiedAgain.changes[0]?.current).toMatchObject({ + path: 'src/hunks.ts', + status: 'modified', + staged: true, + unstaged: true, + }); + }); + + it('tracks untracked file through stage and commit', async () => { + const sim = await trackSimulationWithTrackedFile('README.md', '# repo\n'); + + const created = await sim.write('new.ts', 'export {}\n', { label: 'untracked' }); + expect(created.tree).toContain('new.ts [A unstaged]'); + + const staged = await sim.gitAdd('new.ts', { label: 'stage new' }); + expect(staged.tree).toContain('new.ts [A staged]'); + + const committed = await sim.gitCommit('add new file', { label: 'commit new' }); + expect(committed.tree).not.toContain('[A'); + }); + + it('tracks deleted file through stage and commit', async () => { + const sim = await trackSimulationWithTrackedFile('remove-me.ts', 'bye\n'); + + const deleted = await sim.delete('remove-me.ts', { label: 'delete working tree' }); + expect(deleted.tree).toContain('[D unstaged]'); + + const staged = await sim.gitAdd('remove-me.ts', { label: 'stage delete' }); + expect(staged.tree).toContain('[D staged]'); + + const committed = await sim.gitCommit('remove file', { label: 'commit delete' }); + expect(committed.tree).not.toContain('remove-me.ts'); + }); + + it('tracks rename with oldPath metadata', async () => { + const sim = await trackSimulationWithTrackedFile('old/name.ts', 'content\n'); + + const renamed = await sim.rename('old/name.ts', 'new/name.ts', { + label: 'rename', + }); + + expect(renamed.changes.some((change) => change.current?.status === 'renamed')).toBe( + true, + ); + expect(renamed.tree).toContain('new/name.ts'); + expect(renamed.tree).toMatch(/\[R/); + expect(renamed.tree).not.toMatch(/^\s+name\.ts\s*$/m); + expect(renamed.tree).toContain('(old/name.ts -> new/name.ts)'); + }); + + it('emits metadata-only transitions without manual refresh', async () => { + const sim = await trackSimulationWithTrackedFile('meta.ts', 'v1\n'); + await sim.write('meta.ts', 'v2\n', { wait: true }); + + const staged = await sim.gitAdd('meta.ts'); + expect(staged.changes).toHaveLength(1); + expect(staged.changes[0]?.current?.staged).toBe(true); + + const committed = await sim.gitCommit('commit meta'); + expect(committed.changes).toHaveLength(1); + expect(committed.changes[0]?.current).toBeUndefined(); + }); + + it('does not emit extra events for unchanged filesystem writes', async () => { + const sim = await trackSimulationWithTrackedFile('stable.ts', 'same\n'); + + const first = await sim.write('stable.ts', 'same\n', { wait: false }); + await delay(300); + expect(first.changes).toHaveLength(0); + + const second = await sim.write('stable.ts', 'same\n', { wait: false }); + await delay(300); + expect(second.changes).toHaveLength(0); + expect(sim.getState()).toEqual([]); + }); +}); diff --git a/src/test-helpers.ts b/src/test-helpers.ts index 0fe43c5..e39861d 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -1,24 +1,5 @@ -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { simpleGit } from 'simple-git'; - -export async function createTempGitRepo(): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'git-tree-state-test-')); - const git = simpleGit(dir); - await git.init(['-b', 'main']); - await git.addConfig('user.email', 'test@example.com'); - await git.addConfig('user.name', 'Test User'); - return dir; -} - -export async function writeAndAdd( - repoPath: string, - relativePath: string, - contents: string, -): Promise { - const absolutePath = path.join(repoPath, relativePath); - await fs.mkdir(path.dirname(absolutePath), { recursive: true }); - await fs.writeFile(absolutePath, contents, 'utf8'); - await simpleGit(repoPath).add(relativePath); -} \ No newline at end of file +export { + createTempGitRepo, + writeAndAdd, + writeRepoFile, +} from './test-support/repo-fixtures.js'; diff --git a/src/test-support/repo-fixtures.ts b/src/test-support/repo-fixtures.ts new file mode 100644 index 0000000..1d47668 --- /dev/null +++ b/src/test-support/repo-fixtures.ts @@ -0,0 +1,32 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { simpleGit } from 'simple-git'; + +export async function createTempGitRepo(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'git-tree-state-test-')); + const git = simpleGit(dir); + await git.init(['-b', 'main']); + await git.addConfig('user.email', 'test@example.com'); + await git.addConfig('user.name', 'Test User'); + return dir; +} + +export async function writeRepoFile( + repoPath: string, + relativePath: string, + contents: string, +): Promise { + const absolutePath = path.join(repoPath, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, contents, 'utf8'); +} + +export async function writeAndAdd( + repoPath: string, + relativePath: string, + contents: string, +): Promise { + await writeRepoFile(repoPath, relativePath, contents); + await simpleGit(repoPath).add(relativePath); +} diff --git a/src/test-support/simulate-cli.ts b/src/test-support/simulate-cli.ts new file mode 100644 index 0000000..4b09ad3 --- /dev/null +++ b/src/test-support/simulate-cli.ts @@ -0,0 +1,51 @@ +import { setTimeout as delay } from 'node:timers/promises'; +import { createGitStateSimulation } from './simulator.js'; + +process.env.GIT_TREE_STATE_SIM_PRINT = '1'; + +async function main(): Promise { + const sim = await createGitStateSimulation({ debounceMs: 100 }); + + try { + await sim.seedTracked('src/app.ts', 'v1\n'); + await delay(200); + + await sim.write('src/app.ts', 'v2\n', { label: 'edit tracked file' }); + await delay(200); + + await sim.gitAdd('src/app.ts', { label: 'stage changes' }); + await delay(200); + + await sim.write('src/app.ts', 'v3\n', { + label: 'modify staged file again', + }); + await delay(200); + + await sim.gitAdd('src/app.ts', { label: 'stage second change' }); + await delay(200); + + await sim.gitCommit('commit staged changes', { label: 'commit' }); + await delay(200); + + await sim.write('src/new.ts', 'export {}\n', { label: 'add untracked file' }); + await delay(200); + + await sim.gitAdd('src/new.ts', { label: 'stage untracked file' }); + await delay(200); + + await sim.gitCommit('add new file', { label: 'commit new file' }); + await delay(200); + + await sim.rename('src/app.ts', 'src/app-renamed.ts', { label: 'rename file' }); + await delay(200); + + await sim.gitCommit('commit rename', { label: 'commit rename' }); + } finally { + await sim.close(); + } +} + +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/src/test-support/simulator.ts b/src/test-support/simulator.ts new file mode 100644 index 0000000..4677bf5 --- /dev/null +++ b/src/test-support/simulator.ts @@ -0,0 +1,231 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { simpleGit } from 'simple-git'; +import { createGitStateWatcher } from '../watcher.js'; +import type { GitState, GitStateChange, GitStateWatcher } from '../types.js'; +import { createTempGitRepo, writeRepoFile } from './repo-fixtures.js'; +import { collectRepoPaths, renderGitStateTree } from './tree-renderer.js'; + +export type SimulationStepOptions = { + /** Wait for watcher change events before returning. Default: true */ + wait?: boolean; + /** Label printed when GIT_TREE_STATE_SIM_PRINT=1 */ + label?: string; +}; + +export type SimulationSnapshot = { + label?: string; + changes: GitStateChange[]; + state: GitState[]; + tree: string; +}; + +export type GitStateSimulationOptions = { + debounceMs?: number; + repoPath?: string; +}; + +export type GitStateSimulation = { + repoPath: string; + watcher: GitStateWatcher; + write( + relativePath: string, + contents: string, + options?: SimulationStepOptions, + ): Promise; + mkdir(relativePath: string, options?: SimulationStepOptions): Promise; + delete(relativePath: string, options?: SimulationStepOptions): Promise; + rename( + fromPath: string, + toPath: string, + options?: SimulationStepOptions, + ): Promise; + gitAdd(relativePath: string, options?: SimulationStepOptions): Promise; + gitCommit(message: string, options?: SimulationStepOptions): Promise; + gitReset( + mode: 'soft' | 'mixed' | 'hard', + options?: SimulationStepOptions, + ): Promise; + refresh(options?: SimulationStepOptions): Promise; + seedTracked( + relativePath: string, + contents: string, + commitMessage?: string, + ): Promise; + getState(): GitState[]; + renderTree(): Promise; + snapshot(options?: SimulationStepOptions): Promise; + close(): Promise; +}; + +const SIM_PRINT = process.env.GIT_TREE_STATE_SIM_PRINT === '1'; +const DEFAULT_SIM_DEBOUNCE_MS = 75; + +export async function createGitStateSimulation( + options: GitStateSimulationOptions = {}, +): Promise { + const createdRepo = !options.repoPath; + const repoPath = options.repoPath ?? (await createTempGitRepo()); + const debounceMs = options.debounceMs ?? DEFAULT_SIM_DEBOUNCE_MS; + const watcher = await createGitStateWatcher(repoPath, { debounceMs }); + + let pendingChanges: GitStateChange[] = []; + const unsubscribe = watcher.on('change', (batch) => { + pendingChanges.push(...batch); + }); + + const git = () => simpleGit(repoPath); + + async function collectChangesAfterAction( + wait: boolean, + ): Promise { + await delay(debounceMs + 150); + + if (!wait) { + const changes = [...pendingChanges]; + pendingChanges = []; + return changes; + } + + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (pendingChanges.length > 0) { + const changes = [...pendingChanges]; + pendingChanges = []; + return changes; + } + await delay(50); + } + + throw new Error('Timed out waiting for git state changes'); + } + + async function snapshot(step?: SimulationStepOptions): Promise { + const filesystemPaths = await collectRepoPaths(repoPath); + const state = watcher.getState(); + const tree = renderGitStateTree(repoPath, state, filesystemPaths); + const result: SimulationSnapshot = { + label: step?.label, + changes: [], + state, + tree, + }; + + if (SIM_PRINT && step?.label) { + console.log(`\n--- ${step.label} ---\n${tree}\n`); + } + + return result; + } + + async function runStep( + action: () => Promise, + options: SimulationStepOptions = {}, + ): Promise { + const wait = options.wait ?? true; + pendingChanges = []; + + await action(); + + const changes = await collectChangesAfterAction(wait); + const result = await snapshot(options); + result.changes = changes; + return result; + } + + return { + repoPath, + watcher, + + write(relativePath, contents, options) { + return runStep(async () => { + await writeRepoFile(repoPath, relativePath, contents); + }, options); + }, + + mkdir(relativePath, options) { + return runStep(async () => { + await fs.mkdir(path.join(repoPath, relativePath), { recursive: true }); + }, options); + }, + + delete(relativePath, options) { + return runStep(async () => { + await fs.rm(path.join(repoPath, relativePath), { force: true }); + }, options); + }, + + rename(fromPath, toPath, options) { + return runStep(async () => { + const destination = path.join(repoPath, toPath); + await fs.mkdir(path.dirname(destination), { recursive: true }); + await git().mv(fromPath, toPath); + }, options); + }, + + gitAdd(relativePath, options) { + return runStep(async () => { + await git().add(relativePath); + }, options); + }, + + gitCommit(message, options) { + return runStep(async () => { + await git().commit(message); + }, options); + }, + + gitReset(mode, options) { + return runStep(async () => { + await git().reset([`--${mode}`]); + }, options); + }, + + refresh(options) { + return runStep(async () => { + await watcher.refresh(); + }, options); + }, + + async seedTracked(relativePath, contents, commitMessage = 'initial') { + await runStep( + async () => { + await writeRepoFile(repoPath, relativePath, contents); + }, + { label: `seed write ${relativePath}` }, + ); + await runStep( + async () => { + await git().add(relativePath); + }, + { label: `seed add ${relativePath}` }, + ); + return runStep( + async () => { + await git().commit(commitMessage); + }, + { label: `seed commit ${relativePath}` }, + ); + }, + + getState() { + return watcher.getState(); + }, + + async renderTree() { + const filesystemPaths = await collectRepoPaths(repoPath); + return renderGitStateTree(repoPath, watcher.getState(), filesystemPaths); + }, + + snapshot, + + async close() { + unsubscribe(); + await watcher.close(); + if (createdRepo) { + await fs.rm(repoPath, { recursive: true, force: true }); + } + }, + }; +} diff --git a/src/test-support/tree-renderer.test.ts b/src/test-support/tree-renderer.test.ts new file mode 100644 index 0000000..bb9ae4b --- /dev/null +++ b/src/test-support/tree-renderer.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { renderGitStateTree } from './tree-renderer.js'; + +describe('renderGitStateTree', () => { + it('does not show rename source as a separate clean file', () => { + const tree = renderGitStateTree( + '/repo', + [ + { + path: 'src/app-renamed.ts', + oldPath: 'src/app.ts', + status: 'renamed', + staged: true, + unstaged: false, + }, + ], + ['src/app-renamed.ts'], + ); + + expect(tree).toMatchInlineSnapshot(` + "repo/ + app-renamed.ts [R staged] (src/app.ts -> src/app-renamed.ts)" + `); + }); + + it('still shows copy source when the original file remains on disk', () => { + const tree = renderGitStateTree( + '/repo', + [ + { + path: 'src/copy.ts', + oldPath: 'src/original.ts', + status: 'copied', + staged: true, + unstaged: false, + }, + ], + ['src/original.ts', 'src/copy.ts'], + ); + + expect(tree).toContain('original.ts'); + expect(tree).toContain('copy.ts [C staged]'); + }); +}); diff --git a/src/test-support/tree-renderer.ts b/src/test-support/tree-renderer.ts new file mode 100644 index 0000000..fd2e906 --- /dev/null +++ b/src/test-support/tree-renderer.ts @@ -0,0 +1,117 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { GitState, GitStatus } from '../types.js'; + +const SKIP_DIRS = new Set(['.git', 'node_modules']); + +export async function collectRepoPaths(repoPath: string): Promise { + const paths: string[] = []; + + async function walk(relativeDir: string): Promise { + const absoluteDir = relativeDir + ? path.join(repoPath, relativeDir) + : repoPath; + const entries = await fs.readdir(absoluteDir, { withFileTypes: true }); + + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) { + continue; + } + + const relativePath = relativeDir + ? path.posix.join(relativeDir.split(path.sep).join('/'), entry.name) + : entry.name; + + if (entry.isDirectory()) { + await walk(relativePath); + } else if (entry.isFile()) { + paths.push(relativePath); + } + } + } + + await walk(''); + return paths.sort(); +} + +export function renderGitStateTree( + repoPath: string, + states: GitState[], + filesystemPaths: string[], +): string { + const stateByPath = new Map(states.map((state) => [state.path, state])); + const filesystemSet = new Set(filesystemPaths); + const allPaths = new Set([ + ...filesystemPaths, + ...states.map((state) => state.path), + ]); + + // Rename sources are not separate tree rows once the file has moved on disk. + // The destination entry carries `(oldPath -> path)` in its badge. + for (const state of states) { + if (state.oldPath && filesystemSet.has(state.oldPath)) { + allPaths.add(state.oldPath); + } + } + + const rootLabel = path.basename(repoPath) || repoPath; + const lines: string[] = [`${rootLabel}/`]; + + const sortedPaths = [...allPaths].sort(); + for (const filePath of sortedPaths) { + const segments = filePath.split('/'); + const indent = ' '.repeat(segments.length); + const fileName = segments.at(-1) ?? filePath; + const state = stateByPath.get(filePath); + const badge = formatStateBadge(state); + + lines.push(`${indent}${fileName}${badge}`); + } + + return lines.join('\n'); +} + +function formatStateBadge(state?: GitState): string { + if (!state) { + return ''; + } + + const code = statusCode(state.status); + const stageLabel = formatStageLabel(state); + const rename = + state.oldPath && state.oldPath !== state.path + ? ` (${state.oldPath} -> ${state.path})` + : ''; + + return ` [${code}${stageLabel ? ` ${stageLabel}` : ''}]${rename}`; +} + +function statusCode(status: GitStatus): string { + switch (status) { + case 'added': + return 'A'; + case 'modified': + return 'M'; + case 'deleted': + return 'D'; + case 'renamed': + return 'R'; + case 'copied': + return 'C'; + default: + return '?'; + } +} + +function formatStageLabel(state: GitState): string { + if (state.staged && state.unstaged) { + return 'staged+unstaged'; + } + if (state.staged) { + return 'staged'; + } + if (state.unstaged) { + return 'unstaged'; + } + return ''; +} diff --git a/tsconfig.json b/tsconfig.json index 56272b9..b8a041e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,7 @@ "node_modules", "dist", "src/**/*.test.ts", - "src/test-helpers.ts" + "src/test-helpers.ts", + "src/test-support/**" ] }