pgit-cli
Version:
Private file tracking with dual git repositories
549 lines • 23.5 kB
JavaScript
import chalk from 'chalk';
import { ConfigManager } from '../core/config.manager.js';
import { FileSystemService } from '../core/filesystem.service.js';
import { PresetManager } from '../core/preset.manager.js';
import { AddCommand, BatchOperationError } from './add.command.js';
import { BaseError } from '../errors/base.error.js';
import { PresetNotFoundError, PresetValidationError } from '../core/preset.manager.js';
import { InputValidator } from '../utils/input.validator.js';
import { logger } from '../utils/logger.service.js';
/**
* Preset command specific errors
*/
export class PresetCommandError extends BaseError {
constructor() {
super(...arguments);
this.code = 'PRESET_COMMAND_ERROR';
this.recoverable = true;
}
}
export class NotInitializedError extends BaseError {
constructor() {
super(...arguments);
this.code = 'NOT_INITIALIZED';
this.recoverable = false;
}
}
/**
* Preset command for managing file and directory presets
*/
export class PresetCommand {
constructor(workingDir) {
this.workingDir = workingDir || process.cwd();
this.fileSystem = new FileSystemService();
this.configManager = new ConfigManager(this.workingDir, this.fileSystem);
this.presetManager = new PresetManager(this.configManager);
this.addCommand = new AddCommand(this.workingDir);
}
/**
* Apply a preset by adding all its paths to private tracking
*/
async apply(presetName, options = {}) {
try {
// Check if pgit is initialized
if (!(await this.configManager.exists())) {
throw new NotInitializedError('Private git tracking is not initialized. Run "pgit init" first.');
}
// Get the preset
const preset = await this.presetManager.getPreset(presetName);
if (!preset) {
return this.handlePresetNotFound(presetName);
}
// Mark preset as used
await this.presetManager.markPresetUsed(presetName);
logger.info(`Applying preset '${presetName}'...`);
if (options.verbose) {
const source = await this.presetManager.getPresetSource(presetName);
logger.info(`Source: ${source} preset`);
logger.info(`Description: ${preset.description}`);
}
// Apply all paths in the preset using bulk operation (single atomic commit)
const result = await this.applyPresetPathsBulk(preset.paths, options);
// Display results
this.displayApplyResults(presetName, result);
return {
success: true,
message: `Preset '${presetName}' applied successfully`,
data: result,
exitCode: 0,
};
}
catch (error) {
if (error instanceof BaseError) {
return {
success: false,
message: error.message,
error,
exitCode: 1,
};
}
return {
success: false,
message: `Failed to apply preset '${presetName}'`,
error: error instanceof Error ? error : new Error(String(error)),
exitCode: 1,
};
}
}
/**
* Add a new user preset
*/
async add(presetName, paths, options = {}) {
try {
// Global presets don't require pgit initialization - they are stored in ~/.pgit/presets.json
// Local presets require initialization since they are stored in the project
if (!options.global) {
// Check if pgit is initialized for local presets
if (!(await this.configManager.exists())) {
// For local presets, we need pgit to be initialized
// But we can provide better guidance by suggesting global presets
throw new NotInitializedError('Private git tracking is not initialized. Run "pgit init" first, or use --global flag to create a global preset that works across all projects.');
}
}
// Validate preset name
if (!presetName || presetName.trim().length === 0) {
throw new PresetValidationError('Preset name cannot be empty');
}
if (presetName.length > 50) {
throw new PresetValidationError('Preset name too long (max 50 characters)');
}
// Validate paths
if (!paths || paths.length === 0) {
throw new PresetValidationError('At least one path is required');
}
if (paths.length > 50) {
throw new PresetValidationError('Too many paths in preset (max 50)');
}
// Validate each path
const validatedPaths = [];
for (const path of paths) {
const trimmedPath = path.trim();
if (trimmedPath.length === 0) {
throw new PresetValidationError('Path cannot be empty');
}
// Basic path validation
try {
InputValidator.validatePath(trimmedPath);
}
catch {
throw new PresetValidationError(`Invalid path: ${trimmedPath}`);
}
validatedPaths.push(trimmedPath);
}
// Check if preset already exists and warn user
const existingSource = await this.presetManager.getPresetSource(presetName);
if (existingSource === 'builtin') {
logger.warn(`Warning: Preset '${presetName}' exists as a built-in preset. Your custom preset will override it.`);
}
else if (existingSource === 'localUser' || existingSource === 'globalUser') {
const sourceType = existingSource === 'localUser' ? 'local' : 'global';
logger.warn(`Warning: ${sourceType} user preset '${presetName}' already exists. It will be updated.`);
}
// Create preset object
const preset = {
description: `Custom preset with ${validatedPaths.length} path${validatedPaths.length === 1 ? '' : 's'}`,
paths: validatedPaths,
created: new Date(),
};
// Save the preset (global or local)
await this.presetManager.saveUserPreset(presetName, preset, options.global);
const presetType = options.global ? 'global' : 'local';
logger.success(`✔ ${presetType} preset '${presetName}' saved.`);
logger.info(`Use 'pgit preset apply ${presetName}' to apply this preset.`);
if (options.verbose) {
logger.info(`Preset type: ${presetType}`);
logger.info('Paths in preset:');
validatedPaths.forEach(path => logger.info(` • ${path}`));
}
return {
success: true,
message: `Preset '${presetName}' added successfully`,
data: preset,
exitCode: 0,
};
}
catch (error) {
if (error instanceof BaseError) {
return {
success: false,
message: error.message,
error,
exitCode: 1,
};
}
return {
success: false,
message: `Failed to add preset '${presetName}'`,
error: error instanceof Error ? error : new Error(String(error)),
exitCode: 1,
};
}
}
/**
* Remove a user-defined preset
*/
async remove(presetName, options = {}) {
try {
// Check what type of preset exists
const source = await this.presetManager.getPresetSource(presetName);
if (source === 'builtin') {
return {
success: false,
message: `Cannot remove built-in preset '${presetName}'. Built-in presets are read-only.`,
exitCode: 1,
};
}
if (source === 'none') {
return {
success: false,
message: `Preset '${presetName}' not found.`,
exitCode: 1,
};
}
let removed = false;
let removedType = '';
// If global flag is specified, only remove global preset
if (options.global) {
if (source === 'globalUser') {
removed = await this.presetManager.removeUserPreset(presetName, true);
removedType = 'global';
}
else {
return {
success: false,
message: `Global preset '${presetName}' not found.`,
exitCode: 1,
};
}
}
else {
// Remove from the appropriate location
if (source === 'localUser') {
removed = await this.presetManager.removeUserPreset(presetName, false);
removedType = 'local';
}
else if (source === 'globalUser') {
removed = await this.presetManager.removeUserPreset(presetName, true);
removedType = 'global';
}
}
if (removed) {
logger.success(`✔ ${removedType} preset '${presetName}' removed successfully.`);
return {
success: true,
message: `Preset '${presetName}' removed successfully`,
exitCode: 0,
};
}
else {
return {
success: false,
message: `Failed to remove preset '${presetName}'.`,
exitCode: 1,
};
}
}
catch (error) {
if (error instanceof BaseError) {
return {
success: false,
message: error.message,
error,
exitCode: 1,
};
}
return {
success: false,
message: `Failed to remove preset '${presetName}'`,
error: error instanceof Error ? error : new Error(String(error)),
exitCode: 1,
};
}
}
/**
* List all available presets
*/
async list(options = {}) {
try {
const allPresets = await this.presetManager.getAllPresets();
logger.info('Available Presets:\n');
// Display built-in presets
const builtinNames = Object.keys(allPresets.builtin);
if (builtinNames.length > 0) {
logger.info(chalk.bold('Built-in:'));
for (const name of builtinNames.sort()) {
const preset = allPresets.builtin[name];
const category = preset.category ? `[${preset.category}]` : '[general]';
if (options.verbose) {
logger.info(` ${chalk.cyan(name.padEnd(15))} ${chalk.gray(category.padEnd(15))} ${preset.description}`);
logger.info(` Paths: ${preset.paths.length} item${preset.paths.length === 1 ? '' : 's'}`);
}
else {
logger.info(` ${chalk.cyan(name.padEnd(15))} ${chalk.gray(category.padEnd(15))} ${preset.description}`);
}
}
logger.info('');
}
// Display user-defined presets
const localUserNames = Object.keys(allPresets.localUser);
const globalUserNames = Object.keys(allPresets.globalUser);
if (localUserNames.length > 0) {
logger.info(chalk.bold('Local User-defined:'));
for (const name of localUserNames.sort()) {
const preset = allPresets.localUser[name];
const pathCount = `(${preset.paths.length} path${preset.paths.length === 1 ? '' : 's'})`;
if (options.verbose) {
logger.info(` ${chalk.green(name.padEnd(15))} ${chalk.gray('[local]'.padEnd(15))} ${preset.description || pathCount}`);
if (preset.created) {
logger.info(` Created: ${preset.created.toLocaleDateString()}`);
}
if (preset.lastUsed) {
logger.info(` Last used: ${preset.lastUsed.toLocaleDateString()}`);
}
}
else {
logger.info(` ${chalk.green(name.padEnd(15))} ${chalk.gray('[local]'.padEnd(15))} ${preset.description || pathCount}`);
}
}
logger.info('');
}
if (globalUserNames.length > 0) {
logger.info(chalk.bold('Global User-defined:'));
for (const name of globalUserNames.sort()) {
const preset = allPresets.globalUser[name];
const pathCount = `(${preset.paths.length} path${preset.paths.length === 1 ? '' : 's'})`;
if (options.verbose) {
logger.info(` ${chalk.yellow(name.padEnd(15))} ${chalk.gray('[global]'.padEnd(15))} ${preset.description || pathCount}`);
if (preset.created) {
logger.info(` Created: ${preset.created.toLocaleDateString()}`);
}
if (preset.lastUsed) {
logger.info(` Last used: ${preset.lastUsed.toLocaleDateString()}`);
}
}
else {
logger.info(` ${chalk.yellow(name.padEnd(15))} ${chalk.gray('[global]'.padEnd(15))} ${preset.description || pathCount}`);
}
}
logger.info('');
}
if (builtinNames.length === 0 &&
localUserNames.length === 0 &&
globalUserNames.length === 0) {
logger.info('No presets available.');
logger.info('Use "pgit preset add <name> <path1> [path2]..." to create a local preset.');
logger.info('Use "pgit preset add --global <name> <path1> [path2]..." to create a global preset.');
}
else {
const totalUser = localUserNames.length + globalUserNames.length;
logger.info(`Total: ${builtinNames.length} built-in, ${totalUser} user-defined (${localUserNames.length} local, ${globalUserNames.length} global)`);
logger.info('Use "pgit preset show <name>" to see details about a specific preset.');
}
return {
success: true,
message: 'Presets listed successfully',
data: allPresets,
exitCode: 0,
};
}
catch (error) {
// Always show the detailed error message for debugging
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `DEBUG_ERROR: ${errorMessage}`,
error: error instanceof Error ? error : new Error(String(error)),
exitCode: 1,
};
}
}
/**
* Show details about a specific preset
*/
async show(presetName, _options = {}) {
try {
const preset = await this.presetManager.getPreset(presetName);
if (!preset) {
return this.handlePresetNotFound(presetName);
}
const source = await this.presetManager.getPresetSource(presetName);
const sourceLabel = source === 'builtin' ? 'Built-in' : 'User-defined';
logger.info(`Preset: ${chalk.bold(presetName)} ${chalk.gray(`[${sourceLabel}]`)}`);
if (preset.category) {
logger.info(`Category: ${preset.category}`);
}
logger.info(`Description: ${preset.description}`);
if (preset.created) {
logger.info(`Created: ${preset.created.toLocaleDateString()}`);
}
if (preset.lastUsed) {
logger.info(`Last used: ${preset.lastUsed.toLocaleDateString()}`);
}
logger.info(`\nPaths (${preset.paths.length}):`);
preset.paths.forEach(path => {
logger.info(` • ${path}`);
});
return {
success: true,
message: `Preset '${presetName}' details shown`,
data: { preset, source },
exitCode: 0,
};
}
catch (error) {
return {
success: false,
message: `Failed to show preset '${presetName}'`,
error: error instanceof Error ? error : new Error(String(error)),
exitCode: 1,
};
}
}
/**
* Apply preset paths using bulk add command for atomic commit
*/
async applyPresetPathsBulk(paths, options) {
const result = {
added: [],
skipped: [],
failed: [],
};
if (paths.length === 0) {
return result;
}
try {
// Use AddCommand's bulk processing which handles atomic commits
const addResult = await this.addCommand.execute(paths, options);
if (addResult.success) {
// All paths were added successfully
result.added = [...paths];
}
else {
// Handle different types of errors
if (addResult.error instanceof Error) {
const errorMessage = addResult.error.message;
// Check for batch operation errors which contain detailed path information
if (addResult.error instanceof BatchOperationError) {
const batchError = addResult.error;
result.added = batchError.successfulPaths;
result.failed = batchError.failedPaths.map((path) => ({
path,
error: 'Batch operation failed',
}));
}
else if (errorMessage.includes('already tracked')) {
// All paths are already tracked
result.skipped = [...paths];
}
else if (errorMessage.includes('does not exist') ||
errorMessage.includes('not found')) {
// Some or all paths don't exist
result.failed = paths.map(path => ({
path,
error: 'Path does not exist',
}));
}
else {
// Other errors - fallback to individual processing for better error reporting
return this.fallbackToIndividualProcessing(paths, options);
}
}
else {
// Unknown error - fallback to individual processing
return this.fallbackToIndividualProcessing(paths, options);
}
}
}
catch {
// If bulk operation fails, fallback to individual processing for better error reporting
return this.fallbackToIndividualProcessing(paths, options);
}
return result;
}
/**
* Fallback to individual file processing when bulk operation fails
*/
async fallbackToIndividualProcessing(paths, options) {
const result = {
added: [],
skipped: [],
failed: [],
};
if (options.verbose) {
logger.info('Falling back to individual file processing...');
}
for (const path of paths) {
try {
const addResult = await this.addCommand.execute([path], options);
if (addResult.success) {
result.added.push(path);
}
else {
// Check if it was skipped (already tracked)
if (addResult.error?.message.includes('already tracked') ||
addResult.error?.message.includes('already being tracked')) {
result.skipped.push(path);
}
else {
result.failed.push({
path,
error: addResult.error?.message || 'Unknown error',
});
}
}
}
catch (error) {
result.failed.push({
path,
error: error instanceof Error ? error.message : String(error),
});
}
}
return result;
}
/**
* Display the results of applying a preset
*/
displayApplyResults(presetName, result) {
// Show successes
result.added.forEach(path => {
logger.success(`✔ Added '${path}' to private tracking.`);
});
// Show skipped items
result.skipped.forEach(path => {
logger.warn(`⚠ '${path}' is already tracked, skipping.`);
});
// Show failures
result.failed.forEach(({ path, error }) => {
if (error.includes('does not exist') || error.includes('not found')) {
logger.warn(`⚠ '${path}' does not exist, skipping.`);
}
else {
logger.error(`✗ Failed to add '${path}': ${error}`);
}
});
// Summary
const total = result.added.length + result.skipped.length + result.failed.length;
logger.info(`\nPreset '${presetName}' applied.`);
logger.info(`${result.added.length} added, ${result.skipped.length} skipped, ${result.failed.length} failed (${total} total).`);
}
/**
* Handle preset not found error with helpful suggestions
*/
async handlePresetNotFound(presetName) {
const allPresets = await this.presetManager.getAllPresets();
const availableNames = Object.keys(allPresets.merged);
let message = `Preset '${presetName}' not found.`;
if (availableNames.length > 0) {
message += `\n\nAvailable presets: ${availableNames.sort().join(', ')}`;
}
message += "\nUse 'pgit preset list' to see all available presets.";
return {
success: false,
message,
error: new PresetNotFoundError(message),
exitCode: 1,
};
}
}
//# sourceMappingURL=preset.command.js.map