pgit-cli
Version:
Private file tracking with dual git repositories
1,023 lines • 55 kB
JavaScript
import * as path from 'node:path';
import chalk from 'chalk';
import { DEFAULT_PATHS, DEFAULT_SETTINGS, DEFAULT_GIT_EXCLUDE_SETTINGS, CURRENT_CONFIG_VERSION, } from '../types/config.types.js';
import { ConfigManager } from '../core/config.manager.js';
import { FileSystemService } from '../core/filesystem.service.js';
import { GitService } from '../core/git.service.js';
import { SymlinkService } from '../core/symlink.service.js';
import { BaseError } from '../errors/base.error.js';
import { GitExcludeError } from '../errors/git.error.js';
import { InputValidator } from '../utils/input.validator.js';
import { PathNotFoundError, UnsafePathError, InvalidInputError } from '../errors/specific.errors.js';
/**
* Add command specific errors
*/
export class AddError extends BaseError {
constructor() {
super(...arguments);
this.code = 'ADD_ERROR';
this.recoverable = true;
}
}
export class AlreadyTrackedError extends BaseError {
constructor() {
super(...arguments);
this.code = 'ALREADY_TRACKED';
this.recoverable = false;
}
}
export class NotInitializedError extends BaseError {
constructor() {
super(...arguments);
this.code = 'NOT_INITIALIZED';
this.recoverable = false;
}
}
/**
* Batch operation specific errors
*/
export class BatchOperationError extends BaseError {
constructor(message, failedPaths = [], successfulPaths = []) {
super(message);
this.code = 'BATCH_OPERATION_ERROR';
this.recoverable = true;
this.failedPaths = failedPaths;
this.successfulPaths = successfulPaths;
}
}
export class PartialSuccessError extends BaseError {
constructor(message, processedPaths = [], remainingPaths = []) {
super(message);
this.code = 'PARTIAL_SUCCESS';
this.recoverable = false;
this.processedPaths = processedPaths;
this.remainingPaths = remainingPaths;
}
}
/**
* Add command for tracking files in private repository
*/
export class AddCommand {
constructor(workingDir) {
this.workingDir = workingDir || process.cwd();
this.fileSystem = new FileSystemService();
this.configManager = new ConfigManager(this.workingDir, this.fileSystem);
this.symlinkService = new SymlinkService(this.fileSystem);
}
/**
* Create GitService instance with current configuration
*/
async createGitService(workingDir) {
try {
const config = await this.configManager.load();
return new GitService(workingDir || this.workingDir, this.fileSystem, config.settings.gitExclude);
}
catch {
// If config loading fails, use default settings
return new GitService(workingDir || this.workingDir, this.fileSystem);
}
}
/**
* Execute the add command for single or multiple files
*/
async execute(filePaths, options = {}) {
try {
// Handle both single and multiple file inputs
const pathsArray = Array.isArray(filePaths) ? filePaths : [filePaths];
// Limit the number of files that can be processed in a single batch
const MAX_BATCH_SIZE = 100;
if (pathsArray.length > MAX_BATCH_SIZE) {
throw new AddError(`Cannot process more than ${MAX_BATCH_SIZE} files in a single operation. Please split your request into smaller batches.`);
}
if (options.verbose) {
if (pathsArray.length === 1) {
console.log(chalk.blue(`🔄 Adding ${pathsArray[0]} to private tracking...`));
}
else {
console.log(chalk.blue(`🔄 Adding ${pathsArray.length} files to private tracking...`));
}
}
// Validate environment
await this.validateEnvironment();
// Validate and process multiple paths
const validationResult = await this.validateAndNormalizeMultiplePaths(pathsArray);
// Check for validation errors
if (validationResult.invalidPaths.length > 0) {
const errorMessages = validationResult.invalidPaths
.map(item => `${item.path}: ${item.error}`)
.join('\n');
if (pathsArray.length === 1) {
throw new InvalidInputError(`Invalid path detected:\n${errorMessages}`);
}
else {
throw new BatchOperationError(`Invalid paths detected in batch operation:\n${errorMessages}`, validationResult.invalidPaths.map(item => item.path), validationResult.validPaths);
}
}
// Check for already tracked paths
if (validationResult.alreadyTracked.length > 0) {
if (pathsArray.length === 1) {
throw new AlreadyTrackedError(`Path is already tracked: ${validationResult.alreadyTracked[0]}`);
}
else {
throw new BatchOperationError(`The following paths are already tracked: ${validationResult.alreadyTracked.join(', ')}`, validationResult.alreadyTracked, validationResult.validPaths);
}
}
// Execute the add operation atomically for all files
await this.executeMultipleAddOperation(validationResult.normalizedPaths, options);
const successMessage = pathsArray.length === 1
? `Successfully added ${validationResult.normalizedPaths[0]} to private tracking`
: `Successfully added ${validationResult.normalizedPaths.length} files to private tracking`;
return {
success: true,
message: successMessage,
exitCode: 0,
};
}
catch (error) {
// Special handling for GitExcludeError with 'error' fallback behavior
// These should cause the command to fail by throwing, not returning a failure result
if (error instanceof GitExcludeError &&
error.message.includes('Git exclude operations are disabled')) {
throw error; // Re-throw to fail the entire command with rejection
}
if (error instanceof BaseError) {
return {
success: false,
message: error.message,
error,
exitCode: 1,
};
}
return {
success: false,
message: 'Failed to add files to private tracking',
error: error instanceof Error ? error : new Error(String(error)),
exitCode: 1,
};
}
}
/**
* Validate that the environment is ready for add operation
*/
async validateEnvironment() {
// Check if private storage directory exists first (indicates pgit was initialized)
const storagePath = path.join(this.workingDir, DEFAULT_PATHS.storage);
const storageExists = await this.fileSystem.pathExists(storagePath);
// Check if config file exists
const configExists = await this.configManager.exists();
// If neither config nor storage exists, pgit is not initialized
if (!configExists && !storageExists) {
throw new NotInitializedError('Private git tracking is not initialized. Run "private init" first.');
}
// If storage exists but config is missing/corrupted, we can fall back to defaults
// This provides resilience against config file corruption
if (!configExists && storageExists) {
console.warn('Warning: Configuration file is missing or corrupted. Using default settings.');
}
// Check if symbolic links are supported
if (!(await SymlinkService.supportsSymlinks())) {
throw new AddError('This platform does not support symbolic links, which are required for private file tracking.');
}
// Ensure private storage directory exists (create if missing)
if (!storageExists) {
throw new AddError('Private storage directory does not exist. The initialization may have failed.');
}
}
/**
* Validate and normalize multiple file paths
*/
async validateAndNormalizeMultiplePaths(filePaths) {
const result = {
validPaths: [],
invalidPaths: [],
normalizedPaths: [],
alreadyTracked: [],
};
// Remove duplicates while preserving order
const uniquePaths = [...new Set(filePaths)];
// Load config once for efficiency, with fallback for corrupted config
let config;
try {
config = await this.configManager.load();
}
catch {
// If config loading fails, create a minimal fallback config
console.warn('Warning: Could not load configuration. Using default settings for validation.');
config = {
version: CURRENT_CONFIG_VERSION,
privateRepoPath: DEFAULT_PATHS.privateRepo,
storagePath: DEFAULT_PATHS.storage,
trackedPaths: [], // Empty tracked paths as fallback
initialized: new Date(),
settings: {
...DEFAULT_SETTINGS,
gitExclude: { ...DEFAULT_GIT_EXCLUDE_SETTINGS },
},
metadata: {
projectName: 'unknown',
mainRepoPath: this.workingDir,
cliVersion: CURRENT_CONFIG_VERSION,
platform: 'unknown',
lastModified: new Date(),
},
};
}
for (const filePath of uniquePaths) {
try {
// Validate individual path
const normalizedPath = await this.validateAndNormalizePath(filePath);
// Check if already tracked
if (config.trackedPaths.includes(normalizedPath)) {
result.alreadyTracked.push(normalizedPath);
}
else {
result.validPaths.push(filePath);
result.normalizedPaths.push(normalizedPath);
}
}
catch (error) {
result.invalidPaths.push({
path: filePath,
error: error instanceof Error ? error.message : String(error),
});
}
}
return result;
}
/**
* Validate and normalize a single file path
*/
async validateAndNormalizePath(filePath) {
// Input validation with security checks
const validation = InputValidator.validatePath(filePath, {
allowAbsolutePaths: false,
allowParentDirectory: false,
maxPathLength: 255,
});
if (!validation.isValid) {
if (validation.securityRisk) {
throw new UnsafePathError(filePath, validation.issues.join(', '));
}
else {
throw new InvalidInputError(`Invalid path: ${validation.issues.join(', ')}`);
}
}
// Create safe absolute path
const safePath = InputValidator.createSafePath(this.workingDir, validation.normalizedPath);
// Check if file/directory exists
if (!(await this.fileSystem.pathExists(safePath))) {
throw new PathNotFoundError(`Path does not exist: ${filePath}`);
}
// Convert back to relative path for storage
const relativePath = path.relative(this.workingDir, safePath);
return relativePath;
}
/**
* Get the current git state of a file (legacy version for backward compatibility)
* @deprecated Use getEnhancedFileGitState instead
*/
// @ts-ignore - Method kept for backward compatibility
async getFileGitState(relativePath) {
try {
const gitService = await this.createGitService();
const enhancedState = await gitService.getFileGitState(relativePath);
// Return legacy format for backward compatibility
return {
isTracked: enhancedState.isTracked,
isStaged: enhancedState.isStaged,
};
}
catch {
return { isTracked: false, isStaged: false };
}
}
/**
* Get enhanced git state of a file including exclude status
* TODO: This method will be used in future tasks for enhanced git removal functionality
*/
// @ts-ignore - Method will be used in future tasks
async getEnhancedFileGitState(relativePath) {
try {
const gitService = await this.createGitService();
return await gitService.getFileGitState(relativePath);
}
catch {
// Return default state on error
return {
isTracked: false,
isStaged: false,
isModified: false,
isUntracked: false,
isExcluded: false,
originalPath: relativePath,
timestamp: new Date(),
};
}
}
/**
* Execute atomic add operation for multiple files with enhanced git removal
*/
async executeMultipleAddOperation(relativePaths, options) {
if (relativePaths.length === 0) {
throw new AddError('No valid paths to process');
}
if (relativePaths.length === 1) {
// Use the existing single file operation for single files
return this.executeAddOperation(relativePaths[0], options);
}
// For multiple files, implement atomic batch operation with enhanced git removal
// Performance optimization: chunk large batches to prevent memory issues and improve performance
const OPTIMAL_BATCH_SIZE = 50; // Optimal batch size for git operations
if (relativePaths.length > OPTIMAL_BATCH_SIZE) {
if (options.verbose) {
console.log(chalk.gray(` Large batch detected (${relativePaths.length} files), processing in chunks for optimal performance...`));
}
// Process in chunks for better performance
const chunks = this.chunkArray(relativePaths, OPTIMAL_BATCH_SIZE);
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
if (options.verbose) {
console.log(chalk.gray(` Processing chunk ${i + 1}/${chunks.length} (${chunk.length} files)...`));
}
await this.executeMultipleAddOperation(chunk, { ...options, verbose: false }); // Reduce verbosity for chunks
}
if (options.verbose) {
console.log(chalk.green(` ✓ Successfully processed all ${relativePaths.length} files in ${chunks.length} chunks`));
}
return;
}
const rollbackActions = [];
const processedPaths = [];
const originalGitStates = new Map();
try {
if (options.verbose) {
console.log(chalk.gray(` Processing ${relativePaths.length} files atomically...`));
}
// Step 1: Record original git states and exclude file state for all files
if (options.verbose) {
console.log(chalk.gray(' Recording original git states...'));
}
const mainGitService = await this.createGitService();
let isGitRepo = false;
let originalExcludeFileContent = '';
if (await mainGitService.isRepository()) {
isGitRepo = true;
// Record original exclude file content for rollback
originalExcludeFileContent = await mainGitService.readGitExcludeFile();
}
const gitService = await this.createGitService();
for (const relativePath of relativePaths) {
const originalState = await gitService.recordOriginalState(relativePath);
originalGitStates.set(relativePath, originalState);
}
// Step 2: Enhanced batch git removal with exclude operations using optimized batch processing
if (options.verbose) {
console.log(chalk.gray(' Removing files from main git index and adding to exclude...'));
}
let batchGitResult = null;
if (isGitRepo) {
// Use optimized batch git removal
batchGitResult = await this.batchRemoveFromMainGitIndex(relativePaths, {
verbose: options.verbose,
});
// Check for any failures in git operations
if (batchGitResult.failed.length > 0) {
const failedPaths = batchGitResult.failed.map(f => f.path);
const errorMessages = batchGitResult.failed.map(f => `${f.path}: ${f.error}`).join('\n');
console.warn(chalk.yellow(` Warning: Some git operations failed for ${failedPaths.length} files:\n${errorMessages}`));
}
if (batchGitResult.successful.length > 0) {
processedPaths.push(...batchGitResult.successful);
if (options.verbose) {
console.log(chalk.gray(` Successfully processed ${batchGitResult.successful.length} files for git operations`));
}
}
// Add optimized rollback for git operations using batch restore
rollbackActions.push(async () => {
if (batchGitResult && batchGitResult.originalStates.size > 0) {
const rollbackResult = await this.batchRestoreToEnhancedGitState(batchGitResult.originalStates, originalExcludeFileContent, { verbose: options.verbose });
if (rollbackResult.failed.length > 0) {
console.warn(chalk.yellow(` Warning: Git rollback failed for ${rollbackResult.failed.length} files: ${rollbackResult.failed.map(f => f.path).join(', ')}`));
}
}
});
}
else {
// Not a git repository, skip git operations but still track processed paths
processedPaths.push(...relativePaths);
}
// Step 3: Move all files to private storage
if (options.verbose) {
console.log(chalk.gray(' Moving files to private storage...'));
}
const movedFiles = [];
for (const relativePath of relativePaths) {
const originalPath = path.join(this.workingDir, relativePath);
const storagePath = path.join(this.workingDir, DEFAULT_PATHS.storage, relativePath);
await this.fileSystem.moveFileAtomic(originalPath, storagePath);
this.fileSystem.clearRollbackActions();
movedFiles.push(relativePath);
}
// Add rollback for file moves
rollbackActions.push(async () => {
for (const relativePath of movedFiles.reverse()) {
const originalPath = path.join(this.workingDir, relativePath);
const storagePath = path.join(this.workingDir, DEFAULT_PATHS.storage, relativePath);
if (await this.fileSystem.pathExists(storagePath)) {
if (await this.fileSystem.pathExists(originalPath)) {
await this.fileSystem.remove(originalPath);
}
await this.fileSystem.moveFileAtomic(storagePath, originalPath);
this.fileSystem.clearRollbackActions();
}
}
});
// Step 4: Create all symbolic links
if (options.verbose) {
console.log(chalk.gray(' Creating symbolic links...'));
}
const createdLinks = [];
for (const relativePath of relativePaths) {
const originalPath = path.join(this.workingDir, relativePath);
const storagePath = path.join(this.workingDir, DEFAULT_PATHS.storage, relativePath);
const isDirectory = await this.fileSystem.isDirectory(storagePath);
await this.symlinkService.create(storagePath, originalPath, {
force: true,
createParents: true,
isDirectory,
});
createdLinks.push(relativePath);
}
// Add rollback for symbolic links
rollbackActions.push(async () => {
for (const relativePath of createdLinks.reverse()) {
const originalPath = path.join(this.workingDir, relativePath);
await this.symlinkService.remove(originalPath);
}
});
// Step 5: Add all files to private git repository and commit in one transaction
if (options.verbose) {
console.log(chalk.gray(' Adding files to private git repository...'));
}
const privateStoragePath = path.join(this.workingDir, DEFAULT_PATHS.storage);
const privateGitService = await this.createGitService(privateStoragePath);
if (!(await privateGitService.isRepository())) {
throw new AddError('Private git repository not found. The initialization may have failed.');
}
// Use the new atomic commit method
const commitHash = await privateGitService.addFilesAndCommit(relativePaths, 'Add files to private tracking');
// Add rollback for git operations
rollbackActions.push(async () => {
try {
// Reset the private repository to before the commit
await privateGitService.reset('hard', 'HEAD~1');
}
catch {
// If reset fails, try to remove files individually
await privateGitService.removeFromIndex(relativePaths, false);
}
});
// Step 6: Update configuration with all paths
if (options.verbose) {
console.log(chalk.gray(' Updating configuration...'));
}
await this.configManager.addMultipleTrackedPaths(relativePaths);
// Add rollback for configuration
rollbackActions.push(async () => {
try {
await this.configManager.removeMultipleTrackedPaths(relativePaths);
}
catch {
// Ignore errors during rollback
}
});
if (options.verbose) {
console.log(chalk.green(` ✓ Successfully added ${relativePaths.length} files to private tracking`));
console.log(chalk.gray(` Commit hash: ${commitHash}`));
}
}
catch (error) {
// Execute rollback in reverse order
if (options.verbose) {
console.log(chalk.yellow(' Rolling back changes due to error...'));
}
for (const rollbackAction of rollbackActions.reverse()) {
try {
await rollbackAction();
}
catch (rollbackError) {
// Log rollback errors but don't throw to avoid masking original error
console.error(chalk.red(` Rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`));
}
}
throw error;
}
}
/**
* Execute the complete add operation atomically for a single file
*/
async executeAddOperation(relativePath, options) {
const originalPath = path.join(this.workingDir, relativePath);
const storagePath = path.join(this.workingDir, DEFAULT_PATHS.storage, relativePath);
// Store rollback actions
const rollbackActions = [];
try {
if (options.verbose) {
console.log(chalk.gray(' Removing from main git index...'));
}
// Step 1: Use enhanced batch git removal logic for consistency (Requirements 7.1, 7.2)
// This ensures single file operations follow the same logic as batch operations
const batchGitResult = await this.batchRemoveFromMainGitIndex([relativePath], {
verbose: options.verbose,
});
// Record original exclude file content for rollback
const gitService = await this.createGitService();
let originalExcludeContent = '';
if (await gitService.isRepository()) {
originalExcludeContent = await gitService.readGitExcludeFile();
}
// Handle git operation results with consistent error handling
if (batchGitResult.failed.length > 0) {
const failedResult = batchGitResult.failed[0];
console.warn(chalk.yellow(` Warning: Git operation failed for ${failedResult.path}: ${failedResult.error}`));
}
// Add enhanced rollback for git operations using batch restore
rollbackActions.push(async () => {
if (batchGitResult.originalStates.size > 0) {
const rollbackResult = await this.batchRestoreToEnhancedGitState(batchGitResult.originalStates, originalExcludeContent, { verbose: options.verbose });
if (rollbackResult.failed.length > 0) {
console.warn(chalk.yellow(` Warning: Git rollback failed for ${rollbackResult.failed[0].path}: ${rollbackResult.failed[0].error}`));
}
}
});
if (options.verbose) {
console.log(chalk.gray(' Moving file to private storage...'));
}
// Step 2: Move file to private storage
await this.fileSystem.moveFileAtomic(originalPath, storagePath);
// Clear the FileSystemService rollback actions since we'll handle rollback ourselves
this.fileSystem.clearRollbackActions();
rollbackActions.push(async () => {
// Move back to original location
if (await this.fileSystem.pathExists(storagePath)) {
// Remove symlink first if it exists
if (await this.fileSystem.pathExists(originalPath)) {
await this.fileSystem.remove(originalPath);
}
await this.fileSystem.moveFileAtomic(storagePath, originalPath);
this.fileSystem.clearRollbackActions();
}
});
if (options.verbose) {
console.log(chalk.gray(' Creating symbolic link...'));
console.log(chalk.gray(` Target: ${storagePath}`));
console.log(chalk.gray(` Link: ${originalPath}`));
console.log(chalk.gray(` Target exists: ${await this.fileSystem.pathExists(storagePath)}`));
console.log(chalk.gray(` Link exists: ${await this.fileSystem.pathExists(originalPath)}`));
}
// Step 3: Create symbolic link
const isDirectory = await this.fileSystem.isDirectory(storagePath);
await this.symlinkService.create(storagePath, originalPath, {
force: true,
createParents: true,
isDirectory,
});
rollbackActions.push(async () => {
// Remove symbolic link
await this.symlinkService.remove(originalPath);
});
if (options.verbose) {
console.log(chalk.gray(' Adding to private git repository...'));
}
// Step 4: Add to private git repository
await this.addToPrivateGit(relativePath);
rollbackActions.push(async () => {
// Remove from private git
await this.removeFromPrivateGit(relativePath);
});
if (options.verbose) {
console.log(chalk.gray(' Updating configuration...'));
}
// Step 5: Update configuration (skip if config is corrupted)
try {
await this.configManager.addTrackedPath(relativePath);
rollbackActions.push(async () => {
// Remove from tracked paths
try {
await this.configManager.removeTrackedPath(relativePath);
}
catch {
// Ignore errors during rollback
}
});
}
catch {
// If config is corrupted, log warning but continue
console.warn(chalk.yellow(` Warning: Could not update configuration for ${relativePath}. File tracking will still work, but may not persist after restart.`));
// Add a no-op rollback action for consistency
rollbackActions.push(async () => {
// No-op since config update failed
});
}
if (options.verbose) {
console.log(chalk.gray(' Committing to private repository...'));
}
// Step 6: Commit to private repository
await this.commitToPrivateGit(relativePath, 'Add file to private tracking');
if (options.verbose) {
console.log(chalk.green(' ✓ File successfully added to private tracking'));
}
}
catch (error) {
// Execute rollback in reverse order
if (options.verbose) {
console.log(chalk.yellow(' Rolling back changes due to error...'));
}
for (const rollbackAction of rollbackActions.reverse()) {
try {
await rollbackAction();
}
catch (rollbackError) {
// Log rollback errors but don't throw to avoid masking original error
console.error(chalk.red(` Rollback failed: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`));
}
}
throw error;
}
}
/**
* Remove file from main git index and add to git exclude
* @deprecated Use batchRemoveFromMainGitIndex for consistency with enhanced logic
*/
// @ts-ignore - Method kept for backward compatibility
async removeFromMainGitIndex(relativePath) {
try {
const gitService = await this.createGitService();
if (await gitService.isRepository()) {
// Always attempt to remove from git index regardless of current state
// This handles tracked, staged, and untracked files consistently
try {
await gitService.removeFromIndex(relativePath, true);
}
catch (removeError) {
// Log warning but continue - file might not be in index
console.warn(chalk.yellow(` Warning: Could not remove ${relativePath} from git index: ${removeError instanceof Error ? removeError.message : String(removeError)}`));
}
// Add to .git/info/exclude to prevent future git add operations
// The new addToGitExclude method handles errors gracefully and logs warnings internally
await gitService.addToGitExclude(relativePath);
}
}
catch (error) {
// If repository check fails, log warning but continue
console.warn(chalk.yellow(` Warning: Could not process git operations for ${relativePath}: ${error instanceof Error ? error.message : String(error)}`));
}
}
/**
* Remove multiple files from main git index and add to git exclude in batch
* Optimized for performance with large file batches
*/
async batchRemoveFromMainGitIndex(relativePaths, options = {}) {
const result = {
successful: [],
failed: [],
originalStates: new Map(),
};
if (relativePaths.length === 0) {
return result;
}
try {
const gitService = await this.createGitService();
if (!(await gitService.isRepository())) {
// Not a git repository, skip git operations
return result;
}
// Step 1: Record original states for all files
if (options.verbose) {
console.log(chalk.gray(` Recording git states for ${relativePaths.length} files...`));
}
for (const relativePath of relativePaths) {
try {
const originalState = await gitService.recordOriginalState(relativePath);
result.originalStates.set(relativePath, originalState);
}
catch (error) {
result.failed.push({
path: relativePath,
error: `Failed to record git state: ${error instanceof Error ? error.message : String(error)}`,
});
}
}
// Step 2: Batch remove from git index (only files that are tracked/staged)
const filesToRemove = relativePaths.filter(path => {
const state = result.originalStates.get(path);
return state && (state.isTracked || state.isStaged);
});
if (filesToRemove.length > 0) {
if (options.verbose) {
console.log(chalk.gray(` Removing ${filesToRemove.length} files from git index...`));
}
try {
await gitService.removeFromIndex(filesToRemove, true);
result.successful.push(...filesToRemove);
}
catch {
// If batch removal fails, try individual removals
if (options.verbose) {
console.log(chalk.gray(' Batch removal failed, trying individual removals...'));
}
for (const relativePath of filesToRemove) {
try {
await gitService.removeFromIndex(relativePath, true);
result.successful.push(relativePath);
}
catch (individualError) {
result.failed.push({
path: relativePath,
error: `Failed to remove from git index: ${individualError instanceof Error ? individualError.message : String(individualError)}`,
});
}
}
}
}
// Step 3: Batch add to .git/info/exclude with enhanced error handling
if (options.verbose) {
console.log(chalk.gray(` Adding ${relativePaths.length} files to .git/info/exclude...`));
}
const excludeResult = await gitService.addMultipleToGitExclude(relativePaths);
// Mark successful exclude operations
for (const successfulPath of excludeResult.successful) {
if (!result.successful.includes(successfulPath)) {
result.successful.push(successfulPath);
}
}
// Handle exclude operation failures gracefully
if (excludeResult.failed.length > 0) {
if (options.verbose) {
console.log(chalk.gray(` ${excludeResult.failed.length} exclude operations failed, handling gracefully...`));
}
for (const failedExclude of excludeResult.failed) {
// Always treat exclude operation failures as warnings, not hard failures
// The core functionality (moving to private storage and creating symlinks) can proceed
// even if we can't add files to .git/info/exclude
console.warn(chalk.yellow(` Warning: Could not add ${failedExclude.path} to .git/info/exclude: ${failedExclude.error}`));
// Always ensure files are marked as successful so the add operation can proceed
// Exclude operations are an optimization, not a requirement for core functionality
if (!result.successful.includes(failedExclude.path)) {
result.successful.push(failedExclude.path);
}
}
}
}
catch (error) {
// Check if this is a GitExcludeError with 'error' fallback behavior
// These should propagate up to fail the entire command, not be treated as graceful failures
if (error instanceof GitExcludeError &&
error.message.includes('Git exclude operations are disabled')) {
throw error; // Re-throw to fail the entire command
}
// Repository-level error, mark all files as failed
const errorMessage = `Git repository error: ${error instanceof Error ? error.message : String(error)}`;
for (const relativePath of relativePaths) {
result.failed.push({
path: relativePath,
error: errorMessage,
});
}
}
return result;
}
/**
* Add file to private git repository
*/
async addToPrivateGit(relativePath) {
const privateStoragePath = path.join(this.workingDir, DEFAULT_PATHS.storage);
const gitService = await this.createGitService(privateStoragePath);
if (!(await gitService.isRepository())) {
throw new AddError('Private git repository not found. The initialization may have failed.');
}
await gitService.addFiles([relativePath]);
}
/**
* Commit changes to private git repository
*/
async commitToPrivateGit(relativePath, message) {
const privateStoragePath = path.join(this.workingDir, DEFAULT_PATHS.storage);
const gitService = await this.createGitService(privateStoragePath);
await gitService.commit(`${message}: ${relativePath}`);
}
/**
* Remove file from private git repository (for rollback)
*/
async removeFromPrivateGit(relativePath) {
try {
const privateStoragePath = path.join(this.workingDir, DEFAULT_PATHS.storage);
const gitService = await this.createGitService(privateStoragePath);
await gitService.removeFromIndex(relativePath, false);
}
catch {
// Ignore errors during rollback
}
}
/**
* Restore file to its original git state (for rollback) - Legacy version for backward compatibility
* @deprecated Use restoreToEnhancedGitState instead
*/
// @ts-ignore - Method kept for backward compatibility
async restoreToOriginalGitState(relativePath, originalState) {
try {
const gitService = await this.createGitService();
if (!(await gitService.isRepository())) {
return; // Nothing to restore in non-git directories
}
if (originalState.isTracked && originalState.isStaged) {
// File was previously staged, add it back to staging
await gitService.addFiles([relativePath]);
}
else if (originalState.isTracked && !originalState.isStaged) {
// File was tracked but not staged, add then unstage to get it back in index but not staged
await gitService.addFiles([relativePath]);
await gitService.removeFromIndex(relativePath, true); // Remove from staging but keep in index
}
// If originalState.isTracked is false, file was untracked - do nothing (leave it untracked)
}
catch (error) {
// Log warning but don't fail rollback
console.warn(chalk.yellow(` Warning: Could not restore original git state: ${error instanceof Error ? error.message : String(error)}`));
}
}
/**
* Enhanced rollback functionality: Restore file to its original enhanced git state including exclude status
* @deprecated Use batchRestoreToEnhancedGitState for consistency with enhanced logic
*/
// @ts-ignore - Method kept for backward compatibility
async restoreToEnhancedGitState(relativePath, originalState, originalExcludeContent) {
const rollbackErrors = [];
try {
const gitService = await this.createGitService();
if (!(await gitService.isRepository())) {
return; // Nothing to restore in non-git directories
}
// Step 1: Restore git index state
try {
if (originalState.isTracked && originalState.isStaged) {
// File was previously staged, add it back to staging
await gitService.addFiles([relativePath]);
}
else if (originalState.isTracked && !originalState.isStaged) {
// File was tracked but not staged, add then unstage to get it back in index but not staged
await gitService.addFiles([relativePath]);
await gitService.removeFromIndex(relativePath, true); // Remove from staging but keep in index
}
// If originalState.isTracked is false, file was untracked - do nothing (leave it untracked)
}
catch (gitError) {
rollbackErrors.push(`Git index restoration failed: ${gitError instanceof Error ? gitError.message : String(gitError)}`);
}
// Step 2: Restore exclude file state
// The new exclude methods handle errors gracefully and log warnings internally
if (originalState.isExcluded) {
// File was originally excluded, ensure it's back in exclude file
await gitService.addToGitExclude(relativePath);
}
else {
// File was not originally excluded, remove it from exclude file
await gitService.removeFromGitExclude(relativePath);
}
// Step 3: If we have original exclude content and there were exclude errors, try full restore
if (rollbackErrors.some(error => error.includes('Exclude file')) &&
originalExcludeContent !== undefined) {
try {
if (originalExcludeContent.trim()) {
await gitService.writeGitExcludeFile(originalExcludeContent);
}
else {
// Original exclude file was empty, remove current exclude file
const gitExcludePath = path.join(this.workingDir, '.git', 'info', 'exclude');
if (await this.fileSystem.pathExists(gitExcludePath)) {
await this.fileSystem.remove(gitExcludePath);
}
}
// Clear exclude-related errors since we did a full restore
const nonExcludeErrors = rollbackErrors.filter(error => !error.includes('Exclude file'));
rollbackErrors.length = 0;
rollbackErrors.push(...nonExcludeErrors);
}
catch (fullRestoreError) {
rollbackErrors.push(`Full exclude file restoration failed: ${fullRestoreError instanceof Error ? fullRestoreError.message : String(fullRestoreError)}`);
}
}
// Log warnings for any rollback errors without throwing
if (rollbackErrors.length > 0) {
console.warn(chalk.yellow(` Warning: Rollback issues for ${relativePath}: ${rollbackErrors.join('; ')}`));
}
}
catch (error) {
// Log warning but don't fail rollback to avoid masking original error
console.warn(chalk.yellow(` Warning: Could not restore enhanced git state for ${relativePath}: ${error instanceof Error ? error.message : String(error)}`));
}
}
/**
* Utility method to chunk an array into smaller arrays of specified size
* Used for performance optimization with large file batches
*/
chunkArray(array, chunkSize) {
const chunks = [];
for (let i = 0; i < array.length; i += chunkSize) {
chunks.push(array.slice(i, i + chunkSize));
}
return chunks;
}
/**
* Enhanced batch restore multiple files to their git states including exclude file restoration
* Optimized for performance with large file batches and proper error handling
*/
async batchRestoreToEnhancedGitState(originalStates, originalExcludeContent, options = {}) {
const result = {
successful: [],
failed: [],
};
if (originalStates.size === 0) {
return result;
}
const rollbackErrors = [];
try {
const gitService = await this.createGitService();
if (!(await gitService.isRepository())) {
// Not a git repository, mark all as successful (nothing to restore)
result.successful.push(...Array.from(originalStates.keys()));
return result;
}
if (options.verbose) {
console.log(chalk.gray(` Restoring git states for ${originalStates.size} files...`));
}
// Step 1: Restore original exclude file content completely
try {
if (originalExcludeContent.trim()) {
await gitService.writeGitExcludeFile(originalExcludeContent);
}
else {
// Original exclude file was empty or didn't exist, remove current exclude file
const gitExcludePath = path.join(this.workingDir, '.git', 'info', 'exclude');
if (await this.fileSystem.pathExists(gitExcludePath)) {
await this.fileSystem.remove(gitExcludePath);
}
}
if (options.verbose) {
console.log(chalk.gray(' Restored original .git/info/exclude content'));
}
}
catch (excludeError) {
rollbackErrors.push(`Exclude file restoration failed: ${excludeError instanceof Error ? excludeError.message : String(excludeError)}`);
// If full restore failed, try individual exclude operations as fallback
if (options.verbose) {
console.log(chalk.gray(' Full exclude restore failed, trying individual exclude operations...'));
}
const originallyExcluded = [];
const originallyNotExcluded = [];
for (const [relativePath, originalState] of originalStates) {
if (originalState.isExcluded) {
originallyExcluded.push(relativePath);
}
else {
originallyNotExcluded.push(relativePath);
}
}
// Try to restore exclude states individually
if (originallyExcluded.length > 0) {
const addResult = await gitService.addMultipleToGitExclude(originallyExcluded);
if (addResult.failed.length > 0) {
rollbackErrors.push(`Failed to restore ${addResult.failed.length} excluded paths: ${addResult.failed.map(f => f.path).join(', ')}`);
}
}
if (originallyNotExcluded.length > 0) {
const removeResult = await gitService.removeMultipleFromGitExclude(originallyNotExcluded);
if (removeResult.failed.length > 0) {
rollbackErrors.push(`Failed to remove ${removeResult.failed.length} paths from exclude: ${removeResult.failed.map(f => f.path).join(', ')}`);
}
}
if (options.verbose) {
console.log(chalk.gray(' Individual exclude operations completed'));
}
}
// Step 2: Group files by their required git operations for batch processing
const filesToStage = [];
const filesToTrack = [];
const filesToLeaveUntracked = [];
for (const [relativePath, originalState] of originalStates) {
if (originalState.isTracked && originalState.isStaged) {
filesToStage.push(relativePath);
}
else if (originalState.isTracked && !originalState.isStaged) {
filesToTrack.push(relativePath);
}
else {
// Untracked files don't need git index restoration
filesToLeaveUntracked.push(relativePath);
}
}
// Step 3: Batch restore staged files
if (filesToStage.length > 0) {
try {
await gitService.addFiles(filesToStage);
result.successful.push(...filesT