git-aiflow
Version:
🚀 An AI-powered workflow automation tool for effortless Git-based development, combining smart GitLab/GitHub merge & pull request creation with Conan package management.
1,228 lines • 56 kB
JavaScript
import { Shell } from '../shell.js';
import { StringUtil } from '../utils/string-util.js';
import { logger } from '../logger.js';
/**
* Git operations service
*/
export class GitService {
constructor(shell) {
this.remote_urls = new Map();
this.shell = shell || Shell.instance();
logger.debug('GitService initialized');
}
/**
* Safely checkout to the specified branch with the following steps:
* 1. Automatically stash working directory and staged changes (including untracked files)
* 2. Fetch remote branch and validate its existence
* 3. If local branch exists: checkout and attempt fast-forward to remote/<branch>
* - Only fast-forward (--ff-only) to avoid merge commits
* - If fast-forward fails, preserve current commit with warning
* 4. If local branch doesn't exist: create tracking branch from remote/<branch>
* 5. Finally attempt stash pop (if conflicts occur, preserve and prompt for manual resolution)
*
* @param branchName The name of the branch to checkout
* @returns True if checkout was successful, false otherwise
*/
checkout(branchName) {
logger.info(`Checking out branch: ${branchName}`);
const remoteName = this.getRemoteName();
const currentHead = this.getCurrentHead();
const stashResult = this.handleWorkingDirectoryChanges(branchName);
if (!this.fetchAndValidateRemoteBranch(branchName, remoteName, stashResult.pushedStash)) {
return false;
}
const localExists = this.checkLocalBranchExists(branchName);
let checkoutSuccess = false;
if (localExists) {
checkoutSuccess = this.checkoutExistingBranch(branchName, remoteName, stashResult.pushedStash);
}
else {
checkoutSuccess = this.createTrackingBranch(branchName, remoteName, stashResult.pushedStash);
}
if (checkoutSuccess) {
this.restoreWorkingDirectoryChanges(stashResult.pushedStash);
this.logCheckoutCompletion(currentHead, branchName);
}
return checkoutSuccess;
}
/**
* Gets the current HEAD commit hash.
* @returns The short commit hash or empty string if failed
*/
getCurrentHead() {
return this.executeGitCommand('git rev-parse --short=12 HEAD', 'Reading current HEAD').output;
}
/**
* Handles uncommitted changes by stashing them if necessary.
* @param branchName The target branch name for stash message
* @returns Object containing stash status
*/
handleWorkingDirectoryChanges(branchName) {
// Check for unstaged changes (working directory)
const hasUnstagedChanges = !this.executeGitCommand('git diff --quiet', 'Checking for unstaged changes').success;
// Check for staged changes (index)
const hasStagedChanges = !this.executeGitCommand('git diff --cached --quiet', 'Checking for staged changes').success;
const isDirty = hasUnstagedChanges || hasStagedChanges;
let pushedStash = false;
if (isDirty) {
logger.debug(`Repository is dirty: unstaged=${hasUnstagedChanges}, staged=${hasStagedChanges}`);
const stashMessage = `auto-stash: checkout -> ${branchName}`;
const stashResult = this.executeGitCommand(`git stash push -u -m "${stashMessage}"`, 'Saving working directory changes to stash');
if (!stashResult.success) {
logger.error('Uncommitted changes detected and unable to auto-stash. Operation aborted.');
return { pushedStash: false };
}
pushedStash = true;
}
else {
logger.debug('Repository is clean, no stash needed');
}
return { pushedStash };
}
/**
* Fetches and validates the remote branch exists.
* @param branchName The branch name to fetch and validate
* @param remoteName The remote name to use
* @param pushedStash Whether stash was pushed (for rollback)
* @returns True if remote branch exists and was fetched successfully
*/
fetchAndValidateRemoteBranch(branchName, remoteName, pushedStash) {
// Fetch only the target branch to reduce overhead (removed --prune to avoid deleting other remote tracking branches)
const fetchResult = this.executeGitCommand(`git fetch ${remoteName} "${branchName}":"refs/remotes/${remoteName}/${branchName}"`, 'Fetching target branch');
if (!fetchResult.success) {
logger.error(`Remote branch ${remoteName}/${branchName} does not exist or fetch failed.`);
this.rollbackStash(pushedStash);
return false;
}
// Double-check remote branch existence for safety
const remoteProbe = this.executeGitCommand(`git ls-remote --heads ${remoteName} "${branchName}"`).output;
if (!remoteProbe) {
logger.error(`Remote branch not found: ${remoteName}/${branchName}.`);
this.rollbackStash(pushedStash);
return false;
}
return true;
}
/**
* Checks if local branch exists.
* @param branchName The branch name to check
* @returns True if local branch exists
*/
checkLocalBranchExists(branchName) {
const result = this.executeGitCommand(`git rev-parse --verify --quiet "${branchName}"`);
return result.success && result.output.trim() !== '';
}
/**
* Checkouts to existing local branch and attempts fast-forward.
* @param branchName The branch name to checkout
* @param remoteName The remote name to use
* @param pushedStash Whether stash was pushed (for rollback)
* @returns True if checkout was successful, false otherwise
*/
checkoutExistingBranch(branchName, remoteName, pushedStash) {
const checkoutResult = this.executeGitCommand(`git checkout "${branchName}"`, `Switching to local branch ${branchName}`);
if (!checkoutResult.success) {
this.rollbackStash(pushedStash);
return false;
}
// Check if remote branch still exists before attempting merge
const remoteBranchExists = this.executeGitCommand(`git ls-remote --heads ${remoteName} "${branchName}"`);
if (!remoteBranchExists.success || !remoteBranchExists.output.trim()) {
logger.warn(`Remote branch ${remoteName}/${branchName} does not exist. Unset upstream tracking.`);
// Unset upstream tracking to avoid "upstream branch does not exist" warnings
this.executeGitCommand(`git branch --unset-upstream`, 'Unsetting upstream tracking');
logger.info(`Checked out to local branch ${branchName} without remote tracking.`);
return true;
}
// Attempt safe fast-forward to remote (no merge commits)
const fastForwardResult = this.executeGitCommand(`git merge --ff-only "${remoteName}/${branchName}"`, 'Fast-forwarding to remote');
if (!fastForwardResult.success) {
logger.warn(`Unable to fast-forward to ${remoteName}/${branchName} (local branch may have additional commits). ` +
`Current commit preserved. To discard local divergence, manually run: git reset --hard ${remoteName}/${branchName}`);
}
return true;
}
/**
* Creates a new tracking branch from remote.
* @param branchName The branch name to create
* @param remoteName The remote name to use
* @param pushedStash Whether stash was pushed (for rollback)
* @returns True if branch creation was successful, false otherwise
*/
createTrackingBranch(branchName, remoteName, pushedStash) {
const createResult = this.executeGitCommand(`git checkout -b "${branchName}" "${remoteName}/${branchName}"`, `Creating local tracking branch ${branchName}`);
if (!createResult.success) {
this.rollbackStash(pushedStash);
return false;
}
return true;
}
/**
* Restores working directory changes from stash if applicable.
* @param pushedStash Whether stash was pushed
*/
restoreWorkingDirectoryChanges(pushedStash) {
if (!pushedStash)
return;
const popResult = this.executeGitCommand('git stash pop', 'Restoring previous changes (stash pop)');
if (!popResult.success) {
logger.warn('Conflicts may have occurred during stash pop. Please resolve conflicts manually and commit. ' +
'Conflicted files have been marked.');
}
}
/**
* Logs the completion of checkout operation.
* @param previousHead The previous HEAD commit hash
* @param branchName The target branch name
*/
logCheckoutCompletion(previousHead, branchName) {
const newHead = this.executeGitCommand('git rev-parse --short=12 HEAD').output;
logger.info(`Checkout completed: ${previousHead || '(unknown)'} → ${newHead} @ ${branchName}`);
}
/**
* Rolls back stash if it was pushed.
* @param pushedStash Whether stash was pushed
*/
rollbackStash(pushedStash) {
if (pushedStash) {
this.executeGitCommand('git stash pop', 'Rollback: restoring stash');
}
}
/**
* Executes a git command and returns structured result.
* @param command The git command to execute
* @param description Optional description for logging
* @returns Object containing success status and output
*/
executeGitCommand(command, description) {
try {
const output = this.shell.run(command);
if (description) {
logger.debug(`${description} ✓`);
}
return { success: true, output: (output ?? '').toString().trim() };
}
catch (error) {
if (description) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(`${description} ✗: ${errorMessage}`);
}
return { success: false, output: '' };
}
}
getUserName() {
try {
return StringUtil.sanitizeName(this.shell.runProcess("git", "config", "user.name"));
}
catch (error) {
logger.warn('Failed to get git user name:', error);
return 'unknown-user';
}
}
/**
* Get git diff of staged changes
* @param options Diff options
* @returns Git diff output
*/
getDiff(options = {}) {
try {
const { includeBinary = false, nameOnly = false } = options;
if (nameOnly) {
return this.shell.runProcess("git", "diff", "--cached", "--name-only");
}
if (includeBinary) {
// Force treat all files as text (may produce unreadable output for binary files)
return this.shell.runProcess("git", "diff", "--cached", "--text");
}
// Default behavior: Exclude binary files to avoid unreadable output
return this.getDiffExcludingBinary();
}
catch (error) {
logger.warn('Failed to get git diff:', error);
return '';
}
}
/**
* Get diff excluding binary files
* @returns Git diff output with binary files excluded
*/
getDiffExcludingBinary() {
try {
// Get list of staged files
const stagedFiles = this.getChangedFiles();
if (stagedFiles.length === 0) {
return '';
}
// Filter out binary files
const textFiles = [];
for (const file of stagedFiles) {
if (!this.isBinaryFile(file, { cached: true })) {
textFiles.push(file);
}
}
if (textFiles.length === 0) {
return 'All staged files are binary files.';
}
// Get diff for text files only
return this.shell.runProcess("git", "diff", "--cached", "--", ...textFiles);
}
catch (error) {
logger.warn('Error filtering binary files, falling back to default diff:', error);
return this.shell.runProcess("git", "diff", "--cached");
}
}
/**
* Check if a file is binary
* @param filePath File path to check
* @param options Options for binary detection
* @returns True if file is binary, false otherwise
*/
isBinaryFile(filePath, options = {}) {
try {
const { cached = true, branchComparison } = options;
// Use git to check if file is binary using runWithExitCode for better error handling
const args = ["diff"];
if (branchComparison) {
// For branch comparison
args.push(branchComparison);
}
else if (cached) {
// For staged changes
args.push("--cached");
}
// For unstaged changes, no additional flag needed
args.push("--numstat", "--", filePath);
const result = this.shell.runWithExitCode("git", ...args);
// Check if command succeeded
if (!result.success || result.exitCode !== 0) {
logger.debug(`Failed to check if ${filePath} is binary (exit code: ${result.exitCode}):`, result.output);
return false; // Assume not binary if we can't determine
}
// Binary files show "- - filename" in numstat output
const lines = result.output.trim().split('\n');
for (const line of lines) {
if (line.includes(filePath) && line.startsWith('-\t-\t')) {
return true;
}
}
return false;
}
catch (error) {
// If we can't determine, assume it's not binary
logger.debug(`Could not determine if ${filePath} is binary:`, error);
return false;
}
}
/**
* Get git diff of specific files (unstaged changes)
* @param filePaths Array of file paths to check diff for
* @param options Diff options
* @returns Git diff output
*/
getDiffForFiles(filePaths, options = {}) {
if (filePaths.length === 0) {
return '';
}
const { includeBinary = false } = options;
if (includeBinary) {
// Include all files, treat binary as text (may produce unreadable output)
const args = ["git", "diff", "--text"];
args.push(...filePaths);
return this.shell.runProcess(args[0], ...args.slice(1));
}
// Default behavior: Filter out binary files
return this.getDiffForFilesExcludingBinary(filePaths);
}
/**
* Get diff for specific files excluding binary files
* @param filePaths Array of file paths to check diff for
* @returns Git diff output with binary files excluded
*/
getDiffForFilesExcludingBinary(filePaths) {
try {
// Filter out binary files
const textFiles = [];
for (const file of filePaths) {
if (!this.isBinaryFile(file, { cached: false })) {
textFiles.push(file);
}
}
if (textFiles.length === 0) {
return 'All specified files are binary files.';
}
// Get diff for text files only
const args = ["git", "diff"];
args.push(...textFiles);
return this.shell.runProcess(args[0], ...args.slice(1));
}
catch (error) {
logger.warn('Error filtering binary files, falling back to default diff:', error);
const args = ["git", "diff"];
args.push(...filePaths);
return this.shell.runProcess(args[0], ...args.slice(1));
}
}
/**
* Get diff between two branches
* @param baseBranch Base branch name
* @param targetBranch Target branch name
* @param options Diff options
* @returns Git diff output between branches
*/
getDiffBetweenBranches(baseBranch, targetBranch, options = {}) {
try {
if (!baseBranch || !targetBranch) {
logger.warn('Both baseBranch and targetBranch must be provided');
return '';
}
const { includeBinary = false } = options;
if (includeBinary) {
// Include all files, treat binary as text (may produce unreadable output)
const diffOutput = this.tryGetDiffBetweenBranches(baseBranch, targetBranch, ["--text"]);
if (diffOutput !== null) {
logger.debug(`Got diff between ${baseBranch} and ${targetBranch} (including binary)`);
return diffOutput;
}
else {
logger.error(`Failed to get diff between ${baseBranch} and ${targetBranch}`);
return '';
}
}
// Default behavior: Filter out binary files
const diffOutput = this.getDiffBetweenBranchesExcludingBinary(baseBranch, targetBranch);
logger.debug(`Got diff between ${baseBranch} and ${targetBranch} (excluding binary)`);
return diffOutput;
}
catch (error) {
logger.error(`Error getting diff between branches ${baseBranch} and ${targetBranch}:`, error);
return '';
}
}
/**
* Try to get diff between branches using different formats
* @param baseBranch Base branch name
* @param targetBranch Target branch name
* @param extraArgs Extra arguments for git diff
* @returns Diff output or null if failed
*/
tryGetDiffBetweenBranches(baseBranch, targetBranch, extraArgs = []) {
// Try different branch reference formats
const branchFormats = [
`${baseBranch}...${targetBranch}`,
`${this.getRemoteName()}/${baseBranch}...${targetBranch}`,
`${baseBranch}..${targetBranch}`,
`${this.getRemoteName()}/${baseBranch}..${targetBranch}`
];
for (const format of branchFormats) {
const args = ["diff", ...extraArgs, format];
const result = this.shell.runWithExitCode("git", ...args);
if (result.success && result.exitCode === 0) {
logger.debug(`Successfully got diff using format: ${format} (exit code: ${result.exitCode})`);
return result.output;
}
else {
logger.debug(`Failed to get diff using format: ${format} (exit code: ${result.exitCode})`);
}
}
return null;
}
/**
* Get diff between branches excluding binary files
* @param baseBranch Base branch name
* @param targetBranch Target branch name
* @returns Git diff output with binary files excluded
*/
getDiffBetweenBranchesExcludingBinary(baseBranch, targetBranch) {
try {
// Get list of changed files between branches
const changedFiles = this.getChangedFilesBetweenBranches(baseBranch, targetBranch);
if (changedFiles.length === 0) {
return '';
}
// Filter out binary files
const textFiles = [];
for (const file of changedFiles) {
if (!this.isBinaryFile(file, { branchComparison: `${baseBranch}...${targetBranch}` })) {
textFiles.push(file);
}
}
if (textFiles.length === 0) {
return 'All changed files between branches are binary files.';
}
// Get diff for text files only using the helper method
const diffOutput = this.tryGetDiffBetweenBranches(baseBranch, targetBranch, ["--", ...textFiles]);
if (diffOutput !== null) {
return diffOutput;
}
else {
logger.warn('Failed to get diff with all formats, trying fallback');
return this.tryGetDiffBetweenBranches(baseBranch, targetBranch) || '';
}
}
catch (error) {
logger.warn('Error filtering binary files, falling back to default diff:', error);
return this.tryGetDiffBetweenBranches(baseBranch, targetBranch) || '';
}
}
/**
* Get list of changed files between two branches
* @param baseBranch Base branch name
* @param targetBranch Target branch name
* @returns Array of changed file paths
*/
getChangedFilesBetweenBranches(baseBranch, targetBranch) {
try {
if (!baseBranch || !targetBranch) {
logger.warn('Both baseBranch and targetBranch must be provided');
return [];
}
// First try with remote prefix for base branch
let filesOutput = '';
let success = false;
// Try different branch reference formats
const branchFormats = [
`${baseBranch}...${targetBranch}`,
`${this.getRemoteName()}/${baseBranch}...${targetBranch}`,
`${baseBranch}..${targetBranch}`,
`${this.getRemoteName()}/${baseBranch}..${targetBranch}`
];
for (const format of branchFormats) {
const result = this.shell.runWithExitCode("git", "diff", "--name-only", format);
if (result.success && result.exitCode === 0) {
filesOutput = result.output.trim();
success = true;
logger.debug(`Successfully got changed files using format: ${format} (exit code: ${result.exitCode})`);
break;
}
else {
logger.debug(`Failed to get changed files using format: ${format} (exit code: ${result.exitCode})`);
}
}
if (!success) {
logger.error(`Failed to get diff between ${baseBranch} and ${targetBranch} using all formats`);
return [];
}
const files = filesOutput ? filesOutput.split('\n').filter(Boolean) : [];
logger.debug(`Found ${files.length} changed files between ${baseBranch} and ${targetBranch}`);
return files;
}
catch (error) {
logger.error(`Error getting changed files between branches ${baseBranch} and ${targetBranch}:`, error);
return [];
}
}
/**
* Add specific file to staging area
* @param filePath File path to add
*/
addFile(filePath) {
logger.info(`Adding file: ${filePath}`);
this.shell.runProcess("git", "add", "-f", filePath);
}
/**
* Add multiple files to staging area
* @param filePaths Array of file paths to add
*/
addFiles(filePaths, batchSize = 1000) {
if (filePaths.length === 0)
return;
logger.info(`Adding ${filePaths.length} files in batches of ${batchSize}`);
for (let i = 0; i < filePaths.length; i += batchSize) {
const batch = filePaths.slice(i, i + batchSize);
this.shell.runProcess("git", "add", "-f", ...batch);
}
}
/**
* Create a new branch
* @param branchName Branch name to create
*/
createBranch(branchName) {
logger.info(`Creating branch: ${branchName}`);
this.shell.runProcess("git", "checkout", "-b", branchName);
}
/**
* Commit staged changes
* @param message Commit message
*/
commit(message) {
logger.info('Committing changes...');
logger.debug(`Commit message: ${message.substring(0, 100)}${message.length > 100 ? '...' : ''}`);
if (!message.includes("\n")) {
// 单行 commit
const escapedMessage = message
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/`/g, "\\`");
this.shell.runProcess("git", "commit", "-m", escapedMessage);
return;
}
const lines = message.split(/\r?\n/).map(line => line.trimEnd());
const args = ["commit"];
for (const line of lines) {
args.push("-m", line);
}
this.shell.runProcess("git", ...args);
}
/**
* Push current branch to remote
* @param branchName Branch name to push
*/
push(branchName) {
logger.info(`Pushing branch: ${branchName}`);
this.shell.runProcess("git", "push", "-u", this.getRemoteName(), branchName);
}
/**
* Create branch, commit and push (legacy method)
* @param branch Branch name
* @param message Commit message
*/
commitAndPush(branch, message) {
if (this.checkLocalBranchExists(branch)) {
logger.info(`Branch ${branch} already exists, skipping creation`);
return false;
}
this.createBranch(branch);
this.commit(message);
this.push(branch);
return true;
}
getChangedFiles(limit) {
try {
const output = this.shell.runProcess("git", "diff", "--cached", "--name-only").trim();
if (!output) {
return [];
}
const files = output.split("\n").filter(Boolean);
if (limit) {
return files.slice(0, limit);
}
return files;
}
catch (error) {
logger.warn('Failed to get changed files:', error);
return [];
}
}
/**
* Get git repository root directory
*/
getRepositoryRoot() {
return this.shell.runProcess("git", "rev-parse", "--show-toplevel").trim();
}
/**
* Gets the default remote name for the current repository.
* Tries to detect the most appropriate remote in the following order:
* 1. 'origin' (most common)
* 2. 'upstream' (common in fork workflows)
* 3. First available remote
* @returns The default remote name or 'origin' as fallback
*/
getRemoteName() {
if (this.remote_name) {
return this.remote_name;
}
try {
const remotesOutput = this.shell.runProcess("git", "remote").trim();
if (!remotesOutput) {
logger.warn('No remotes found, using "origin" as fallback');
this.remote_name = 'origin';
return this.remote_name;
}
const remotes = remotesOutput.split('\n').map(r => r.trim()).filter(r => r);
// Prefer 'origin' if it exists
if (remotes.includes('origin')) {
this.remote_name = 'origin';
return this.remote_name;
}
// Fall back to 'upstream' if it exists
if (remotes.includes('upstream')) {
this.remote_name = 'upstream';
logger.debug('Using "upstream" as default remote');
return this.remote_name;
}
// Use the first available remote
if (remotes.length > 0) {
this.remote_name = remotes[0];
logger.debug(`Using "${this.remote_name}" as default remote`);
return this.remote_name;
}
}
catch (error) {
logger.warn('Failed to detect remotes, using "origin" as fallback');
}
// Final fallback
this.remote_name = 'origin';
return this.remote_name;
}
/**
* Get remote URL for specified remote
*/
getRemoteUrl(remoteName) {
remoteName = remoteName || this.getRemoteName();
if (!remoteName) {
return 'No remote configured';
}
let remote_url = this.remote_urls.get(remoteName);
if (remote_url) {
return remote_url;
}
try {
remote_url = this.shell.runProcess("git", "remote", "get-url", remoteName).trim();
this.remote_urls.set(remoteName, remote_url);
return remote_url;
}
catch (error) {
return `Error getting URL for remote '${remoteName}' ${error}`;
}
}
/**
* Extract hostname from Git remote URL
* @param remoteUrl Git remote URL (optional, will get current remote if not provided)
* @returns Hostname (e.g., 'github.com', 'gitlab.example.com')
*/
extractHostnameFromRemoteUrl(remoteUrl) {
const url = remoteUrl || this.getRemoteUrl();
try {
// Handle SSH URLs (git@hostname:user/repo.git)
if (url.startsWith('git@')) {
const match = url.match(/git@([^:]+):/);
return match ? match[1] : '';
}
// Handle HTTPS URLs (https://hostname/user/repo.git)
if (url.startsWith('http')) {
const urlObj = new URL(url);
return urlObj.hostname;
}
return '';
}
catch (error) {
logger.warn(`Could not extract hostname from Git URL: ${url}`);
return '';
}
}
/**
* Extract base URL from Git remote URL with protocol detection
* @param remoteUrl Git remote URL (optional, will get current remote if not provided)
* @returns Base URL (e.g., "https://github.com", "https://gitlab.example.com")
*/
async extractBaseUrlFromRemoteUrl(remoteUrl) {
const url = remoteUrl || this.getRemoteUrl();
try {
// Handle SSH URLs (git@hostname:user/repo.git)
if (url.startsWith('git@')) {
const match = url.match(/git@([^:]+):/);
if (match) {
const hostname = match[1];
// Auto-detect protocol by trying HTTPS first, then fallback to HTTP
return await this.detectProtocolForHost(hostname);
}
}
// Handle HTTPS/HTTP URLs (https://hostname/user/repo.git)
if (url.startsWith('http')) {
const match = url.match(/^(https?:\/\/[^\/]+)/);
return match ? match[1] : '';
}
return '';
}
catch (error) {
logger.warn(`Could not extract base URL from Git URL: ${url}`);
return '';
}
}
/**
* Parse project path from Git remote URL
* @param remoteUrl Git remote URL (optional, will get current remote if not provided)
* @returns Project path (e.g., "user/repo")
*/
parseProjectPathFromUrl(remoteUrl) {
const url = remoteUrl || this.getRemoteUrl();
try {
// Handle SSH URLs (git@hostname:user/repo.git)
const sshMatch = url.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
if (sshMatch) {
return sshMatch[2];
}
// Handle HTTPS/HTTP URLs (https://hostname/user/repo.git)
const httpMatch = url.match(/^https?:\/\/[^\/]+\/(.+?)(?:\.git)?$/);
if (httpMatch) {
return httpMatch[1];
}
return null;
}
catch (error) {
logger.error(`Failed to parse git remote URL: ${url}`, error);
return null;
}
}
/**
* Detect the appropriate protocol (HTTPS/HTTP) for a given hostname
* @param hostname The hostname to check
* @returns Base URL with detected protocol
*/
async detectProtocolForHost(hostname) {
// Check cache first
if (GitService.protocolCache.has(hostname)) {
return GitService.protocolCache.get(hostname);
}
// Well-known HTTPS-only hosts
const httpsOnlyHosts = [
'github.com',
'gitlab.com',
'bitbucket.org',
'gitee.com',
'codeberg.org',
'git.sr.ht',
'coding.net'
];
if (httpsOnlyHosts.includes(hostname)) {
const result = `https://${hostname}`;
GitService.protocolCache.set(hostname, result);
return result;
}
// Start probing
return await this.probeProtocolForHost(hostname);
}
/**
* Probe a hostname to detect HTTPS/HTTP support in background
* Updates the cache when detection is complete
* @param hostname The hostname to probe
* @returns Promise<string> The URL with the detected protocol
*/
async probeProtocolForHost(hostname) {
const httpsUrl = `https://${hostname}`;
try {
logger.debug(`Probing protocol support for: ${hostname}`);
// Try HTTPS first (modern standard)
if (await this.isProtocolSupported(httpsUrl)) {
logger.debug(`HTTPS supported for: ${hostname}`);
GitService.protocolCache.set(hostname, httpsUrl);
return httpsUrl;
}
// Fallback to HTTP
const httpUrl = `http://${hostname}`;
if (await this.isProtocolSupported(httpUrl)) {
logger.debug(`HTTP supported for: ${hostname} (HTTPS not available)`);
GitService.protocolCache.set(hostname, httpUrl);
return httpUrl;
}
// If both fail, keep HTTPS as fallback (already in cache)
logger.warn(`Could not connect to ${hostname}, keeping HTTPS as default`);
}
catch (error) {
logger.warn(`Error probing ${hostname}:`, error);
}
return httpsUrl;
}
/**
* Get detected protocol for hostname (synchronous, may return cached result)
* @param hostname The hostname to check
* @returns Base URL with detected protocol
*/
async getDetectedProtocolForHost(hostname) {
return await this.detectProtocolForHost(hostname);
}
/**
* Clear protocol cache for a specific hostname or all hostnames
* @param hostname Optional hostname to clear, if not provided clears all cache
*/
static clearProtocolCache(hostname) {
if (hostname) {
GitService.protocolCache.delete(hostname);
logger.debug(`Cleared protocol cache for: ${hostname}`);
}
else {
GitService.protocolCache.clear();
logger.debug('Cleared all protocol cache');
}
}
/**
* Get current protocol cache (for debugging)
* @returns Copy of current cache entries
*/
static getProtocolCache() {
return Object.fromEntries(GitService.protocolCache);
}
/**
* Force re-detection of protocol for hostname (asynchronous)
* @param hostname The hostname to re-detect
* @returns Promise<Base URL with detected protocol>
*/
async forceDetectProtocolForHost(hostname) {
// Clear cache for this hostname
GitService.protocolCache.delete(hostname);
// Try HTTPS first
const httpsUrl = `https://${hostname}`;
if (await this.isProtocolSupported(httpsUrl)) {
logger.debug(`HTTPS supported for: ${hostname}`);
GitService.protocolCache.set(hostname, httpsUrl);
return httpsUrl;
}
// Fallback to HTTP
const httpUrl = `http://${hostname}`;
if (await this.isProtocolSupported(httpUrl)) {
logger.debug(`HTTP supported for: ${hostname} (HTTPS not available)`);
GitService.protocolCache.set(hostname, httpUrl);
return httpUrl;
}
// If both fail, use HTTPS as fallback
logger.warn(`Could not connect to ${hostname}, defaulting to HTTPS`);
GitService.protocolCache.set(hostname, httpsUrl);
return httpsUrl;
}
/**
* Test if a protocol is supported for a given URL
* @param baseUrl Base URL to test (e.g., "https://example.com")
* @returns True if the protocol is supported
*/
async isProtocolSupported(baseUrl) {
// Create an AbortController for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2000); // 2 second timeout
try {
// Try to make a simple HEAD request to test connectivity
await fetch(baseUrl, {
method: 'HEAD',
headers: {
'User-Agent': 'AIFlow-Git-Probe/1.0'
},
signal: controller.signal
});
clearTimeout(timeoutId);
// Any response (even 404) indicates the protocol is supported
return true;
}
catch (error) {
// Connection failed, protocol not supported or host unreachable
return false;
}
finally {
clearTimeout(timeoutId);
}
}
/**
* Get current branch name
*/
getCurrentBranch() {
try {
return this.shell.runProcess("git", "branch", "--show-current").trim();
}
catch (error) {
// Fallback for older git versions
try {
const result = this.shell.runProcess("git", "rev-parse", "--abbrev-ref", "HEAD").trim();
return result === 'HEAD' ? '' : result;
}
catch (fallbackError) {
return '';
}
}
}
/**
* Get target branch for merge request (default branch or fallback)
* @returns Target branch name
*/
getTargetBranch() {
try {
// Try to get the default branch from git remote
const currentBranch = this.getCurrentBranch();
if (currentBranch && this.hasRemoteBranch(currentBranch)) {
return currentBranch;
}
logger.debug(`Current branch \`${currentBranch}\` does not exist in remote`);
const baseBranch = this.getBaseBranch();
if (baseBranch && this.hasRemoteBranch(baseBranch)) {
return baseBranch;
}
// Get all remote branches to find the best default
const remoteBranches = this.getRemoteBranches();
if (remoteBranches.length === 0) {
logger.warn('No remote branches found, using "main" as fallback');
return 'main';
}
// Common default branch names to try (in order of preference)
const defaultBranches = ['main', 'master', 'develop', 'dev'];
// If current branch is one of the default branches and exists remotely, use it
if (currentBranch && defaultBranches.includes(currentBranch) && remoteBranches.includes(currentBranch)) {
return currentBranch;
}
// Otherwise, try to find the default branch by checking which exists
for (const branch of defaultBranches) {
if (remoteBranches.includes(branch)) {
logger.debug(`Using \`${branch}\` as target branch`);
return branch;
}
}
// If no common default branches exist, use the first remote branch
const firstBranch = remoteBranches[0];
logger.debug(`No common default branches found, using first remote branch: ${firstBranch}`);
return firstBranch;
}
catch (error) {
logger.warn(`Could not determine target branch, using 'main': ${error}`);
return 'main';
}
}
/**
* Get current commit hash
*/
getCurrentCommit() {
return this.shell.run("git rev-parse HEAD").trim();
}
/**
* Get all remote branches from the remote repository
* @param remoteName Remote name (optional, uses default remote if not provided)
* @returns Array of remote branch names (without remote prefix)
*/
getRemoteBranches(remoteName) {
try {
const remote = remoteName || this.getRemoteName();
const output = this.shell.runProcess("git", "ls-remote", "--heads", remote);
const branches = [];
const lines = output.trim().split('\n');
for (const line of lines) {
if (line.trim()) {
// Format: "commit_hash refs/heads/branch_name"
const match = line.match(/refs\/heads\/(.+)$/);
if (match) {
branches.push(match[1]);
}
}
}
logger.debug(`Found ${branches.length} remote branches: ${branches.join(', ')}`);
return branches;
}
catch (error) {
logger.warn(`Failed to get remote branches: ${error}`);
return [];
}
}
/**
* Check if a remote branch exists
* @param branchName Branch name to check (without remote prefix, e.g., 'main')
* @param remoteName Remote name (optional, uses default remote if not provided)
* @returns True if branch exists, false otherwise
*/
hasRemoteBranch(branchName, remoteName) {
try {
const remote = remoteName || this.getRemoteName();
const output = this.shell.runProcess("git", "ls-remote", "--heads", remote, branchName);
// If the branch exists, ls-remote will return a line with the branch
const hasMatch = output.trim().includes(`refs/heads/${branchName}`);
if (hasMatch) {
logger.debug(`Remote branch ${remote}/${branchName} exists`);
}
else {
logger.debug(`Remote branch ${remote}/${branchName} does not exist`);
}
return hasMatch;
}
catch (error) {
logger.debug(`Error checking remote branch ${branchName}: ${error}`);
return false;
}
}
/**
* Get short commit hash
*/
getShortCommit() {
return this.shell.runProcess("git", "rev-parse", "--short", "HEAD").trim();
}
/**
* Check if repository has uncommitted changes
*/
hasUncommittedChanges() {
const status = this.shell.runProcess("git", "status", "--porcelain").trim();
return status.length > 0;
}
/**
* Check if repository has staged changes
*/
hasStagedChanges() {
const status = this.shell.runProcess("git", "diff", "--cached", "--name-only").trim();
return status.length > 0;
}
/**
* Get git repository status for all files
* @returns Array of GitFileStatus objects representing file changes
*/
status() {
try {
const statusOutput = this.shell.runProcess("git", "status", "--short", "--ignore-submodules", "--porcelain", "--untracked-files=all");
if (!statusOutput) {
return [];
}
return statusOutput.split('\n').map(line => this.parseStatusLine(line)).filter(Boolean);
}
catch (error) {
logger.error('Error getting git status:', error);
return [];
}
}
/**
* Parse a single git status line
* @param line Git status output line (format: "XY filename")
* @returns GitFileStatus object or null if invalid line
*/
parseStatusLine(line) {
if (!line || line.length < 3) {
return null;
}
// Git status format: XY filename
// X = index status, Y = working tree status
const indexStatus = line[0];
const workTreeStatus = line[1];
const path = line.substring(3).trim();
if (!path || path.startsWith('.aiflow')) {
return null;
}
// Determine if file is untracked
const isUntracked = indexStatus === '?' && workTreeStatus === '?';
// Generate human readable status description
const statusDescription = this.getStatusDescription(indexStatus, workTreeStatus);
return {
path,
indexStatus,
workTreeStatus,
isUntracked,
statusDescription
};
}
/**
* Get human readable description for git status codes
* @param indexStatus Index status character
* @param workTreeStatus Working tree status character
* @returns Human readable status description
*/
getStatusDescription(indexStatus, workTreeStatus) {
// Handle untracked files
if (indexStatus === '?' && workTreeStatus === '?') {
return 'Untracked';
}
const descriptions = [];
// Index status (staged changes)
switch (indexStatus) {
case 'A':
descriptions.push('Added to index');
break;
case 'M':
descriptions.push('Modified in index');
break;
case 'D':
descriptions.push('Deleted from index');
break;
case 'R':
descriptions.push('Renamed in index');
break;
case 'C':
descriptions.push('Copied in index');
break;
case 'U':
descriptions.push('Updated but unmerged');
break;
case ' ': break; // No change in index
default:
descriptions.push(`Index: ${indexStatus}`);
break;
}
// Working tree status (unstaged changes)
switch (workTreeStatus) {
case 'M':
descriptions.push('Modified in working tree');
break;
case 'D':
descriptions.push('Deleted in working tree');
break;
case 'A':
descriptions.push('Added in working tree');
break;
case 'U':
descriptions.push('Updated but unmerged');
break;
case ' ': break; // No change in working tree
default:
descriptions.push(`Working tree: ${workTreeStatus}`);
break;
}
return descriptions.length > 0 ? descriptions.join(', ') : 'No changes';
}
/**
* Display comprehensive Git repository information
*/
showGitInfo() {
logger.info('Git Repository Information');
logger.info('─'.repeat(50));
try {
// Current working directory
const currentDir = process.cwd();
logger.info(`Current Working Directory: ${currentDir}`);
// Repository root
const repoRoot = this.getRepositoryRoot();
logger.info(`Repository Root: ${repoRoot}`);
// Check if we're in the repository
const isInRepo = currentDir.startsWith(repoRoot.replace(/\//g, '\\')) ||
currentDir.startsWith(repoRoot.replace(/\\/g, '/'));
logger.info(`Working in Repository: ${isInRepo ? 'Yes' : 'No'}`);
// Current branch
const currentBranch = this.getCurrentBranch();
logger.info(`Current Branch: \`${currentBranch}\``);
// Current commit
const currentCommit = this.getCurrentCommit();
const shortCommit = this.getShortCommit();
logger.info(`Current Commit: ${shortCommit} (${currentCommit})`);
// Remote information
const remoteName = this.getRemoteName();
if (remoteName) {
const remoteUrl = this.getRemoteUrl(remoteName);
logger.info(`Remote Name: ${remoteName}`);
logger.info(`Remote URL: ${remoteUrl}`);
}
else {
logger.info('Remote: No remote configured');
}
// Repository status
const hasUncommitted = this.hasUncommittedChanges();
const hasStaged = this.hasStagedChanges();
logger.info('Repository Status:');
logger.info(` Uncommitted changes: ${hasUncommitted ? 'Yes' : 'No'}`);
logger.info(` Staged changes: ${hasStaged ? 'Yes' : 'No'}`);
// Show some recent files if there are changes
if (hasUncommitted) {
const statusOutput = this.shell.run("git status --porcelain").trim();
const files = statusOutput.split('\n').slice(0, 5);
logger.info('Recent Changes (top 5):');
files.forEach(file => {
const status = file.substring(0, 2);
const fileName = file.substring(3);
const statusIcon = status.includes('M') ? 'Modified' :
status.includes('A') ? 'Added' :
status.includes('D') ? 'Deleted' :
status.includes('??') ? 'Untracked' : 'Changed';
logger.info(` ${statusIcon}: ${fileName}`);
});
}
// User information
const userName = this.getUserName();
const userEmail = this.shell.runProcess("git", "config", "user.email").trim();
logger.info(`Git User: ${userName} <${userEmail}>`);
}
catch (error) {
logger.error(`Error getting Git information: ${error}`);
}
logger.info('─'.repeat(50));
}
/**
* Get the most likely parent branch of the current branch
* @returns Base branch name or null if not found or in detached HEAD
*/
getBaseBranch() {
try {
const currentBranch = this.getCurrentBranch();
if (!currentBranch || currentBranch === 'HEAD')
return null;
const remotes = this.shell
.runProcess("git", "remote")
.trim()
.split('\n')
.map(r => r.trim())
.filter(Boolean);
cons