/**
* Repository Detector
*
* Utility for detecting Git repositories, extracting repository information,
* and auto-detecting project context for Git operations.
*/
import { GitCommandExecutor } from './git-command-executor.js';
import { TerminalController } from './terminal-controller.js';
import path from 'path';
import { promises as fs } from 'fs';
export interface RepositoryInfo {
isGitRepository: boolean;
repositoryPath?: string;
repositoryName?: string;
remoteOrigin?: string;
currentBranch?: string;
detectedProvider?: 'github' | 'gitea' | 'unknown';
hasRemotes?: boolean;
remotes?: Array<{
name: string;
url: string;
provider?: 'github' | 'gitea' | 'unknown';
}>;
workingDirectory?: string;
gitDirectory?: string;
isBare?: boolean;
hasUncommittedChanges?: boolean;
lastCommit?: {
hash: string;
message: string;
author: string;
date: string;
};
}
export interface ProjectContext {
projectPath: string;
projectName: string;
repositoryInfo: RepositoryInfo;
autoDetectedSettings?: {
owner?: string;
repository?: string;
provider?: 'github' | 'gitea';
remoteUrl?: string;
};
}
export class RepositoryDetector {
private gitExecutor: GitCommandExecutor;
private terminal: TerminalController;
constructor() {
this.gitExecutor = new GitCommandExecutor();
this.terminal = new TerminalController();
}
/**
* Detect repository information for a given path
*/
async detectRepository(projectPath: string): Promise<RepositoryInfo> {
const resolvedPath = path.resolve(projectPath);
// Check if directory exists
const dirValidation = await this.terminal.validateDirectory(resolvedPath);
if (!dirValidation.exists) {
return {
isGitRepository: false,
workingDirectory: resolvedPath
};
}
// Check if it's a Git repository
const isGitRepo = await this.gitExecutor.isGitRepository(resolvedPath);
if (!isGitRepo) {
return {
isGitRepository: false,
workingDirectory: resolvedPath
};
}
// Get repository path (could be different from working directory in case of subdirectories)
const repoPathResult = await this.gitExecutor.executeGitCommand(
'rev-parse',
['--show-toplevel'],
resolvedPath
);
const repositoryPath = repoPathResult.success
? path.resolve(repoPathResult.stdout.trim())
: resolvedPath;
// Get repository name
const repositoryName = path.basename(repositoryPath);
// Get Git directory
const gitDirResult = await this.gitExecutor.executeGitCommand(
'rev-parse',
['--git-dir'],
repositoryPath
);
const gitDirectory = gitDirResult.success
? path.resolve(repositoryPath, gitDirResult.stdout.trim())
: path.join(repositoryPath, '.git');
// Check if bare repository
const isBareResult = await this.gitExecutor.executeGitCommand(
'rev-parse',
['--is-bare-repository'],
repositoryPath
);
const isBare = isBareResult.success && isBareResult.stdout.trim() === 'true';
// Get current branch
const branchResult = await this.gitExecutor.getCurrentBranch(repositoryPath);
const currentBranch = branchResult.branch;
// Get remotes
const remotesInfo = await this.getRemotesInfo(repositoryPath);
const remoteOrigin = remotesInfo.find(remote => remote.name === 'origin')?.url;
const detectedProvider = this.detectProviderFromUrl(remoteOrigin);
// Check for uncommitted changes
const statusResult = await this.gitExecutor.getStatus(repositoryPath);
const hasUncommittedChanges = statusResult.success &&
statusResult.parsedStatus &&
!statusResult.parsedStatus.clean;
// Get last commit
const lastCommit = await this.getLastCommitInfo(repositoryPath);
return {
isGitRepository: true,
repositoryPath,
repositoryName,
remoteOrigin,
currentBranch,
detectedProvider,
hasRemotes: remotesInfo.length > 0,
remotes: remotesInfo,
workingDirectory: resolvedPath,
gitDirectory,
isBare,
hasUncommittedChanges,
lastCommit
};
}
/**
* Get complete project context with auto-detection
*/
async getProjectContext(projectPath: string): Promise<ProjectContext> {
const resolvedPath = path.resolve(projectPath);
const projectName = path.basename(resolvedPath);
const repositoryInfo = await this.detectRepository(resolvedPath);
// Auto-detect settings from repository info
const autoDetectedSettings = this.extractAutoDetectedSettings(repositoryInfo);
return {
projectPath: resolvedPath,
projectName,
repositoryInfo,
autoDetectedSettings
};
}
/**
* Find Git repository in current directory or parent directories
*/
async findGitRepository(startPath: string): Promise<RepositoryInfo | null> {
let currentPath = path.resolve(startPath);
const rootPath = path.parse(currentPath).root;
while (currentPath !== rootPath) {
const repoInfo = await this.detectRepository(currentPath);
if (repoInfo.isGitRepository) {
return repoInfo;
}
// Move to parent directory
const parentPath = path.dirname(currentPath);
if (parentPath === currentPath) {
break; // Reached root
}
currentPath = parentPath;
}
return null;
}
/**
* Check if path is within a Git repository
*/
async isWithinGitRepository(filePath: string): Promise<{
isWithin: boolean;
repositoryInfo?: RepositoryInfo;
relativePath?: string;
}> {
const resolvedPath = path.resolve(filePath);
const repoInfo = await this.findGitRepository(resolvedPath);
if (!repoInfo || !repoInfo.repositoryPath) {
return { isWithin: false };
}
const relativePath = path.relative(repoInfo.repositoryPath, resolvedPath);
const isWithin = !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
return {
isWithin,
repositoryInfo: repoInfo,
relativePath: isWithin ? relativePath : undefined
};
}
/**
* Validate project path and ensure it's suitable for Git operations
*/
async validateProjectPath(projectPath: string): Promise<{
valid: boolean;
error?: string;
suggestion?: string;
repositoryInfo?: RepositoryInfo;
}> {
try {
const resolvedPath = path.resolve(projectPath);
// Check if directory exists
const dirValidation = await this.terminal.validateDirectory(resolvedPath);
if (!dirValidation.exists) {
return {
valid: false,
error: `Directory does not exist: ${resolvedPath}`,
suggestion: 'Ensure the project path exists and is accessible'
};
}
if (!dirValidation.isDirectory) {
return {
valid: false,
error: `Path is not a directory: ${resolvedPath}`,
suggestion: 'Provide a directory path, not a file path'
};
}
// Get repository info
const repositoryInfo = await this.detectRepository(resolvedPath);
return {
valid: true,
repositoryInfo
};
} catch (error: any) {
return {
valid: false,
error: `Failed to validate project path: ${error.message}`,
suggestion: 'Check path permissions and accessibility'
};
}
}
/**
* Extract repository name from various sources
*/
extractRepositoryName(projectPath: string, remoteUrl?: string): string {
// Try to extract from remote URL first
if (remoteUrl) {
const urlName = this.extractRepoNameFromUrl(remoteUrl);
if (urlName) {
return urlName;
}
}
// Fall back to directory name
return path.basename(path.resolve(projectPath));
}
/**
* Extract owner/username from repository URL or environment
*/
extractOwnerFromContext(repositoryInfo: RepositoryInfo): string | undefined {
if (repositoryInfo.remoteOrigin) {
return this.extractOwnerFromUrl(repositoryInfo.remoteOrigin);
}
// Try to get from Git config
return undefined;
}
/**
* Detect provider from remote URL
*/
detectProviderFromUrl(url?: string): 'github' | 'gitea' | 'unknown' {
if (!url) return 'unknown';
const normalizedUrl = url.toLowerCase();
if (normalizedUrl.includes('github.com')) {
return 'github';
}
// Check for common Gitea patterns
if (normalizedUrl.includes('gitea') ||
normalizedUrl.includes('git.') ||
normalizedUrl.match(/:\d+\//) || // Custom port
(!normalizedUrl.includes('github') && !normalizedUrl.includes('gitlab'))) {
return 'gitea';
}
return 'unknown';
}
/**
* Get information about all remotes
*/
private async getRemotesInfo(repositoryPath: string): Promise<Array<{
name: string;
url: string;
provider?: 'github' | 'gitea' | 'unknown';
}>> {
const remotesResult = await this.gitExecutor.executeGitCommand(
'remote',
['-v'],
repositoryPath
);
if (!remotesResult.success) {
return [];
}
const remotes: Array<{ name: string; url: string; provider?: 'github' | 'gitea' | 'unknown' }> = [];
const lines = remotesResult.stdout.split('\n').filter(line => line.trim());
for (const line of lines) {
const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/);
if (match) {
const [, name, url] = match;
const provider = this.detectProviderFromUrl(url);
remotes.push({ name, url, provider });
}
}
return remotes;
}
/**
* Get last commit information
*/
private async getLastCommitInfo(repositoryPath: string): Promise<{
hash: string;
message: string;
author: string;
date: string;
} | undefined> {
const commitResult = await this.gitExecutor.executeGitCommand(
'log',
['-1', '--format=%H|%s|%an|%ad', '--date=iso'],
repositoryPath
);
if (commitResult.success && commitResult.stdout.trim()) {
const [hash, message, author, date] = commitResult.stdout.trim().split('|');
return { hash, message, author, date };
}
return undefined;
}
/**
* Extract auto-detected settings from repository info
*/
private extractAutoDetectedSettings(repositoryInfo: RepositoryInfo): {
owner?: string;
repository?: string;
provider?: 'github' | 'gitea';
remoteUrl?: string;
} {
const settings: any = {};
if (repositoryInfo.remoteOrigin) {
settings.remoteUrl = repositoryInfo.remoteOrigin;
settings.owner = this.extractOwnerFromUrl(repositoryInfo.remoteOrigin);
settings.repository = this.extractRepoNameFromUrl(repositoryInfo.remoteOrigin);
}
if (repositoryInfo.detectedProvider && repositoryInfo.detectedProvider !== 'unknown') {
settings.provider = repositoryInfo.detectedProvider;
}
return settings;
}
/**
* Extract repository name from URL
*/
private extractRepoNameFromUrl(url: string): string | undefined {
try {
// Handle SSH URLs like git@github.com:owner/repo.git
if (url.startsWith('git@')) {
const match = url.match(/git@[^:]+:([^\/]+)\/(.+?)(?:\.git)?$/);
if (match) {
return match[2];
}
}
// Handle HTTPS URLs like https://github.com/owner/repo.git
if (url.startsWith('http')) {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/').filter(part => part);
if (pathParts.length >= 2) {
const repoName = pathParts[pathParts.length - 1];
return repoName.endsWith('.git') ? repoName.slice(0, -4) : repoName;
}
}
return undefined;
} catch {
return undefined;
}
}
/**
* Extract owner/username from URL
*/
private extractOwnerFromUrl(url: string): string | undefined {
try {
// Handle SSH URLs like git@github.com:owner/repo.git
if (url.startsWith('git@')) {
const match = url.match(/git@[^:]+:([^\/]+)\/(.+?)(?:\.git)?$/);
if (match) {
return match[1];
}
}
// Handle HTTPS URLs like https://github.com/owner/repo.git
if (url.startsWith('http')) {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/').filter(part => part);
if (pathParts.length >= 2) {
return pathParts[pathParts.length - 2];
}
}
return undefined;
} catch {
return undefined;
}
}
/**
* Check if directory has Git-related files
*/
async hasGitFiles(directoryPath: string): Promise<{
hasGitDir: boolean;
hasGitignore: boolean;
hasGitAttributes: boolean;
gitFiles: string[];
}> {
try {
const resolvedPath = path.resolve(directoryPath);
const files = await fs.readdir(resolvedPath);
const gitFiles: string[] = [];
let hasGitDir = false;
let hasGitignore = false;
let hasGitAttributes = false;
for (const file of files) {
if (file === '.git') {
hasGitDir = true;
gitFiles.push(file);
} else if (file === '.gitignore') {
hasGitignore = true;
gitFiles.push(file);
} else if (file === '.gitattributes') {
hasGitAttributes = true;
gitFiles.push(file);
} else if (file.startsWith('.git')) {
gitFiles.push(file);
}
}
return {
hasGitDir,
hasGitignore,
hasGitAttributes,
gitFiles
};
} catch {
return {
hasGitDir: false,
hasGitignore: false,
hasGitAttributes: false,
gitFiles: []
};
}
}
/**
* Suggest Git initialization for non-Git directories
*/
async suggestGitInit(projectPath: string): Promise<{
shouldInit: boolean;
reason: string;
suggestion: string;
hasExistingFiles: boolean;
fileCount: number;
}> {
try {
const resolvedPath = path.resolve(projectPath);
const dirValidation = await this.terminal.validateDirectory(resolvedPath);
if (!dirValidation.exists) {
return {
shouldInit: false,
reason: 'Directory does not exist',
suggestion: 'Create the directory first',
hasExistingFiles: false,
fileCount: 0
};
}
const repoInfo = await this.detectRepository(resolvedPath);
if (repoInfo.isGitRepository) {
return {
shouldInit: false,
reason: 'Already a Git repository',
suggestion: 'Directory is already initialized as a Git repository',
hasExistingFiles: true,
fileCount: 0
};
}
// Check for existing files
const files = await fs.readdir(resolvedPath);
const nonHiddenFiles = files.filter(file => !file.startsWith('.'));
const hasExistingFiles = nonHiddenFiles.length > 0;
return {
shouldInit: true,
reason: hasExistingFiles
? 'Directory has files but is not a Git repository'
: 'Empty directory ready for Git initialization',
suggestion: hasExistingFiles
? 'Initialize Git repository and make initial commit'
: 'Initialize Git repository',
hasExistingFiles,
fileCount: nonHiddenFiles.length
};
} catch (error: any) {
return {
shouldInit: false,
reason: `Error checking directory: ${error.message}`,
suggestion: 'Check directory permissions and try again',
hasExistingFiles: false,
fileCount: 0
};
}
}
}
// Export singleton instance
export const repositoryDetector = new RepositoryDetector();