aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
350 lines (303 loc) • 8.96 kB
JavaScript
/**
* Git Config Detector - Cached Git Configuration Detection
*
* Detects git configuration status with caching to avoid
* repeated git command execution overhead.
*
* Features:
* - 5-minute cache TTL (configurable)
* - Timeout protection (1000ms)
* - Smart cache invalidation
* - Graceful error handling
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const DEFAULT_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const GIT_TIMEOUT = 1000; // 1 second
class GitConfigDetector {
constructor(cacheTTL = DEFAULT_CACHE_TTL) {
this.cacheTTL = cacheTTL;
this.cache = null;
this.cacheTimestamp = null;
}
/**
* Get git configuration (cached)
* @returns {Object} { configured: boolean, type: string|null, branch: string|null }
*/
get() {
// Check cache validity
if (this._isCacheValid()) {
return this.cache;
}
// Cache miss - detect configuration
return this.detect();
}
/**
* Detect git configuration (bypass cache)
* @returns {Object} { configured: boolean, type: string|null, branch: string|null }
*/
detect() {
try {
const result = {
configured: false,
type: null,
branch: null,
};
// Check if git repository exists
if (!this._isGitRepository()) {
this._updateCache(result);
return result;
}
// Repository exists, get configuration details
result.configured = true;
result.branch = this._getCurrentBranch();
// Detect repository type (GitHub, GitLab, etc.)
const remoteUrl = this._getRemoteUrl();
result.type = this._detectRepositoryType(remoteUrl);
this._updateCache(result);
return result;
} catch (error) {
// Graceful fallback on any error
const fallbackResult = { configured: false, type: null, branch: null };
this._updateCache(fallbackResult);
return fallbackResult;
}
}
/**
* Invalidate cache (force fresh detection on next get())
*/
invalidate() {
this.cache = null;
this.cacheTimestamp = null;
}
/**
* Check if cache is still valid
* @private
* @returns {boolean} True if cache is valid
*/
_isCacheValid() {
if (!this.cache || !this.cacheTimestamp) {
return false;
}
const now = Date.now();
const age = now - this.cacheTimestamp;
return age < this.cacheTTL;
}
/**
* Update cache with new data
* @private
* @param {Object} data - Configuration data to cache
*/
_updateCache(data) {
this.cache = data;
this.cacheTimestamp = Date.now();
}
/**
* Check if current directory is a git repository
* @private
* @returns {boolean} True if git repository
*/
_isGitRepository() {
// NOG-18: Replace execSync (~34ms) with synchronous fs check (~0.05ms).
// .git/HEAD exists in normal repos AND worktrees (as file or via gitdir link).
try {
const gitPath = path.join(process.cwd(), '.git');
// .git can be a directory (normal repo) or a file (worktree with "gitdir:" pointer)
return fs.existsSync(gitPath);
} catch {
return false;
}
}
/**
* Get current git branch name via direct .git/HEAD file read.
* Fallback chain: .git/HEAD read → worktree/gitfile resolution → execSync.
* @private
* @returns {string|null} Branch name or null
*/
_getCurrentBranch() {
const direct = this._detectBranchDirect();
if (direct !== undefined) return direct;
// Fallback: execSync (slow but reliable)
return this._getCurrentBranchExec();
}
/**
* Read branch directly from .git/HEAD file (~0.06ms vs ~52ms for execSync).
* Handles: normal branch, detached HEAD, worktree/gitfile.
* @private
* @returns {string|null|undefined} Branch name, null (detached/no-git), or undefined (needs fallback)
*/
_detectBranchDirect() {
try {
const gitPath = path.join(process.cwd(), '.git');
const stat = fs.statSync(gitPath);
let headPath;
if (stat.isFile()) {
// Worktree/gitfile: .git is a file with "gitdir: <path>"
const gitContent = fs.readFileSync(gitPath, 'utf8').trim();
const match = gitContent.match(/^gitdir:\s*(.+)$/);
if (!match) return undefined;
const gitDir = path.resolve(process.cwd(), match[1]);
headPath = path.join(gitDir, 'HEAD');
} else {
headPath = path.join(gitPath, 'HEAD');
}
const headContent = fs.readFileSync(headPath, 'utf8').trim();
// Normal branch: "ref: refs/heads/feat/my-branch"
const refMatch = headContent.match(/^ref:\s*refs\/heads\/(.+)$/);
if (refMatch) return refMatch[1];
// Detached HEAD: raw commit hash
if (/^[0-9a-f]{40}$/.test(headContent)) {
return headContent.substring(0, 7) + ' (detached)';
}
return undefined; // Unexpected format — fallback
} catch (_err) {
// File not found or permission error — could be no .git dir
if (_err.code === 'ENOENT') return null;
return undefined; // Other error — fallback
}
}
/**
* Fallback: get branch via execSync (original method).
* @private
* @returns {string|null} Branch name or null
*/
_getCurrentBranchExec() {
try {
const branch = execSync('git branch --show-current', {
encoding: 'utf8',
timeout: GIT_TIMEOUT,
stdio: ['pipe', 'pipe', 'ignore'],
}).trim();
return branch || null;
} catch (error) {
return null;
}
}
/**
* Get remote repository URL
* @private
* @returns {string|null} Remote URL or null
*/
_getRemoteUrl() {
try {
const url = execSync('git config --get remote.origin.url', {
encoding: 'utf8',
timeout: GIT_TIMEOUT,
stdio: ['pipe', 'pipe', 'ignore'],
}).trim();
return url || null;
} catch (error) {
return null;
}
}
/**
* Detect repository hosting type from URL
* @private
* @param {string|null} remoteUrl - Remote repository URL
* @returns {string|null} 'github' | 'gitlab' | 'bitbucket' | 'other' | null
*/
_detectRepositoryType(remoteUrl) {
if (!remoteUrl) {
return null;
}
const url = remoteUrl.toLowerCase();
if (url.includes('github.com')) {
return 'github';
}
if (url.includes('gitlab.com') || url.includes('gitlab')) {
return 'gitlab';
}
if (url.includes('bitbucket.org') || url.includes('bitbucket')) {
return 'bitbucket';
}
// Has remote but unknown type
return 'other';
}
/**
* Get cache age in milliseconds
* @returns {number|null} Cache age or null if no cache
*/
getCacheAge() {
if (!this.cacheTimestamp) {
return null;
}
return Date.now() - this.cacheTimestamp;
}
/**
* Check if cache will expire soon (within 1 minute)
* @returns {boolean} True if cache expires soon
*/
isCacheExpiringSoon() {
const age = this.getCacheAge();
if (age === null) {
return true;
}
const remaining = this.cacheTTL - age;
return remaining < 60 * 1000; // Less than 1 minute remaining
}
/**
* Get detailed git information (bypass cache)
* Useful for debugging or detailed reports
* @returns {Object} Detailed git configuration
*/
getDetailed() {
try {
const basic = this.detect();
if (!basic.configured) {
return basic;
}
// Get additional details
const userName = this._execGitCommand('git config user.name');
const userEmail = this._execGitCommand('git config user.email');
const remoteUrl = this._getRemoteUrl();
const lastCommit = this._execGitCommand('git log -1 --format=%H');
const hasUncommittedChanges = this._hasUncommittedChanges();
return {
...basic,
userName,
userEmail,
remoteUrl,
lastCommit,
hasUncommittedChanges,
};
} catch (error) {
return this.detect(); // Fallback to basic detection
}
}
/**
* Execute git command with error handling
* @private
* @param {string} command - Git command to execute
* @returns {string|null} Command output or null
*/
_execGitCommand(command) {
try {
return execSync(command, {
encoding: 'utf8',
timeout: GIT_TIMEOUT,
stdio: ['pipe', 'pipe', 'ignore'],
}).trim();
} catch (error) {
return null;
}
}
/**
* Check if repository has uncommitted changes
* @private
* @returns {boolean} True if has uncommitted changes
*/
_hasUncommittedChanges() {
try {
const status = execSync('git status --porcelain', {
encoding: 'utf8',
timeout: GIT_TIMEOUT,
stdio: ['pipe', 'pipe', 'ignore'],
}).trim();
return status.length > 0;
} catch (error) {
return false;
}
}
}
module.exports = GitConfigDetector;