mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +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.
|
* Shows counts for modified, untracked, staged files, and ahead/behind commits.
|
||||||
*/
|
*/
|
||||||
import { html, LitElement } from 'lit';
|
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';
|
import type { Session } from '../../shared/types.js';
|
||||||
|
|
||||||
@customElement('git-status-badge')
|
@customElement('git-status-badge')
|
||||||
|
|
@ -17,47 +17,6 @@ export class GitStatusBadge extends LitElement {
|
||||||
|
|
||||||
@property({ type: Object }) session: Session | null = null;
|
@property({ type: Object }) session: Session | null = null;
|
||||||
@property({ type: Boolean }) detailed = false; // Show detailed breakdown
|
@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>) {
|
updated(changedProperties: Map<string, unknown>) {
|
||||||
super.updated(changedProperties);
|
super.updated(changedProperties);
|
||||||
|
|
@ -75,75 +34,6 @@ export class GitStatusBadge extends LitElement {
|
||||||
newId: this.session?.id,
|
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 =
|
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
|
// Always show the badge when in a Git repository
|
||||||
// Even if there are no changes, users want to see the branch name
|
// Even if there are no changes, users want to see the branch name
|
||||||
|
|
@ -186,9 +79,9 @@ export class GitStatusBadge extends LitElement {
|
||||||
private renderLocalChanges() {
|
private renderLocalChanges() {
|
||||||
if (!this.session) return null;
|
if (!this.session) return null;
|
||||||
|
|
||||||
const modifiedCount = this._gitModifiedCount;
|
const modifiedCount = this.session?.gitModifiedCount ?? 0;
|
||||||
const untrackedCount = this._gitUntrackedCount;
|
const untrackedCount = this.session?.gitUntrackedCount ?? 0;
|
||||||
const stagedCount = this._gitStagedCount;
|
const stagedCount = this.session?.gitStagedCount ?? 0;
|
||||||
const totalChanges = modifiedCount + untrackedCount + stagedCount;
|
const totalChanges = modifiedCount + untrackedCount + stagedCount;
|
||||||
|
|
||||||
if (totalChanges === 0 && !this.detailed) return null;
|
if (totalChanges === 0 && !this.detailed) return null;
|
||||||
|
|
@ -239,8 +132,8 @@ export class GitStatusBadge extends LitElement {
|
||||||
private renderRemoteChanges() {
|
private renderRemoteChanges() {
|
||||||
if (!this.session) return null;
|
if (!this.session) return null;
|
||||||
|
|
||||||
const aheadCount = this._gitAheadCount;
|
const aheadCount = this.session?.gitAheadCount ?? 0;
|
||||||
const behindCount = this._gitBehindCount;
|
const behindCount = this.session?.gitBehindCount ?? 0;
|
||||||
|
|
||||||
if (aheadCount === 0 && behindCount === 0) return null;
|
if (aheadCount === 0 && behindCount === 0) return null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export interface StreamConnection {
|
||||||
disconnect: () => void;
|
disconnect: () => void;
|
||||||
errorHandler?: EventListener;
|
errorHandler?: EventListener;
|
||||||
sessionExitHandler?: EventListener;
|
sessionExitHandler?: EventListener;
|
||||||
|
sessionUpdateHandler?: EventListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConnectionManager {
|
export class ConnectionManager {
|
||||||
|
|
@ -87,6 +88,38 @@ export class ConnectionManager {
|
||||||
|
|
||||||
this.terminal.addEventListener('session-exit', handleSessionExit);
|
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
|
// Wrap the connection to track reconnections
|
||||||
const originalEventSource = connection.eventSource;
|
const originalEventSource = connection.eventSource;
|
||||||
let lastErrorTime = 0;
|
let lastErrorTime = 0;
|
||||||
|
|
@ -134,6 +167,7 @@ export class ConnectionManager {
|
||||||
...connection,
|
...connection,
|
||||||
errorHandler: handleError as EventListener,
|
errorHandler: handleError as EventListener,
|
||||||
sessionExitHandler: handleSessionExit 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);
|
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.disconnect();
|
||||||
this.streamConnection = null;
|
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 { StreamWatcher } from '../services/stream-watcher.js';
|
||||||
import type { TerminalManager } from '../services/terminal-manager.js';
|
import type { TerminalManager } from '../services/terminal-manager.js';
|
||||||
import { detectGitInfo } from '../utils/git-info.js';
|
import { detectGitInfo } from '../utils/git-info.js';
|
||||||
|
import { getDetailedGitStatus } from '../utils/git-status.js';
|
||||||
import { createLogger } from '../utils/logger.js';
|
import { createLogger } from '../utils/logger.js';
|
||||||
import { resolveAbsolutePath } from '../utils/path-utils.js';
|
import { resolveAbsolutePath } from '../utils/path-utils.js';
|
||||||
import { generateSessionName } from '../utils/session-naming.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';
|
import { controlUnixHandler } from '../websocket/control-unix-handler.js';
|
||||||
|
|
||||||
const logger = createLogger('sessions');
|
const logger = createLogger('sessions');
|
||||||
const execFile = promisify(require('child_process').execFile);
|
const _execFile = promisify(require('child_process').execFile);
|
||||||
|
|
||||||
interface SessionRoutesConfig {
|
interface SessionRoutesConfig {
|
||||||
ptyManager: PtyManager;
|
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
|
// Get git status for a specific session
|
||||||
router.get('/sessions/:sessionId/git-status', async (req, res) => {
|
router.get('/sessions/:sessionId/git-status', async (req, res) => {
|
||||||
const sessionId = req.params.sessionId;
|
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,
|
findLastPrunePoint,
|
||||||
logPruningDetection,
|
logPruningDetection,
|
||||||
} from '../utils/pruning-detector.js';
|
} from '../utils/pruning-detector.js';
|
||||||
|
import { gitWatcher } from './git-watcher.js';
|
||||||
|
|
||||||
const logger = createLogger('stream-watcher');
|
const logger = createLogger('stream-watcher');
|
||||||
|
|
||||||
|
|
@ -217,9 +218,15 @@ export class StreamWatcher {
|
||||||
|
|
||||||
// Start watching for new content
|
// Start watching for new content
|
||||||
this.startWatching(sessionId, streamPath, watcherInfo);
|
this.startWatching(sessionId, streamPath, watcherInfo);
|
||||||
|
|
||||||
|
// Start git watching if this is a git repository
|
||||||
|
this.startGitWatching(sessionId, response);
|
||||||
} else {
|
} else {
|
||||||
// Send existing content to new client
|
// Send existing content to new client
|
||||||
this.sendExistingContent(sessionId, streamPath, client);
|
this.sendExistingContent(sessionId, streamPath, client);
|
||||||
|
|
||||||
|
// Add this client to git watcher
|
||||||
|
gitWatcher.addClient(sessionId, response);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add client to set
|
// 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 no more clients, stop watching
|
||||||
if (watcherInfo.clients.size === 0) {
|
if (watcherInfo.clients.size === 0) {
|
||||||
logger.log(chalk.yellow(`stopping watcher for session ${sessionId} (no clients)`));
|
logger.log(chalk.yellow(`stopping watcher for session ${sessionId} (no clients)`));
|
||||||
|
|
@ -263,6 +273,9 @@ export class StreamWatcher {
|
||||||
watcherInfo.watcher.close();
|
watcherInfo.watcher.close();
|
||||||
}
|
}
|
||||||
this.activeWatchers.delete(sessionId);
|
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
|
* Clean up all watchers and listeners
|
||||||
*/
|
*/
|
||||||
|
|
@ -626,5 +655,7 @@ export class StreamWatcher {
|
||||||
}
|
}
|
||||||
this.activeWatchers.clear();
|
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