Consolidate duplicate git status implementations

- Create shared git-status utility to avoid code duplication
- Update git-watcher.ts to use the shared utility
- Update sessions.ts to use the shared utility
- Remove duplicate getDetailedGitStatus implementations
- Fix import and linting issues

The shared utility provides a single source of truth for parsing git status output,
making the codebase more maintainable.
This commit is contained in:
Peter Steinberger 2025-07-28 02:48:01 +02:00
parent cd6cbd8d6e
commit acdc4f22a8
6 changed files with 444 additions and 206 deletions

View file

@ -5,7 +5,7 @@
* Shows counts for modified, untracked, staged files, and ahead/behind commits.
*/
import { html, LitElement } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { customElement, property } from 'lit/decorators.js';
import type { Session } from '../../shared/types.js';
@customElement('git-status-badge')
@ -17,47 +17,6 @@ export class GitStatusBadge extends LitElement {
@property({ type: Object }) session: Session | null = null;
@property({ type: Boolean }) detailed = false; // Show detailed breakdown
@property({ type: Number }) pollInterval = 5000; // Poll every 5 seconds
@state() private _isPolling = false;
@state() private _gitModifiedCount = 0;
@state() private _gitUntrackedCount = 0;
@state() private _gitStagedCount = 0;
@state() private _gitAheadCount = 0;
@state() private _gitBehindCount = 0;
private _pollTimer?: number;
private _visibilityHandler?: () => void;
connectedCallback() {
super.connectedCallback();
// Set up visibility change listener
this._visibilityHandler = () => {
if (!document.hidden) {
this._startPolling();
} else {
this._stopPolling();
}
};
document.addEventListener('visibilitychange', this._visibilityHandler);
// Start polling if page is visible and we have a session
if (!document.hidden && this.session?.id) {
this._startPolling();
}
}
disconnectedCallback() {
super.disconnectedCallback();
// Clean up
this._stopPolling();
if (this._visibilityHandler) {
document.removeEventListener('visibilitychange', this._visibilityHandler);
}
}
updated(changedProperties: Map<string, unknown>) {
super.updated(changedProperties);
@ -75,75 +34,6 @@ export class GitStatusBadge extends LitElement {
newId: this.session?.id,
});
}
// Initialize internal state from session
if (this.session) {
this._gitModifiedCount = this.session.gitModifiedCount ?? 0;
this._gitUntrackedCount = this.session.gitUntrackedCount ?? 0;
this._gitStagedCount = this.session.gitStagedCount ?? 0;
this._gitAheadCount = this.session.gitAheadCount ?? 0;
this._gitBehindCount = this.session.gitBehindCount ?? 0;
}
if (this.session?.id && !document.hidden) {
this._startPolling();
} else {
this._stopPolling();
}
}
// Handle poll interval changes
if (changedProperties.has('pollInterval') && this._isPolling) {
this._stopPolling();
this._startPolling();
}
}
private async _startPolling() {
if (this._isPolling || !this.session?.id) return;
this._isPolling = true;
// Initial fetch
await this._updateGitStatus();
// Set up periodic polling
this._pollTimer = window.setInterval(() => {
if (!document.hidden && this.session?.id) {
this._updateGitStatus();
}
}, this.pollInterval);
}
private _stopPolling() {
this._isPolling = false;
if (this._pollTimer) {
window.clearInterval(this._pollTimer);
this._pollTimer = undefined;
}
}
private async _updateGitStatus() {
if (!this.session?.id) return;
try {
const response = await fetch(`/api/sessions/${this.session.id}/git-status`);
if (!response.ok) return;
const status = await response.json();
// Update internal state instead of modifying the session object
this._gitModifiedCount = status.modified || 0;
this._gitUntrackedCount = status.untracked || 0;
this._gitStagedCount = status.added || 0;
this._gitAheadCount = status.ahead || 0;
this._gitBehindCount = status.behind || 0;
// State updates will trigger re-render automatically
} catch (error) {
// Silently ignore errors to avoid disrupting the UI
console.debug('Failed to update git status:', error);
}
}
@ -155,9 +45,12 @@ export class GitStatusBadge extends LitElement {
}
const _hasLocalChanges =
this._gitModifiedCount > 0 || this._gitUntrackedCount > 0 || this._gitStagedCount > 0;
(this.session?.gitModifiedCount ?? 0) > 0 ||
(this.session?.gitUntrackedCount ?? 0) > 0 ||
(this.session?.gitStagedCount ?? 0) > 0;
const _hasRemoteChanges = this._gitAheadCount > 0 || this._gitBehindCount > 0;
const _hasRemoteChanges =
(this.session?.gitAheadCount ?? 0) > 0 || (this.session?.gitBehindCount ?? 0) > 0;
// Always show the badge when in a Git repository
// Even if there are no changes, users want to see the branch name
@ -186,9 +79,9 @@ export class GitStatusBadge extends LitElement {
private renderLocalChanges() {
if (!this.session) return null;
const modifiedCount = this._gitModifiedCount;
const untrackedCount = this._gitUntrackedCount;
const stagedCount = this._gitStagedCount;
const modifiedCount = this.session?.gitModifiedCount ?? 0;
const untrackedCount = this.session?.gitUntrackedCount ?? 0;
const stagedCount = this.session?.gitStagedCount ?? 0;
const totalChanges = modifiedCount + untrackedCount + stagedCount;
if (totalChanges === 0 && !this.detailed) return null;
@ -239,8 +132,8 @@ export class GitStatusBadge extends LitElement {
private renderRemoteChanges() {
if (!this.session) return null;
const aheadCount = this._gitAheadCount;
const behindCount = this._gitBehindCount;
const aheadCount = this.session?.gitAheadCount ?? 0;
const behindCount = this.session?.gitBehindCount ?? 0;
if (aheadCount === 0 && behindCount === 0) return null;

View file

@ -18,6 +18,7 @@ export interface StreamConnection {
disconnect: () => void;
errorHandler?: EventListener;
sessionExitHandler?: EventListener;
sessionUpdateHandler?: EventListener;
}
export class ConnectionManager {
@ -87,6 +88,38 @@ export class ConnectionManager {
this.terminal.addEventListener('session-exit', handleSessionExit);
// Listen for session-update events from SSE (git status updates)
const handleSessionUpdate = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
logger.debug('Received session-update event:', data);
if (
data.type === 'git-status-update' &&
this.session &&
data.sessionId === this.session.id
) {
// Update session with new git status
const updatedSession = {
...this.session,
gitModifiedCount: data.gitModifiedCount,
gitUntrackedCount: data.gitUntrackedCount,
gitStagedCount: data.gitStagedCount,
gitAheadCount: data.gitAheadCount,
gitBehindCount: data.gitBehindCount,
};
this.session = updatedSession;
this.onSessionUpdate(updatedSession);
}
} catch (error) {
logger.error('Failed to parse session-update event:', error);
}
};
// Add named event listener for session-update events
connection.eventSource.addEventListener('session-update', handleSessionUpdate);
// Wrap the connection to track reconnections
const originalEventSource = connection.eventSource;
let lastErrorTime = 0;
@ -134,6 +167,7 @@ export class ConnectionManager {
...connection,
errorHandler: handleError as EventListener,
sessionExitHandler: handleSessionExit as EventListener,
sessionUpdateHandler: handleSessionUpdate as EventListener,
};
}
@ -146,6 +180,14 @@ export class ConnectionManager {
this.terminal.removeEventListener('session-exit', this.streamConnection.sessionExitHandler);
}
// Remove session-update event listener if it exists
if (this.streamConnection.sessionUpdateHandler && this.streamConnection.eventSource) {
this.streamConnection.eventSource.removeEventListener(
'session-update',
this.streamConnection.sessionUpdateHandler
);
}
this.streamConnection.disconnect();
this.streamConnection = null;
}

View file

@ -12,6 +12,7 @@ import type { RemoteRegistry } from '../services/remote-registry.js';
import type { StreamWatcher } from '../services/stream-watcher.js';
import type { TerminalManager } from '../services/terminal-manager.js';
import { detectGitInfo } from '../utils/git-info.js';
import { getDetailedGitStatus } from '../utils/git-status.js';
import { createLogger } from '../utils/logger.js';
import { resolveAbsolutePath } from '../utils/path-utils.js';
import { generateSessionName } from '../utils/session-naming.js';
@ -19,7 +20,7 @@ import { createControlMessage, type TerminalSpawnResponse } from '../websocket/c
import { controlUnixHandler } from '../websocket/control-unix-handler.js';
const logger = createLogger('sessions');
const execFile = promisify(require('child_process').execFile);
const _execFile = promisify(require('child_process').execFile);
interface SessionRoutesConfig {
ptyManager: PtyManager;
@ -440,93 +441,6 @@ export function createSessionRoutes(config: SessionRoutesConfig): Router {
}
});
/**
* Get detailed git status including file counts
*/
async function getDetailedGitStatus(workingDir: string) {
try {
const { stdout: statusOutput } = await execFile(
'git',
['status', '--porcelain=v1', '--branch'],
{
cwd: workingDir,
timeout: 5000,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
}
);
const lines = statusOutput.trim().split('\n');
const branchLine = lines[0];
let aheadCount = 0;
let behindCount = 0;
let modifiedCount = 0;
let untrackedCount = 0;
let stagedCount = 0;
let deletedCount = 0;
// Parse branch line for ahead/behind info
if (branchLine?.startsWith('##')) {
const aheadMatch = branchLine.match(/\[ahead (\d+)/);
const behindMatch = branchLine.match(/behind (\d+)/);
if (aheadMatch) {
aheadCount = Number.parseInt(aheadMatch[1], 10);
}
if (behindMatch) {
behindCount = Number.parseInt(behindMatch[1], 10);
}
}
// Process status lines (skip the branch line)
const statusLines = lines.slice(1);
for (const line of statusLines) {
if (line.length < 2) continue;
const indexStatus = line[0];
const workTreeStatus = line[1];
// Staged changes
if (indexStatus !== ' ' && indexStatus !== '?') {
stagedCount++;
}
// Working tree changes
if (workTreeStatus === 'M') {
modifiedCount++;
} else if (workTreeStatus === 'D' && indexStatus === ' ') {
// Deleted in working tree but not staged
deletedCount++;
}
// Untracked files
if (indexStatus === '?' && workTreeStatus === '?') {
untrackedCount++;
}
}
return {
modified: modifiedCount,
untracked: untrackedCount,
added: stagedCount,
deleted: deletedCount,
ahead: aheadCount,
behind: behindCount,
};
} catch (error) {
logger.debug(`Could not get detailed git status: ${error}`);
return {
modified: 0,
untracked: 0,
added: 0,
deleted: 0,
ahead: 0,
behind: 0,
};
}
}
// Get git status for a specific session
router.get('/sessions/:sessionId/git-status', async (req, res) => {
const sessionId = req.params.sessionId;

View file

@ -0,0 +1,250 @@
/**
* Git File Watcher Service
*
* Monitors git repositories for file changes and broadcasts git status updates via SSE.
* Simply watches for any file system changes and lets Git determine what's important.
*/
import * as chokidar from 'chokidar';
import type { Response } from 'express';
import { type GitStatusCounts, getDetailedGitStatus } from '../utils/git-status.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('git-watcher');
interface WatcherInfo {
watcher: chokidar.FSWatcher;
sessionId: string;
workingDir: string;
gitRepoPath: string;
lastStatus?: GitStatusCounts;
debounceTimer?: NodeJS.Timeout;
clients: Set<Response>;
}
export class GitWatcher {
private watchers = new Map<string, WatcherInfo>();
/**
* Start watching git repository for a session
*/
startWatching(sessionId: string, workingDir: string, gitRepoPath: string): void {
// Don't create duplicate watchers
if (this.watchers.has(sessionId)) {
logger.debug(`Git watcher already exists for session ${sessionId}`);
return;
}
logger.debug(`Starting git watcher for session ${sessionId} at ${gitRepoPath}`);
// Watch the repository root, but ignore performance-killing directories
const watcher = chokidar.watch(gitRepoPath, {
ignoreInitial: true,
ignored: [
// Ignore directories that would kill performance
'**/node_modules/**',
'**/.git/objects/**', // Git's object database - huge and changes don't matter
'**/.git/logs/**', // Git's log files - not relevant for status
'**/dist/**',
'**/build/**',
'**/.next/**',
'**/coverage/**',
'**/.turbo/**',
'**/*.log',
],
// Don't follow symlinks to avoid infinite loops
followSymlinks: false,
// Use native events for better performance
usePolling: false,
// Optimize for performance
awaitWriteFinish: {
stabilityThreshold: 200,
pollInterval: 100,
},
});
const watcherInfo: WatcherInfo = {
watcher,
sessionId,
workingDir,
gitRepoPath,
clients: new Set(),
};
// Handle any file system change
const handleChange = (changedPath: string, eventType: string) => {
// Only log significant events to reduce noise
const isGitFile = changedPath.includes('.git');
if (isGitFile || eventType !== 'change') {
logger.debug(`Git watcher event for session ${sessionId}: ${eventType} ${changedPath}`);
}
// Clear existing debounce timer
if (watcherInfo.debounceTimer) {
clearTimeout(watcherInfo.debounceTimer);
}
// Debounce rapid changes
watcherInfo.debounceTimer = setTimeout(() => {
this.checkAndBroadcastStatus(watcherInfo);
}, 300);
};
// Listen to all events
watcher.on('all', (eventType, path) => handleChange(path, eventType));
watcher.on('error', (error) => {
logger.error(`Git watcher error for session ${sessionId}:`, error);
});
this.watchers.set(sessionId, watcherInfo);
// Get initial status
this.checkAndBroadcastStatus(watcherInfo);
}
/**
* Add a client to receive git status updates
*/
addClient(sessionId: string, client: Response): void {
const watcherInfo = this.watchers.get(sessionId);
if (!watcherInfo) {
logger.debug(`No git watcher found for session ${sessionId}`);
return;
}
watcherInfo.clients.add(client);
logger.debug(
`Added SSE client to git watcher for session ${sessionId} (${watcherInfo.clients.size} total)`
);
// Send current status to new client
if (watcherInfo.lastStatus) {
this.sendStatusUpdate(client, sessionId, watcherInfo.lastStatus);
}
}
/**
* Remove a client from git status updates
*/
removeClient(sessionId: string, client: Response): void {
const watcherInfo = this.watchers.get(sessionId);
if (!watcherInfo) {
return;
}
watcherInfo.clients.delete(client);
logger.debug(
`Removed SSE client from git watcher for session ${sessionId} (${watcherInfo.clients.size} remaining)`
);
// If no more clients, stop watching
if (watcherInfo.clients.size === 0) {
this.stopWatching(sessionId);
}
}
/**
* Stop watching git directory for a session
*/
stopWatching(sessionId: string): void {
const watcherInfo = this.watchers.get(sessionId);
if (!watcherInfo) {
return;
}
logger.debug(`Stopping git watcher for session ${sessionId}`);
// Clear debounce timer
if (watcherInfo.debounceTimer) {
clearTimeout(watcherInfo.debounceTimer);
}
// Close watcher
watcherInfo.watcher.close();
// Remove from map
this.watchers.delete(sessionId);
}
/**
* Check git status and broadcast if changed
*/
private async checkAndBroadcastStatus(watcherInfo: WatcherInfo): Promise<void> {
try {
const status = await getDetailedGitStatus(watcherInfo.workingDir);
// Check if status has changed
if (this.hasStatusChanged(watcherInfo.lastStatus, status)) {
logger.debug(`Git status changed for session ${watcherInfo.sessionId}:`, status);
watcherInfo.lastStatus = status;
// Broadcast to all clients
this.broadcastStatusUpdate(watcherInfo, status);
}
} catch (error) {
logger.error(`Failed to get git status for session ${watcherInfo.sessionId}:`, error);
}
}
/**
* Check if git status has changed
*/
private hasStatusChanged(
oldStatus: GitStatusCounts | undefined,
newStatus: GitStatusCounts
): boolean {
if (!oldStatus) return true;
return (
oldStatus.modified !== newStatus.modified ||
oldStatus.untracked !== newStatus.untracked ||
oldStatus.staged !== newStatus.staged ||
oldStatus.ahead !== newStatus.ahead ||
oldStatus.behind !== newStatus.behind
);
}
/**
* Broadcast status update to all clients
*/
private broadcastStatusUpdate(watcherInfo: WatcherInfo, status: GitStatusCounts): void {
for (const client of watcherInfo.clients) {
this.sendStatusUpdate(client, watcherInfo.sessionId, status);
}
}
/**
* Send status update to a specific client
*/
private sendStatusUpdate(client: Response, sessionId: string, status: GitStatusCounts): void {
try {
const event = {
type: 'git-status-update',
sessionId,
gitModifiedCount: status.modified,
gitUntrackedCount: status.untracked,
gitStagedCount: status.staged,
gitAheadCount: status.ahead,
gitBehindCount: status.behind,
};
client.write(`event: session-update\ndata: ${JSON.stringify(event)}\n\n`);
} catch (error) {
logger.error(`Failed to send git status update to client:`, error);
}
}
/**
* Clean up all watchers
*/
cleanup(): void {
logger.debug('Cleaning up all git watchers');
for (const [sessionId] of this.watchers) {
this.stopWatching(sessionId);
}
}
}
// Export singleton instance
export const gitWatcher = new GitWatcher();

View file

@ -10,6 +10,7 @@ import {
findLastPrunePoint,
logPruningDetection,
} from '../utils/pruning-detector.js';
import { gitWatcher } from './git-watcher.js';
const logger = createLogger('stream-watcher');
@ -217,9 +218,15 @@ export class StreamWatcher {
// Start watching for new content
this.startWatching(sessionId, streamPath, watcherInfo);
// Start git watching if this is a git repository
this.startGitWatching(sessionId, response);
} else {
// Send existing content to new client
this.sendExistingContent(sessionId, streamPath, client);
// Add this client to git watcher
gitWatcher.addClient(sessionId, response);
}
// Add client to set
@ -256,6 +263,9 @@ export class StreamWatcher {
)
);
// Remove client from git watcher
gitWatcher.removeClient(sessionId, response);
// If no more clients, stop watching
if (watcherInfo.clients.size === 0) {
logger.log(chalk.yellow(`stopping watcher for session ${sessionId} (no clients)`));
@ -263,6 +273,9 @@ export class StreamWatcher {
watcherInfo.watcher.close();
}
this.activeWatchers.delete(sessionId);
// Stop git watching when no clients remain
gitWatcher.stopWatching(sessionId);
}
}
}
@ -611,6 +624,22 @@ export class StreamWatcher {
}
}
/**
* Start git watching for a session if it's in a git repository
*/
private async startGitWatching(sessionId: string, response: Response): Promise<void> {
try {
const sessionInfo = this.sessionManager.loadSessionInfo(sessionId);
if (sessionInfo?.gitRepoPath && sessionInfo.workingDir) {
logger.debug(`Starting git watcher for session ${sessionId} at ${sessionInfo.gitRepoPath}`);
await gitWatcher.startWatching(sessionId, sessionInfo.workingDir, sessionInfo.gitRepoPath);
gitWatcher.addClient(sessionId, response);
}
} catch (error) {
logger.error(`Failed to start git watching for session ${sessionId}:`, error);
}
}
/**
* Clean up all watchers and listeners
*/
@ -626,5 +655,7 @@ export class StreamWatcher {
}
this.activeWatchers.clear();
}
// Clean up git watchers
gitWatcher.cleanup();
}
}

View file

@ -0,0 +1,108 @@
/**
* Shared Git Status Utilities
*
* Provides a single implementation for parsing git status output
* to avoid duplication across the codebase.
*/
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
export interface GitStatusCounts {
modified: number;
untracked: number;
staged: number;
deleted: number;
ahead: number;
behind: number;
}
/**
* Get detailed git status including file counts and ahead/behind info
* @param workingDir The directory to check git status in
* @returns Git status counts or null if not a git repository
*/
export async function getDetailedGitStatus(workingDir: string): Promise<GitStatusCounts> {
try {
const { stdout: statusOutput } = await execFileAsync(
'git',
['status', '--porcelain=v1', '--branch'],
{
cwd: workingDir,
timeout: 5000,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
}
);
const lines = statusOutput.trim().split('\n');
const branchLine = lines[0];
let aheadCount = 0;
let behindCount = 0;
let modifiedCount = 0;
let untrackedCount = 0;
let stagedCount = 0;
let deletedCount = 0;
// Parse branch line for ahead/behind info
if (branchLine?.startsWith('##')) {
const aheadMatch = branchLine.match(/\[ahead (\d+)/);
const behindMatch = branchLine.match(/behind (\d+)/);
if (aheadMatch) {
aheadCount = Number.parseInt(aheadMatch[1], 10);
}
if (behindMatch) {
behindCount = Number.parseInt(behindMatch[1], 10);
}
}
// Parse file statuses
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line || line.length < 2) continue;
const indexStatus = line[0];
const workingStatus = line[1];
// Staged files (changes in index)
if (indexStatus !== ' ' && indexStatus !== '?') {
stagedCount++;
}
// Working directory changes
if (workingStatus === 'M') {
modifiedCount++;
} else if (workingStatus === 'D' && indexStatus === ' ') {
// Deleted in working tree but not staged
deletedCount++;
}
// Untracked files
if (indexStatus === '?' && workingStatus === '?') {
untrackedCount++;
}
}
return {
modified: modifiedCount,
untracked: untrackedCount,
staged: stagedCount,
deleted: deletedCount,
ahead: aheadCount,
behind: behindCount,
};
} catch (_error) {
// Not a git repository or git command failed
return {
modified: 0,
untracked: 0,
staged: 0,
deleted: 0,
ahead: 0,
behind: 0,
};
}
}