Skip to content

Commit 1c87498

Browse files
committed
git: zed-style refresh via .git metadata watch
1 parent 6eba875 commit 1c87498

File tree

3 files changed

+141
-12
lines changed

3 files changed

+141
-12
lines changed

packages/desktop/src/features/git/FileWatcher.ts

Lines changed: 117 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,42 @@
11
import { EventEmitter } from 'events';
2-
import { watch, FSWatcher } from 'fs';
2+
import { watch, FSWatcher, existsSync, readFileSync, statSync } from 'fs';
3+
import { dirname, isAbsolute, join, resolve } from 'path';
34
import type { Logger } from '../../infrastructure/logging/logger';
45

56
interface WatchedSession {
67
sessionId: string;
78
worktreePath: string;
89
watcher?: FSWatcher;
10+
gitWatchers?: FSWatcher[];
911
lastModified: number;
1012
pendingRefresh: boolean;
1113
}
1214

15+
export function resolveGitDir(worktreePath: string): string | null {
16+
const dotGit = join(worktreePath, '.git');
17+
if (!existsSync(dotGit)) return null;
18+
19+
try {
20+
const stat = statSync(dotGit);
21+
if (stat.isDirectory()) return dotGit;
22+
} catch {
23+
// ignore
24+
}
25+
26+
// In a linked worktree, `.git` is a file containing `gitdir: <path>`
27+
try {
28+
const content = readFileSync(dotGit, 'utf8').trim();
29+
const m = content.match(/^gitdir:\s*(.+)\s*$/i);
30+
if (!m) return null;
31+
const raw = m[1].trim();
32+
if (!raw) return null;
33+
const base = dirname(dotGit);
34+
return isAbsolute(raw) ? raw : resolve(base, raw);
35+
} catch {
36+
return null;
37+
}
38+
}
39+
1340
/**
1441
* Smart file watcher that detects when git status actually needs refreshing
1542
*
@@ -21,7 +48,8 @@ interface WatchedSession {
2148
export class GitFileWatcher extends EventEmitter {
2249
private watchedSessions: Map<string, WatchedSession> = new Map();
2350
private refreshDebounceTimers: Map<string, NodeJS.Timeout> = new Map();
24-
private readonly DEBOUNCE_MS = 1500; // 1.5 second debounce for file changes
51+
private readonly WORKTREE_DEBOUNCE_MS = 200; // fast for UI responsiveness
52+
private readonly GIT_DEBOUNCE_MS = 50; // index/HEAD updates should feel instant
2553
private readonly IGNORE_PATTERNS = [
2654
'.git/',
2755
'node_modules/',
@@ -49,17 +77,35 @@ export class GitFileWatcher extends EventEmitter {
4977
this.logger?.info(`[GitFileWatcher] Starting watch for session ${sessionId} at ${worktreePath}`);
5078

5179
try {
52-
// Create a watcher for the worktree directory
53-
const watcher = watch(worktreePath, { recursive: true }, (eventType, filename) => {
54-
if (filename) {
55-
this.handleFileChange(sessionId, filename, eventType);
56-
}
57-
});
80+
let watcher: FSWatcher | undefined;
81+
try {
82+
// Prefer recursive watch when available (macOS/Windows).
83+
watcher = watch(worktreePath, { recursive: true }, (eventType, filename) => {
84+
if (filename) {
85+
this.handleFileChange(sessionId, filename, eventType);
86+
} else {
87+
// Some platforms do not provide filename; still refresh.
88+
this.handleWorktreeUnknownChange(sessionId, eventType);
89+
}
90+
});
91+
} catch {
92+
// Fallback: non-recursive watch for top-level changes.
93+
watcher = watch(worktreePath, { recursive: false }, (eventType, filename) => {
94+
if (filename) {
95+
this.handleFileChange(sessionId, filename, eventType);
96+
} else {
97+
this.handleWorktreeUnknownChange(sessionId, eventType);
98+
}
99+
});
100+
}
101+
102+
const gitWatchers = this.startGitMetadataWatch(sessionId, worktreePath);
58103

59104
this.watchedSessions.set(sessionId, {
60105
sessionId,
61106
worktreePath,
62107
watcher,
108+
gitWatchers,
63109
lastModified: Date.now(),
64110
pendingRefresh: false
65111
});
@@ -75,6 +121,9 @@ export class GitFileWatcher extends EventEmitter {
75121
const session = this.watchedSessions.get(sessionId);
76122
if (session) {
77123
session.watcher?.close();
124+
session.gitWatchers?.forEach((w) => {
125+
try { w.close(); } catch { /* ignore */ }
126+
});
78127
this.watchedSessions.delete(sessionId);
79128

80129
// Clear any pending refresh timer
@@ -114,7 +163,23 @@ export class GitFileWatcher extends EventEmitter {
114163
session.pendingRefresh = true;
115164

116165
// Debounce the refresh to batch rapid changes
117-
this.scheduleRefreshCheck(sessionId);
166+
this.scheduleRefreshCheck(sessionId, this.WORKTREE_DEBOUNCE_MS);
167+
}
168+
169+
private handleWorktreeUnknownChange(sessionId: string, eventType: string): void {
170+
const session = this.watchedSessions.get(sessionId);
171+
if (!session) return;
172+
session.lastModified = Date.now();
173+
session.pendingRefresh = true;
174+
this.scheduleRefreshCheck(sessionId, this.WORKTREE_DEBOUNCE_MS);
175+
}
176+
177+
private handleGitMetadataChange(sessionId: string, filename: string, eventType: string): void {
178+
const session = this.watchedSessions.get(sessionId);
179+
if (!session) return;
180+
session.lastModified = Date.now();
181+
session.pendingRefresh = true;
182+
this.scheduleRefreshCheck(sessionId, this.GIT_DEBOUNCE_MS);
118183
}
119184

120185
/**
@@ -155,10 +220,51 @@ export class GitFileWatcher extends EventEmitter {
155220
return false;
156221
}
157222

223+
private startGitMetadataWatch(sessionId: string, worktreePath: string): FSWatcher[] {
224+
const gitdir = resolveGitDir(worktreePath);
225+
if (!gitdir) return [];
226+
227+
// Zed-style: watch git metadata files that reflect status changes instantly.
228+
const paths = [
229+
join(gitdir, 'index'),
230+
join(gitdir, 'HEAD'),
231+
join(gitdir, 'logs', 'HEAD'),
232+
join(gitdir, 'packed-refs'),
233+
];
234+
235+
const watchers: FSWatcher[] = [];
236+
237+
for (const p of paths) {
238+
try {
239+
const w = watch(p, { persistent: true }, (eventType) => {
240+
this.handleGitMetadataChange(sessionId, p, eventType);
241+
});
242+
watchers.push(w);
243+
} catch {
244+
// ignore missing files / unsupported watch
245+
}
246+
}
247+
248+
// Also watch the gitdir itself for ref changes (best-effort, non-recursive).
249+
try {
250+
const w = watch(gitdir, { persistent: true }, (eventType, filename) => {
251+
const name = filename ? String(filename) : '';
252+
if (name.startsWith('refs') || name === 'refs' || name === 'packed-refs') {
253+
this.handleGitMetadataChange(sessionId, join(gitdir, name), eventType);
254+
}
255+
});
256+
watchers.push(w);
257+
} catch {
258+
// ignore
259+
}
260+
261+
return watchers;
262+
}
263+
158264
/**
159265
* Schedule a refresh check for a session
160266
*/
161-
private scheduleRefreshCheck(sessionId: string): void {
267+
private scheduleRefreshCheck(sessionId: string, debounceMs: number): void {
162268
// Clear existing timer
163269
const existingTimer = this.refreshDebounceTimers.get(sessionId);
164270
if (existingTimer) {
@@ -169,7 +275,7 @@ export class GitFileWatcher extends EventEmitter {
169275
const timer = setTimeout(() => {
170276
this.refreshDebounceTimers.delete(sessionId);
171277
this.performRefreshCheck(sessionId);
172-
}, this.DEBOUNCE_MS);
278+
}, debounceMs);
173279

174280
this.refreshDebounceTimers.set(sessionId, timer);
175281
}

packages/desktop/src/features/git/StatusManager.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export class GitStatusManager extends EventEmitter {
2323
// Smart visibility-aware polling for active sessions only
2424
private readonly CACHE_TTL_MS = 5000; // 5 seconds cache
2525
private refreshDebounceTimers: Map<string, NodeJS.Timeout> = new Map();
26-
private readonly DEBOUNCE_MS = 2000; // 2 seconds debounce to batch rapid changes
26+
private readonly DEBOUNCE_MS = 250; // Keep status refresh responsive for active session
2727
private gitLogger: GitStatusLogger;
2828
private fileWatcher: GitFileWatcher;
2929

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { mkdtempSync, writeFileSync, mkdirSync } from 'fs';
3+
import { tmpdir } from 'os';
4+
import { join } from 'path';
5+
import { resolveGitDir } from '../FileWatcher';
6+
7+
describe('resolveGitDir', () => {
8+
it('returns .git when it is a directory', () => {
9+
const root = mkdtempSync(join(tmpdir(), 'snowtree-fw-'));
10+
const git = join(root, '.git');
11+
mkdirSync(git, { recursive: true });
12+
expect(resolveGitDir(root)).toBe(git);
13+
});
14+
15+
it('resolves gitdir from .git file (linked worktree)', () => {
16+
const root = mkdtempSync(join(tmpdir(), 'snowtree-fw-'));
17+
const actual = join(root, 'actual-gitdir');
18+
mkdirSync(actual, { recursive: true });
19+
writeFileSync(join(root, '.git'), `gitdir: ${actual}\n`, 'utf8');
20+
expect(resolveGitDir(root)).toBe(actual);
21+
});
22+
});
23+

0 commit comments

Comments
 (0)