git-validators.ts•15.6 kB
/**
 * @fileoverview CLI provider git validators using git command execution
 * @module services/git/providers/cli/utils/git-validators
 *
 * This module contains validators that require git command execution.
 * These are used internally by the CLI provider for pre-flight checks.
 */
import { JsonRpcErrorCode, McpError } from '@/types-global/errors.js';
import { logger, type RequestContext } from '@/utils/index.js';
import { executeGitCommand } from './git-executor.js';
/**
 * Validate that a directory is a git repository.
 *
 * Uses `git rev-parse --is-inside-work-tree` to check if the path
 * is within a git working directory.
 *
 * @param path - Path to check
 * @param context - Request context for logging
 * @returns Promise resolving to true if valid git repository
 * @throws {McpError} If not a git repository or path is invalid
 *
 * @example
 * ```typescript
 * await validateGitRepository('/path/to/repo', appContext);
 * // Throws if not a git repo
 * ```
 */
export async function validateGitRepository(
  path: string,
  _context: RequestContext,
): Promise<boolean> {
  try {
    const result = await executeGitCommand(
      ['rev-parse', '--is-inside-work-tree'],
      path,
    );
    if (result.stdout.trim() !== 'true') {
      throw new McpError(
        JsonRpcErrorCode.ValidationError,
        `Not a git repository: ${path}`,
      );
    }
    return true;
  } catch (error) {
    if (error instanceof McpError) {
      throw error;
    }
    throw new McpError(
      JsonRpcErrorCode.ValidationError,
      `Not a git repository: ${path}`,
    );
  }
}
/**
 * Get current branch name.
 *
 * Returns the name of the current branch, or null if in detached HEAD state.
 * Uses `git symbolic-ref` which fails gracefully for detached HEAD.
 *
 * @param path - Repository path
 * @param context - Request context for logging
 * @returns Promise resolving to branch name or null if detached HEAD
 *
 * @example
 * ```typescript
 * const branch = await getCurrentBranch('/path/to/repo', appContext);
 * // Returns: 'main' or null
 * ```
 */
export async function getCurrentBranch(
  path: string,
  context: RequestContext,
): Promise<string | null> {
  try {
    const result = await executeGitCommand(
      ['symbolic-ref', '--short', 'HEAD'],
      path,
    );
    const branch = result.stdout.trim();
    return branch || null;
  } catch (_error) {
    // If symbolic-ref fails, we're in detached HEAD state
    logger.debug('Not on a branch (detached HEAD)', { ...context, path });
    return null;
  }
}
/**
 * Check if repository has uncommitted changes.
 *
 * Returns true if the working directory is clean (no staged or unstaged changes).
 * Uses `git status --porcelain` which outputs nothing when clean.
 *
 * @param path - Repository path
 * @param context - Request context for logging
 * @returns Promise resolving to true if working directory is clean
 *
 * @example
 * ```typescript
 * const isClean = await isWorkingDirectoryClean('/path/to/repo', appContext);
 * if (!isClean) {
 *   console.log('Repository has uncommitted changes');
 * }
 * ```
 */
export async function isWorkingDirectoryClean(
  path: string,
  _context: RequestContext,
): Promise<boolean> {
  const result = await executeGitCommand(['status', '--porcelain'], path);
  return result.stdout.trim() === '';
}
/**
 * Check if repository has uncommitted changes and validate for destructive operations.
 *
 * Many git operations should not proceed if there are uncommitted changes,
 * as they could result in data loss. This validator ensures the working
 * directory is clean unless explicitly forced.
 *
 * @param path - Repository path
 * @param context - Request context for logging
 * @param operation - Operation being performed (for error messages)
 * @param force - Whether operation is forced (bypasses check if true)
 * @throws {McpError} If uncommitted changes exist and operation is not forced
 *
 * @example
 * ```typescript
 * await validateCleanWorkingDirectory(
 *   '/path/to/repo',
 *   appContext,
 *   'checkout branch',
 *   false // force
 * );
 * // Throws if working directory has changes
 * ```
 */
export async function validateCleanWorkingDirectory(
  path: string,
  context: RequestContext,
  operation: string,
  force = false,
): Promise<void> {
  const result = await executeGitCommand(['status', '--porcelain'], path);
  const hasChanges = result.stdout.trim().length > 0;
  if (hasChanges && !force) {
    throw new McpError(
      JsonRpcErrorCode.ValidationError,
      `Cannot perform '${operation}' with uncommitted changes. Commit or stash changes first, or use force=true.`,
      {
        operation,
        hint: 'Use git_status to see uncommitted changes, or set force=true to proceed anyway.',
      },
    );
  }
  if (hasChanges && force) {
    logger.warning(
      `Proceeding with '${operation}' despite uncommitted changes`,
      {
        ...context,
        operation,
      },
    );
  }
}
/**
 * Validate that target branch exists before attempting merge/rebase/checkout.
 *
 * Prevents errors from attempting operations on non-existent branches.
 * Uses `git rev-parse --verify` to check branch existence.
 *
 * @param branchName - Branch name to validate
 * @param path - Repository path
 * @param context - Request context for logging
 * @throws {McpError} If branch does not exist
 *
 * @example
 * ```typescript
 * await validateBranchExists('feature/my-branch', '/path/to/repo', appContext);
 * // Throws if branch doesn't exist
 * ```
 */
export async function validateBranchExists(
  branchName: string,
  path: string,
  _context: RequestContext,
): Promise<void> {
  try {
    await executeGitCommand(
      ['rev-parse', '--verify', `refs/heads/${branchName}`],
      path,
    );
  } catch {
    throw new McpError(
      JsonRpcErrorCode.ValidationError,
      `Branch '${branchName}' does not exist`,
      {
        branchName,
        hint: 'Use git_branch to list available branches.',
      },
    );
  }
}
/**
 * Validate that remote exists before attempting remote operations.
 *
 * @param remoteName - Remote name to validate (e.g., 'origin')
 * @param path - Repository path
 * @param context - Request context for logging
 * @throws {McpError} If remote does not exist
 *
 * @example
 * ```typescript
 * await validateRemoteExists('origin', '/path/to/repo', appContext);
 * ```
 */
export async function validateRemoteExists(
  remoteName: string,
  path: string,
  _context: RequestContext,
): Promise<void> {
  try {
    await executeGitCommand(['remote', 'get-url', remoteName], path);
  } catch {
    throw new McpError(
      JsonRpcErrorCode.ValidationError,
      `Remote '${remoteName}' does not exist`,
      {
        remoteName,
        hint: 'Use git_remote to list available remotes.',
      },
    );
  }
}
/**
 * Validate that current HEAD has commits (repository is not empty).
 *
 * Some git operations require at least one commit to exist.
 * This check prevents errors from operations on empty repositories.
 *
 * @param path - Repository path
 * @param context - Request context for logging
 * @throws {McpError} If repository has no commits
 *
 * @example
 * ```typescript
 * await validateHasCommits('/path/to/repo', appContext);
 * // Throws if repository is empty (no commits)
 * ```
 */
export async function validateHasCommits(
  path: string,
  _context: RequestContext,
): Promise<void> {
  try {
    await executeGitCommand(['rev-parse', 'HEAD'], path);
  } catch {
    throw new McpError(
      JsonRpcErrorCode.ValidationError,
      'Repository has no commits yet',
      {
        hint: 'Make at least one commit before attempting this operation.',
      },
    );
  }
}
/**
 * Validate merge state - ensure no merge is in progress.
 *
 * Prevents starting a new merge/rebase/cherry-pick while another
 * operation is already in progress.
 *
 * @param path - Repository path
 * @param context - Request context for logging
 * @throws {McpError} If merge/rebase is in progress
 *
 * @example
 * ```typescript
 * await validateNotInMergeState('/path/to/repo', appContext);
 * ```
 */
export async function validateNotInMergeState(
  path: string,
  _context: RequestContext,
): Promise<void> {
  try {
    const result = await executeGitCommand(
      ['rev-parse', '--verify', 'MERGE_HEAD'],
      path,
    );
    if (result.stdout.trim()) {
      throw new McpError(
        JsonRpcErrorCode.ValidationError,
        'A merge is already in progress',
        {
          hint: 'Complete or abort the current merge before starting a new operation.',
        },
      );
    }
  } catch (error) {
    // MERGE_HEAD not found means no merge in progress - this is OK
    if (
      error instanceof McpError &&
      error.code === JsonRpcErrorCode.ValidationError
    ) {
      throw error; // Re-throw our own errors
    }
    // Other errors (MERGE_HEAD doesn't exist) are expected and mean we're good to proceed
  }
}
/**
 * Validate that the current branch is not detached HEAD.
 *
 * Some operations require being on a branch, not in detached HEAD state.
 *
 * @param path - Repository path
 * @param context - Request context for logging
 * @throws {McpError} If in detached HEAD state
 *
 * @example
 * ```typescript
 * await validateNotDetachedHead('/path/to/repo', appContext);
 * ```
 */
export async function validateNotDetachedHead(
  path: string,
  context: RequestContext,
): Promise<void> {
  const currentBranch = await getCurrentBranch(path, context);
  if (!currentBranch) {
    throw new McpError(
      JsonRpcErrorCode.ValidationError,
      'Cannot perform this operation in detached HEAD state',
      {
        hint: 'Checkout a branch first using git_checkout.',
      },
    );
  }
}
/**
 * Get the root directory of a git repository.
 *
 * Returns the absolute path to the top-level directory of the repository,
 * regardless of where in the working tree the path parameter points to.
 *
 * @param path - Path within the repository (can be any subdirectory)
 * @param context - Request context for logging
 * @returns Promise resolving to absolute path of repository root
 * @throws {McpError} If not within a git repository
 *
 * @example
 * ```typescript
 * const root = await getGitRoot('/path/to/repo/subdir', appContext);
 * // Returns: '/path/to/repo'
 * ```
 */
export async function getGitRoot(
  path: string,
  _context: RequestContext,
): Promise<string> {
  const result = await executeGitCommand(
    ['rev-parse', '--show-toplevel'],
    path,
  );
  return result.stdout.trim();
}
/**
 * Validate commit reference (hash, branch, tag).
 *
 * Uses `git rev-parse --verify` to check if the reference is valid.
 * Accepts full/short commit hashes, branch names, and tag names.
 *
 * @param ref - Reference to validate (hash, branch, or tag)
 * @param path - Repository path
 * @param context - Request context for logging
 * @returns Promise resolving to true if valid reference
 * @throws {McpError} If reference is invalid or doesn't exist
 *
 * @example
 * ```typescript
 * await validateCommitRef('main', '/path/to/repo', appContext); // OK
 * await validateCommitRef('a1b2c3d', '/path/to/repo', appContext); // OK
 * await validateCommitRef('invalid-ref', '/path/to/repo', appContext); // Throws
 * ```
 */
export async function validateCommitRef(
  ref: string,
  path: string,
  _context: RequestContext,
): Promise<boolean> {
  const result = await executeGitCommand(['rev-parse', '--verify', ref], path);
  if (!result.stdout.trim()) {
    throw new McpError(
      JsonRpcErrorCode.ValidationError,
      `Invalid commit reference: ${ref}`,
    );
  }
  return true;
}
/**
 * Get the commit hash for a given reference.
 *
 * Resolves any valid git reference (branch, tag, short hash, HEAD~1, etc.)
 * to its full SHA-1 commit hash.
 *
 * @param ref - Reference to resolve (branch, tag, hash, etc.)
 * @param path - Repository path
 * @param context - Request context for logging
 * @returns Promise resolving to full commit hash
 * @throws {McpError} If reference cannot be resolved
 *
 * @example
 * ```typescript
 * const hash = await getCommitHash('HEAD', '/path/to/repo', appContext);
 * // Returns: 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0'
 * ```
 */
export async function getCommitHash(
  ref: string,
  path: string,
  _context: RequestContext,
): Promise<string> {
  const result = await executeGitCommand(['rev-parse', ref], path);
  return result.stdout.trim();
}
/**
 * Check if a remote exists in the repository.
 *
 * @param remoteName - Name of the remote to check
 * @param path - Repository path
 * @param context - Request context for logging
 * @returns Promise resolving to true if remote exists
 *
 * @example
 * ```typescript
 * const hasOrigin = await hasRemote('origin', '/path/to/repo', appContext);
 * ```
 */
export async function hasRemote(
  remoteName: string,
  path: string,
  _context: RequestContext,
): Promise<boolean> {
  try {
    const result = await executeGitCommand(
      ['remote', 'get-url', remoteName],
      path,
    );
    return Boolean(result.stdout.trim());
  } catch {
    return false;
  }
}
/**
 * Get the remote URL for a given remote name.
 *
 * @param remoteName - Name of the remote (e.g., 'origin')
 * @param path - Repository path
 * @param context - Request context for logging
 * @returns Promise resolving to remote URL
 * @throws {McpError} If remote doesn't exist
 *
 * @example
 * ```typescript
 * const url = await getRemoteUrl('origin', '/path/to/repo', appContext);
 * // Returns: 'https://github.com/user/repo.git'
 * ```
 */
export async function getRemoteUrl(
  remoteName: string,
  path: string,
  _context: RequestContext,
): Promise<string> {
  const result = await executeGitCommand(
    ['remote', 'get-url', remoteName],
    path,
  );
  const url = result.stdout.trim();
  if (!url) {
    throw new McpError(
      JsonRpcErrorCode.ValidationError,
      `Remote '${remoteName}' does not exist`,
    );
  }
  return url;
}
/**
 * Validate branch name format according to git naming conventions.
 *
 * Git branch names must follow these rules:
 * - Cannot start with '.'
 * - Cannot contain '..' (consecutive dots)
 * - Cannot contain '//' (consecutive slashes)
 * - Cannot contain '@{' (ref syntax)
 * - Cannot contain control characters
 * - Cannot contain special characters: ~^:?*[\\
 * - Cannot end with '.lock'
 * - Cannot end with '/'
 * - Cannot be empty
 *
 * @param branchName - Branch name to validate
 * @throws {McpError} If branch name is invalid
 *
 * @example
 * ```typescript
 * validateBranchName('feature/my-feature'); // OK
 * validateBranchName('main'); // OK
 * validateBranchName('../etc/passwd'); // Throws error
 * ```
 */
export function validateBranchName(branchName: string): void {
  // Git branch naming rules
  const invalidPatterns = [
    /^\./, // Cannot start with .
    /\.\./, // Cannot contain ..
    /\/\//, // Cannot contain consecutive slashes
    /@\{/, // Cannot contain @{
    /[\x00-\x1F\x7F]/, // No control characters
    /[~^:?*\[\\]/, // No special characters
    /\.lock$/, // Cannot end with .lock
    /\/$/, // Cannot end with /
  ];
  if (branchName.length === 0) {
    throw new McpError(
      JsonRpcErrorCode.ValidationError,
      'Branch name cannot be empty',
    );
  }
  for (const pattern of invalidPatterns) {
    if (pattern.test(branchName)) {
      throw new McpError(
        JsonRpcErrorCode.ValidationError,
        `Invalid branch name: ${branchName}`,
        { pattern: pattern.source },
      );
    }
  }
}