slopus--happy
98e40dac97
CLI Smoke Test / smoke-test-linux (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-linux (24) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (24) (push) Has been cancelled
Expo App TypeScript typecheck / typecheck (push) Has been cancelled
283 行
11 KiB
TypeScript
283 行
11 KiB
TypeScript
/**
|
|
* Git status synchronization module
|
|
* Provides real-time git repository status tracking using remote bash commands
|
|
*/
|
|
|
|
import { InvalidateSync } from '@/utils/sync';
|
|
import { sessionBash } from './ops';
|
|
import { GitStatus } from './storageTypes';
|
|
import { storage } from './storage';
|
|
import { parseStatusSummary, getStatusCounts, isDirty } from './git-parsers/parseStatus';
|
|
import { parseStatusSummaryV2, getStatusCountsV2, isDirtyV2, getCurrentBranchV2, getTrackingInfoV2 } from './git-parsers/parseStatusV2';
|
|
import { parseCurrentBranch } from './git-parsers/parseBranch';
|
|
import { parseNumStat, mergeDiffSummaries } from './git-parsers/parseDiff';
|
|
|
|
|
|
export class GitStatusSync {
|
|
// Map project keys to sync instances
|
|
private projectSyncMap = new Map<string, InvalidateSync>();
|
|
// Map session IDs to project keys for cleanup
|
|
private sessionToProjectKey = new Map<string, string>();
|
|
// Debounce timers to coalesce rapid invalidations (e.g. new-message + update-session arriving together)
|
|
private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
|
|
/**
|
|
* Get project key string for a session
|
|
*/
|
|
private getProjectKeyForSession(sessionId: string): string | null {
|
|
const session = storage.getState().sessions[sessionId];
|
|
if (!session?.metadata?.machineId || !session?.metadata?.path) {
|
|
return null;
|
|
}
|
|
return `${session.metadata.machineId}:${session.metadata.path}`;
|
|
}
|
|
|
|
/**
|
|
* Get or create git status sync for a session (creates project-based sync)
|
|
*/
|
|
getSync(sessionId: string): InvalidateSync {
|
|
const projectKey = this.getProjectKeyForSession(sessionId);
|
|
if (!projectKey) {
|
|
// Return a no-op sync if no valid project
|
|
return new InvalidateSync(async () => {});
|
|
}
|
|
|
|
// Map session to project key
|
|
this.sessionToProjectKey.set(sessionId, projectKey);
|
|
|
|
let sync = this.projectSyncMap.get(projectKey);
|
|
if (!sync) {
|
|
sync = new InvalidateSync(() => this.fetchGitStatusForProject(sessionId, projectKey));
|
|
this.projectSyncMap.set(projectKey, sync);
|
|
}
|
|
return sync;
|
|
}
|
|
|
|
/**
|
|
* Invalidate git status for a session (triggers refresh for the entire project).
|
|
* Debounces rapid calls (e.g. new-message + update-session arriving together)
|
|
* to avoid duplicate RPC round-trips.
|
|
*/
|
|
invalidate(sessionId: string): void {
|
|
const projectKey = this.sessionToProjectKey.get(sessionId);
|
|
if (projectKey) {
|
|
const existing = this.debounceTimers.get(projectKey);
|
|
if (existing) clearTimeout(existing);
|
|
|
|
this.debounceTimers.set(projectKey, setTimeout(() => {
|
|
this.debounceTimers.delete(projectKey);
|
|
const sync = this.projectSyncMap.get(projectKey);
|
|
if (sync) {
|
|
sync.invalidate();
|
|
}
|
|
}, 300));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop git status sync for a session
|
|
*/
|
|
stop(sessionId: string): void {
|
|
const projectKey = this.sessionToProjectKey.get(sessionId);
|
|
if (projectKey) {
|
|
this.sessionToProjectKey.delete(sessionId);
|
|
|
|
// Check if any other sessions are using this project
|
|
const hasOtherSessions = Array.from(this.sessionToProjectKey.values()).includes(projectKey);
|
|
|
|
// Only stop the project sync if no other sessions are using it
|
|
if (!hasOtherSessions) {
|
|
const timer = this.debounceTimers.get(projectKey);
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
this.debounceTimers.delete(projectKey);
|
|
}
|
|
const sync = this.projectSyncMap.get(projectKey);
|
|
if (sync) {
|
|
sync.stop();
|
|
this.projectSyncMap.delete(projectKey);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear git status for a session when it's deleted
|
|
* Similar to stop() but also clears any stored git status
|
|
*/
|
|
clearForSession(sessionId: string): void {
|
|
const projectKey = this.sessionToProjectKey.get(sessionId);
|
|
|
|
// First stop any active syncs
|
|
this.stop(sessionId);
|
|
|
|
// Only clear git status if no other sessions share this path
|
|
if (projectKey) {
|
|
const hasOtherSessions = Array.from(this.sessionToProjectKey.values()).includes(projectKey);
|
|
if (!hasOtherSessions) {
|
|
storage.getState().applyGitStatus(projectKey, null);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch git status for a project using any session in that project
|
|
*/
|
|
private async fetchGitStatusForProject(sessionId: string, projectKey: string): Promise<void> {
|
|
try {
|
|
// Check if we have a session with valid metadata
|
|
const session = storage.getState().sessions[sessionId];
|
|
if (!session?.metadata?.path) {
|
|
return;
|
|
}
|
|
|
|
// First check if we're in a git repository
|
|
const gitCheckResult = await sessionBash(sessionId, {
|
|
command: 'git rev-parse --is-inside-work-tree',
|
|
cwd: session.metadata.path,
|
|
timeout: 5000
|
|
});
|
|
|
|
if (!gitCheckResult.success || gitCheckResult.exitCode !== 0) {
|
|
// Not a git repository, clear any existing status
|
|
storage.getState().applyGitStatus(projectKey, null);
|
|
return;
|
|
}
|
|
|
|
// Get git status in porcelain v2 format (includes branch info)
|
|
// --untracked-files=all ensures we get individual files, not directories
|
|
const statusResult = await sessionBash(sessionId, {
|
|
command: 'git -c core.quotepath=false status --porcelain=v2 --branch --show-stash --untracked-files=all',
|
|
cwd: session.metadata.path,
|
|
timeout: 10000
|
|
});
|
|
|
|
if (!statusResult.success) {
|
|
console.error('Failed to get git status:', statusResult.error);
|
|
return;
|
|
}
|
|
|
|
// Get git diff statistics for unstaged changes
|
|
const diffStatResult = await sessionBash(sessionId, {
|
|
command: 'git -c core.quotepath=false diff --numstat',
|
|
cwd: session.metadata.path,
|
|
timeout: 10000
|
|
});
|
|
|
|
// Get git diff statistics for staged changes
|
|
const stagedDiffStatResult = await sessionBash(sessionId, {
|
|
command: 'git -c core.quotepath=false diff --cached --numstat',
|
|
cwd: session.metadata.path,
|
|
timeout: 10000
|
|
});
|
|
|
|
// Parse the git status output with diff statistics
|
|
const gitStatus = this.parseGitStatusV2(
|
|
statusResult.stdout,
|
|
diffStatResult.success ? diffStatResult.stdout : '',
|
|
stagedDiffStatResult.success ? stagedDiffStatResult.stdout : ''
|
|
);
|
|
|
|
// Apply to storage keyed by path
|
|
storage.getState().applyGitStatus(projectKey, gitStatus);
|
|
|
|
} catch (error) {
|
|
console.error('Error fetching git status for session', sessionId, ':', error);
|
|
// Don't apply error state, just skip this update
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse git status porcelain v2 output into structured data
|
|
*/
|
|
private parseGitStatusV2(
|
|
porcelainV2Output: string,
|
|
diffStatOutput: string = '',
|
|
stagedDiffStatOutput: string = ''
|
|
): GitStatus {
|
|
// Parse status using v2 parser
|
|
const statusSummary = parseStatusSummaryV2(porcelainV2Output);
|
|
const counts = getStatusCountsV2(statusSummary);
|
|
const repoIsDirty = isDirtyV2(statusSummary);
|
|
const branchName = getCurrentBranchV2(statusSummary);
|
|
const trackingInfo = getTrackingInfoV2(statusSummary);
|
|
|
|
// Parse diff statistics
|
|
const unstagedDiff = parseNumStat(diffStatOutput);
|
|
const stagedDiff = parseNumStat(stagedDiffStatOutput);
|
|
const { stagedAdded, stagedRemoved, unstagedAdded, unstagedRemoved } = mergeDiffSummaries(stagedDiff, unstagedDiff);
|
|
|
|
// Calculate totals
|
|
const linesAdded = stagedAdded + unstagedAdded;
|
|
const linesRemoved = stagedRemoved + unstagedRemoved;
|
|
const linesChanged = linesAdded + linesRemoved;
|
|
|
|
return {
|
|
branch: branchName,
|
|
isDirty: repoIsDirty,
|
|
modifiedCount: counts.modified,
|
|
untrackedCount: counts.untracked,
|
|
stagedCount: counts.staged,
|
|
stagedLinesAdded: stagedAdded,
|
|
stagedLinesRemoved: stagedRemoved,
|
|
unstagedLinesAdded: unstagedAdded,
|
|
unstagedLinesRemoved: unstagedRemoved,
|
|
linesAdded,
|
|
linesRemoved,
|
|
linesChanged,
|
|
lastUpdatedAt: Date.now(),
|
|
// V2-specific fields
|
|
upstreamBranch: statusSummary.branch.upstream || null,
|
|
aheadCount: trackingInfo?.ahead,
|
|
behindCount: trackingInfo?.behind,
|
|
stashCount: statusSummary.stashCount
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse git status porcelain output into structured data using simple-git parsers
|
|
* (Legacy v1 fallback method - kept for compatibility)
|
|
*/
|
|
private parseGitStatus(
|
|
branchName: string | null,
|
|
porcelainOutput: string,
|
|
diffStatOutput: string = '',
|
|
stagedDiffStatOutput: string = ''
|
|
): GitStatus {
|
|
// Parse status using simple-git parser
|
|
const statusSummary = parseStatusSummary(porcelainOutput);
|
|
const counts = getStatusCounts(statusSummary);
|
|
const repoIsDirty = isDirty(statusSummary);
|
|
|
|
// Parse diff statistics
|
|
const unstagedDiff = parseNumStat(diffStatOutput);
|
|
const stagedDiff = parseNumStat(stagedDiffStatOutput);
|
|
const { stagedAdded, stagedRemoved, unstagedAdded, unstagedRemoved } = mergeDiffSummaries(stagedDiff, unstagedDiff);
|
|
|
|
// Calculate totals
|
|
const linesAdded = stagedAdded + unstagedAdded;
|
|
const linesRemoved = stagedRemoved + unstagedRemoved;
|
|
const linesChanged = linesAdded + linesRemoved;
|
|
|
|
return {
|
|
branch: branchName || null,
|
|
isDirty: repoIsDirty,
|
|
modifiedCount: counts.modified,
|
|
untrackedCount: counts.untracked,
|
|
stagedCount: counts.staged,
|
|
stagedLinesAdded: stagedAdded,
|
|
stagedLinesRemoved: stagedRemoved,
|
|
unstagedLinesAdded: unstagedAdded,
|
|
unstagedLinesRemoved: unstagedRemoved,
|
|
linesAdded,
|
|
linesRemoved,
|
|
linesChanged,
|
|
lastUpdatedAt: Date.now()
|
|
};
|
|
}
|
|
|
|
}
|
|
|
|
// Global singleton instance
|
|
export const gitStatusSync = new GitStatusSync();
|