mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
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:
parent
cd6cbd8d6e
commit
acdc4f22a8
6 changed files with 444 additions and 206 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
250
web/src/server/services/git-watcher.ts
Normal file
250
web/src/server/services/git-watcher.ts
Normal 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();
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
108
web/src/server/utils/git-status.ts
Normal file
108
web/src/server/utils/git-status.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue