From b2d51efa112ca9654f907fa09ba5d980f1060f44 Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Sun, 23 Nov 2025 14:31:13 -0700 Subject: [PATCH 01/10] feat(commit): add interactive preview and edit loop - Replace simple preview confirmation with interactive edit options - Add ability to edit type, scope, subject, and body individually - Pre-fill previous values when editing fields - Re-validate scope when commit type changes - Add cancel option to exit commit flow - Require explicit config file instead of using fallback defaults --- src/cli/commands/commit/index.ts | 91 +++++++++++++++++++++--------- src/cli/commands/commit/prompts.ts | 73 ++++++++++++++++++------ 2 files changed, 122 insertions(+), 42 deletions(-) diff --git a/src/cli/commands/commit/index.ts b/src/cli/commands/commit/index.ts index c1256c9..cc3ea53 100644 --- a/src/cli/commands/commit/index.ts +++ b/src/cli/commands/commit/index.ts @@ -81,6 +81,13 @@ export async function commitAction(options: { // Step 1: Load configuration const configResult = await loadConfig(); + // Require an actual config file - reject fallback defaults + if (configResult.source === "defaults") { + Logger.error("Configuration not found"); + console.error("\n Run 'lab init' to create configuration file.\n"); + process.exit(1); + } + if (!configResult.config) { Logger.error("Configuration not found"); console.error("\n Run 'lab init' to create configuration file.\n"); @@ -202,13 +209,65 @@ export async function commitAction(options: { const gitStatus = getGitStatus(alreadyStagedFiles); await displayStagedFiles(gitStatus); - // Step 5: Collect commit data via prompts - const { type, emoji } = await promptType(config, options.type); - const scope = await promptScope(config, type, options.scope); - const subject = await promptSubject(config, options.message); - const body = await promptBody(config); + // Step 5: Collect initial commit data via prompts + let { type, emoji } = await promptType(config, options.type); + let scope = await promptScope(config, type, options.scope); + let subject = await promptSubject(config, options.message); + let body = await promptBody(config); + + // Step 6: Preview/edit loop + let action: "commit" | "edit-type" | "edit-scope" | "edit-subject" | "edit-body" | "cancel"; + + do { + // Format message with current values + const formattedMessage = formatCommitMessage( + config, + type, + emoji, + scope, + subject, + ); + + // Show preview and get user action + action = await displayPreview(formattedMessage, body); + + // Handle edit actions + if (action === "edit-type") { + const typeResult = await promptType(config, undefined, type); + type = typeResult.type; + emoji = typeResult.emoji; + // Re-validate scope if type changed (scope requirements might have changed) + const isScopeRequired = config.validation.require_scope_for.includes(type); + if (isScopeRequired && !scope) { + // Scope is now required, prompt for it + scope = await promptScope(config, type, undefined, scope); + } + } else if (action === "edit-scope") { + scope = await promptScope(config, type, undefined, scope); + } else if (action === "edit-subject") { + subject = await promptSubject(config, undefined, subject); + } else if (action === "edit-body") { + body = await promptBody(config, body); + } else if (action === "cancel") { + await cleanup({ + config, + autoStageEnabled, + alreadyStagedFiles, + newlyStagedFiles, + type, + typeEmoji: emoji, + scope, + subject, + body, + formattedMessage: formatCommitMessage(config, type, emoji, scope, subject), + }); + console.log("\nCommit cancelled."); + process.exit(0); + } + // If action is "commit", exit loop and proceed + } while (action !== "commit"); - // Step 6: Format and preview message + // Final formatted message for commit const formattedMessage = formatCommitMessage( config, type, @@ -217,26 +276,6 @@ export async function commitAction(options: { subject, ); - const confirmed = await displayPreview(formattedMessage, body); - - if (!confirmed) { - // User selected "No, let me edit" - await cleanup({ - config, - autoStageEnabled, - alreadyStagedFiles, - newlyStagedFiles, - type, - typeEmoji: emoji, - scope, - subject, - body, - formattedMessage, - }); - console.log("\nCommit cancelled."); - process.exit(0); - } - // Step 7: Execute commit console.log(); console.log("◐ Creating commit..."); diff --git a/src/cli/commands/commit/prompts.ts b/src/cli/commands/commit/prompts.ts index 8fe4f0e..10828d4 100644 --- a/src/cli/commands/commit/prompts.ts +++ b/src/cli/commands/commit/prompts.ts @@ -62,6 +62,7 @@ function handleCancel(value: unknown): void { export async function promptType( config: LabcommitrConfig, providedType?: string, + initialType?: string, ): Promise<{ type: string; emoji?: string }> { // If type provided via CLI flag, validate it if (providedType) { @@ -87,6 +88,11 @@ export async function promptType( }; } + // Find initial type index if provided + const initialIndex = initialType + ? config.types.findIndex((t) => t.id === initialType) + : undefined; + const selected = await select({ message: `${label("type", "magenta")} ${textColors.pureWhite("Select commit type:")}`, options: config.types.map((type) => ({ @@ -94,6 +100,7 @@ export async function promptType( label: `${type.id.padEnd(8)} ${type.description}`, hint: type.description, })), + initialValue: initialIndex !== undefined && initialIndex >= 0 ? config.types[initialIndex].id : undefined, }); handleCancel(selected); @@ -113,6 +120,7 @@ export async function promptScope( config: LabcommitrConfig, selectedType: string, providedScope?: string, + initialScope?: string | undefined, ): Promise { const isRequired = config.validation.require_scope_for.includes(selectedType); const allowedScopes = config.validation.allowed_scopes; @@ -146,11 +154,17 @@ export async function promptScope( }, ]; + // Find initial scope index if provided + const initialIndex = initialScope + ? allowedScopes.findIndex((s) => s === initialScope) + : undefined; + const selected = await select({ message: `${label("scope", "blue")} ${textColors.pureWhite( `Enter scope ${isRequired ? "(required for '" + selectedType + "')" : "(optional)"}:`, )}`, options, + initialValue: initialIndex !== undefined && initialIndex >= 0 ? allowedScopes[initialIndex] : initialScope || undefined, }); handleCancel(selected); @@ -158,7 +172,8 @@ export async function promptScope( if (selected === "__custom__") { const custom = await text({ message: `${label("scope", "blue")} ${textColors.pureWhite("Enter custom scope:")}`, - placeholder: "", + placeholder: initialScope || "", + initialValue: initialScope, validate: (value) => { if (isRequired && !value) { return "Scope is required for this commit type"; @@ -180,6 +195,7 @@ export async function promptScope( `Enter scope ${isRequired ? "(required)" : "(optional)"}:`, )}`, placeholder: "", + initialValue: initialScope, validate: (value) => { if (isRequired && !value) { return "Scope is required for this commit type"; @@ -242,6 +258,7 @@ function validateSubject( export async function promptSubject( config: LabcommitrConfig, providedMessage?: string, + initialSubject?: string, ): Promise { if (providedMessage) { const errors = validateSubject(config, providedMessage); @@ -259,7 +276,7 @@ export async function promptSubject( return providedMessage; } - let subject: string | symbol = ""; + let subject: string | symbol = initialSubject || ""; let errors: ValidationError[] = []; do { @@ -280,6 +297,7 @@ export async function promptSubject( `Enter commit subject (max ${config.format.subject_max_length} chars):`, )}`, placeholder: "", + initialValue: typeof subject === "string" ? subject : initialSubject, validate: (value) => { const validationErrors = validateSubject(config, value); if (validationErrors.length > 0) { @@ -368,6 +386,7 @@ function validateBody( */ export async function promptBody( config: LabcommitrConfig, + initialBody?: string | undefined, ): Promise { const bodyConfig = config.format.body; const editorAvailable = detectEditor() !== null; @@ -386,11 +405,11 @@ export async function promptBody( // Fall through to inline input } else if (preference === "editor" && editorAvailable && !isRequired) { // Optional body with editor preference - use editor directly - const edited = await promptBodyWithEditor(config, ""); + const edited = await promptBodyWithEditor(config, initialBody || ""); return edited || undefined; } else if (preference === "editor" && editorAvailable && isRequired) { // Required body with editor preference - use editor with validation loop - return await promptBodyRequiredWithEditor(config); + return await promptBodyRequiredWithEditor(config, initialBody); } // Inline input path @@ -420,7 +439,7 @@ export async function promptBody( if (inputMethod === "skip") { return undefined; } else if (inputMethod === "editor") { - return await promptBodyWithEditor(config, ""); + return await promptBodyWithEditor(config, initialBody || ""); } // Fall through to inline } @@ -428,6 +447,7 @@ export async function promptBody( const body = await text({ message: `${label("body", "yellow")} ${textColors.pureWhite("Enter commit body (optional):")}`, placeholder: "Press Enter to skip", + initialValue: initialBody, validate: (value) => { if (!value) return undefined; // Empty is OK if optional const errors = validateBody(config, value); @@ -443,7 +463,7 @@ export async function promptBody( } // Required body - let body: string | symbol = ""; + let body: string | symbol = initialBody || ""; let errors: ValidationError[] = []; do { @@ -480,7 +500,7 @@ export async function promptBody( handleCancel(inputMethod); if (inputMethod === "editor") { - const editorBody = await promptBodyWithEditor(config, body as string); + const editorBody = await promptBodyWithEditor(config, typeof body === "string" ? body : initialBody || ""); if (editorBody !== null && editorBody !== undefined) { body = editorBody; } else { @@ -494,6 +514,7 @@ export async function promptBody( `Enter commit body (required${bodyConfig.min_length > 0 ? `, min ${bodyConfig.min_length} chars` : ""}):`, )}`, placeholder: "", + initialValue: typeof body === "string" ? body : initialBody, validate: (value) => { const validationErrors = validateBody(config, value); if (validationErrors.length > 0) { @@ -512,6 +533,7 @@ export async function promptBody( `Enter commit body (required${bodyConfig.min_length > 0 ? `, min ${bodyConfig.min_length} chars` : ""}):`, )}`, placeholder: "", + initialValue: typeof body === "string" ? body : initialBody, validate: (value) => { const validationErrors = validateBody(config, value); if (validationErrors.length > 0) { @@ -537,9 +559,10 @@ export async function promptBody( */ async function promptBodyRequiredWithEditor( config: LabcommitrConfig, + initialBody?: string, ): Promise { const bodyConfig = config.format.body; - let body: string = ""; + let body: string = initialBody || ""; let errors: ValidationError[] = []; do { @@ -588,6 +611,7 @@ async function promptBodyRequiredWithEditor( `Enter commit body (required${bodyConfig.min_length > 0 ? `, min ${bodyConfig.min_length} chars` : ""}):`, )}`, placeholder: "", + initialValue: body, validate: (value) => { const validationErrors = validateBody(config, value); if (validationErrors.length > 0) { @@ -902,11 +926,12 @@ export async function displayStagedFiles(status: { * Display commit message preview with connector line support * Uses @clack/prompts log.info() to start connector, then manually * renders connector lines for multi-line preview content. + * Returns the action the user selected: "commit", "edit-type", "edit-scope", "edit-subject", "edit-body", or "cancel" */ export async function displayPreview( formattedMessage: string, body: string | undefined, -): Promise { +): Promise<"commit" | "edit-type" | "edit-scope" | "edit-subject" | "edit-body" | "cancel"> { // Start connector line using @clack/prompts log.info( `${label("preview", "green")} ${textColors.pureWhite("Commit message preview:")}`, @@ -931,20 +956,36 @@ export async function displayPreview( renderWithConnector("─────────────────────────────────────────────"), ); - const confirmed = await select({ + const action = await select({ message: `${success("✓")} ${textColors.pureWhite("Ready to commit?")}`, options: [ { - value: true, - label: "Yes, create commit", + value: "commit", + label: "Create commit", + }, + { + value: "edit-type", + label: "Edit type", + }, + { + value: "edit-scope", + label: "Edit scope", + }, + { + value: "edit-subject", + label: "Edit subject", + }, + { + value: "edit-body", + label: "Edit body", }, { - value: false, - label: "No, let me edit", + value: "cancel", + label: "Cancel", }, ], }); - handleCancel(confirmed); - return confirmed as boolean; + handleCancel(action); + return action as "commit" | "edit-type" | "edit-scope" | "edit-subject" | "edit-body" | "cancel"; } From 64f10a472ec41441ac89a5b5c045aeb0aaf927d9 Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Sun, 23 Nov 2025 14:31:16 -0700 Subject: [PATCH 02/10] fix(init): clarify Angular convention preset example - Update Angular preset example to use perf type - Add hint explaining additional types in Angular convention - Improve type safety for preset options --- src/cli/commands/init/prompts.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/init/prompts.ts b/src/cli/commands/init/prompts.ts index e0b0d72..7bffcca 100644 --- a/src/cli/commands/init/prompts.ts +++ b/src/cli/commands/init/prompts.ts @@ -66,18 +66,25 @@ function handleCancel(value: unknown): void { * Preset option data structure * Keeps descriptions for future use while labels only show examples */ -const PRESET_OPTIONS = [ +const PRESET_OPTIONS: Array<{ + value: string; + name: string; + description: string; + example: string; + hint?: string; +}> = [ { value: "conventional", name: "Conventional Commits (Recommended)", description: "Popular across open-source and personal projects.", - example: "fix(dining): add security to treat container", + example: "fix(api): add security to treat container", }, { value: "angular", name: "Angular Convention", description: "Strict format used by Angular and enterprise teams.", - example: "fix(snacks): add security to treat container", + example: "perf(compiler): optimize template parsing", + hint: "Includes perf, build, ci types", }, { value: "minimal", @@ -85,7 +92,7 @@ const PRESET_OPTIONS = [ description: "Start with basics, customize everything yourself later.", example: "fix: add security to treat container", }, -] as const; +]; /** * Prompt for commit style preset selection @@ -96,6 +103,7 @@ export async function promptPreset(): Promise { options: PRESET_OPTIONS.map((option) => ({ value: option.value, label: `${option.name} - e.g., ${option.example}`, + ...(option.hint && { hint: option.hint }), // Include hint if present // description is kept in PRESET_OPTIONS for future use })), }); From 787aa489e342f43688899ad70e3a44ea5563a3e2 Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Sun, 23 Nov 2025 14:31:20 -0700 Subject: [PATCH 03/10] feat(testing): add sandbox script for safe commit testing - Add labcommitr-sandbox.sh script for isolated testing - Update sandbox directory from .test-temp to .sandbox - Add npm scripts for sandbox management - Update gitignore and tsconfig to exclude sandbox directory --- .gitignore | 3 +- package.json | 6 +- scripts/labcommitr-sandbox.sh | 470 ++++++++++++++++++++++++++++++++++ tsconfig.json | 2 +- 4 files changed, 476 insertions(+), 5 deletions(-) create mode 100755 scripts/labcommitr-sandbox.sh diff --git a/.gitignore b/.gitignore index 9c44b80..ba70f3b 100644 --- a/.gitignore +++ b/.gitignore @@ -36,9 +36,8 @@ coverage/ *.lcov # Testing -.test-temp/ +.sandbox/ test-results/ -scripts/ # npm/yarn/pnpm npm-debug.log* diff --git a/package.json b/package.json index b91d1ac..e0a57f1 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,10 @@ "format:ci": "pnpm run format:code", "format:code": "prettier -w \"**/*\" --ignore-unknown --cache", "version": "changeset version && pnpm install --no-frozen-lockfile && pnpm run format", - "test:commit:sandbox": "bash scripts/test-commit-sandbox.sh", - "test:commit:reset": "bash scripts/reset-sandbox.sh" + "test:sandbox": "bash scripts/labcommitr-sandbox.sh", + "test:sandbox:bare": "bash scripts/labcommitr-sandbox.sh --no-config", + "test:sandbox:reset": "bash scripts/labcommitr-sandbox.sh --reset", + "test:sandbox:clean": "bash scripts/labcommitr-sandbox.sh --clean" }, "type": "module", "bin": { diff --git a/scripts/labcommitr-sandbox.sh b/scripts/labcommitr-sandbox.sh new file mode 100755 index 0000000..f47786d --- /dev/null +++ b/scripts/labcommitr-sandbox.sh @@ -0,0 +1,470 @@ +#!/bin/bash + +# Labcommitr Testing Sandbox +# Creates an isolated git repository for testing Labcommitr commands +# Safe to use - nothing affects your real repository + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SANDBOX_BASE="$PROJECT_ROOT/.sandbox" + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Scientific names for fun repository names +SCIENTIFIC_NAMES=( + "quark" "photon" "neutron" "electron" "atom" "molecule" + "proton" "boson" "fermion" "quantum" "plasma" "ion" + "catalyst" "enzyme" "polymer" "crystal" "isotope" "nucleus" + "chromosome" "genome" "protein" "dna" "rna" "enzyme" + "nebula" "galaxy" "asteroid" "comet" "pulsar" "quasar" +) + +# Function to generate random scientific name +generate_sandbox_name() { + local random_index=$((RANDOM % ${#SCIENTIFIC_NAMES[@]})) + echo "${SCIENTIFIC_NAMES[$random_index]}" +} + +# Function to find existing sandbox +find_existing_sandbox() { + if [ -d "$SANDBOX_BASE" ]; then + local dirs=("$SANDBOX_BASE"/*) + if [ -d "${dirs[0]}" ]; then + echo "${dirs[0]}" + return 0 + fi + fi + return 1 +} + +# Function to display usage +show_usage() { + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --reset Quick reset: reset git state without full recreation" + echo " --clean Remove sandbox directory entirely" + echo " --no-config Create sandbox without copying config (start from scratch)" + echo " --help Show this help message" + echo "" + echo "Examples:" + echo " $0 # Create or recreate sandbox (with config if available)" + echo " $0 --no-config # Create sandbox without config (run 'lab init' yourself)" + echo " $0 --reset # Quick reset (faster, keeps repo structure)" + echo " $0 --clean # Remove sandbox completely" +} + +# Parse arguments +RESET_MODE=false +CLEAN_MODE=false +NO_CONFIG=false + +while [[ $# -gt 0 ]]; do + case $1 in + --reset) + RESET_MODE=true + shift + ;; + --clean) + CLEAN_MODE=true + shift + ;; + --no-config) + NO_CONFIG=true + shift + ;; + --help|-h) + show_usage + exit 0 + ;; + *) + echo -e "${RED}Error: Unknown option: $1${NC}" + show_usage + exit 1 + ;; + esac +done + +# Handle clean mode +if [ "$CLEAN_MODE" = true ]; then + SANDBOX_DIR=$(find_existing_sandbox) + if [ -n "$SANDBOX_DIR" ]; then + echo -e "${YELLOW}Removing sandbox: $SANDBOX_DIR${NC}" + rm -rf "$SANDBOX_DIR" + echo -e "${GREEN}✓${NC} Sandbox removed" + + # Clean up base directory if empty + if [ -d "$SANDBOX_BASE" ] && [ -z "$(ls -A "$SANDBOX_BASE")" ]; then + rmdir "$SANDBOX_BASE" + fi + else + echo -e "${YELLOW}No sandbox found to clean${NC}" + fi + exit 0 +fi + +# Handle reset mode +if [ "$RESET_MODE" = true ]; then + SANDBOX_DIR=$(find_existing_sandbox) + if [ -z "$SANDBOX_DIR" ]; then + echo -e "${RED}Error: No existing sandbox found. Run without --reset to create one.${NC}" + exit 1 + fi + + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${BLUE} Labcommitr Testing Sandbox - Quick Reset${NC}" + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + echo -e "${GREEN}✓${NC} Resetting sandbox: $SANDBOX_DIR" + cd "$SANDBOX_DIR" + + # Reset git state + git reset --hard HEAD 2>/dev/null || true + git clean -fd 2>/dev/null || true + + # Ensure directories exist (they might have been removed by git clean) + mkdir -p src docs lib utils + + # Re-apply changes (same as modification phase) + echo -e "${GREEN}✓${NC} Re-applying test file states..." + + # Modify 4 files + cat >> src/component-a.ts << 'EOF' +export function newFeatureA() { return 'new'; } +EOF + cat >> src/component-b.ts << 'EOF' +export function newFeatureB() { return 'new'; } +EOF + cat >> src/component-c.ts << 'EOF' +export function newFeatureC() { return 'new'; } +EOF + cat >> src/component-d.ts << 'EOF' +export function newFeatureD() { return 'new'; } +EOF + git add src/component-a.ts src/component-b.ts src/component-c.ts src/component-d.ts + + # Add 4 new files + echo "# New service A" > src/service-a.ts + echo "export class ServiceA {}" >> src/service-a.ts + git add src/service-a.ts + + echo "# New service B" > src/service-b.ts + echo "export class ServiceB {}" >> src/service-b.ts + git add src/service-b.ts + + echo "# New service C" > src/service-c.ts + echo "export class ServiceC {}" >> src/service-c.ts + git add src/service-c.ts + + echo "# New service D" > docs/guide.md + echo "# User Guide" >> docs/guide.md + git add docs/guide.md + + # Delete 4 files + git rm -f utils/old-util-1.js utils/old-util-2.js utils/old-util-3.js utils/old-util-4.js 2>/dev/null || true + + # Rename 4 files + git mv -f lib/helpers.ts lib/helper-functions.ts 2>/dev/null || true + git mv -f lib/constants.ts lib/app-constants.ts 2>/dev/null || true + git mv -f lib/types.ts lib/type-definitions.ts 2>/dev/null || true + git mv -f lib/config.ts lib/configuration.ts 2>/dev/null || true + + # Copy 4 files + cp src/model-1.ts src/model-1-backup.ts + echo "" >> src/model-1-backup.ts + echo "// Backup copy" >> src/model-1-backup.ts + git add src/model-1-backup.ts + + cp src/model-2.ts src/model-2-backup.ts + echo "" >> src/model-2-backup.ts + echo "// Backup copy" >> src/model-2-backup.ts + git add src/model-2-backup.ts + + cp src/model-3.ts lib/model-3-copy.ts + echo "" >> lib/model-3-copy.ts + echo "// Copy in lib directory" >> lib/model-3-copy.ts + git add lib/model-3-copy.ts + + cp src/model-4.ts lib/model-4-copy.ts + echo "" >> lib/model-4-copy.ts + echo "// Copy in lib directory" >> lib/model-4-copy.ts + git add lib/model-4-copy.ts + + # Pre-staged file + echo "# Pre-staged file" > pre-staged.ts + git add pre-staged.ts + + echo "" + echo -e "${GREEN}✓${NC} Sandbox reset complete!" + echo "" + echo -e "${YELLOW}Sandbox location:${NC} $SANDBOX_DIR" + echo "" + echo -e "${YELLOW}To test:${NC}" + echo " cd $SANDBOX_DIR" + echo " node $PROJECT_ROOT/dist/index.js commit" + echo "" + echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + exit 0 +fi + +# Normal mode: create or recreate sandbox +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${BLUE} Labcommitr Testing Sandbox${NC}" +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo "" + +# Generate sandbox name +SANDBOX_NAME=$(generate_sandbox_name) +SANDBOX_DIR="$SANDBOX_BASE/$SANDBOX_NAME" + +# Clean up existing sandbox if it exists +if [ -d "$SANDBOX_DIR" ]; then + echo -e "${YELLOW}⚠ Cleaning up existing sandbox...${NC}" + echo -e "${YELLOW} (This ensures all file types are properly staged)${NC}" + rm -rf "$SANDBOX_DIR" +fi + +# Create sandbox directory +echo -e "${GREEN}✓${NC} Creating sandbox: $SANDBOX_NAME" +mkdir -p "$SANDBOX_DIR" +cd "$SANDBOX_DIR" + +# Initialize git repository +echo -e "${GREEN}✓${NC} Initializing git repository..." +git init --initial-branch=main +git config user.name "Test User" +git config user.email "test@example.com" + +# Copy config file if it exists (unless --no-config flag is set) +if [ "$NO_CONFIG" = false ]; then + if [ -f "$PROJECT_ROOT/.labcommitr.config.yaml" ]; then + echo -e "${GREEN}✓${NC} Copying config file..." + cp "$PROJECT_ROOT/.labcommitr.config.yaml" "$SANDBOX_DIR/.labcommitr.config.yaml" + else + echo -e "${YELLOW}⚠ No config file found. Run 'lab init' in sandbox after setup.${NC}" + fi +else + echo -e "${YELLOW}⚠ Sandbox created without config. Run 'lab init' to set up configuration.${NC}" +fi + +# Create initial commit +echo -e "${GREEN}✓${NC} Creating initial commit structure..." +cat > README.md << 'EOF' +# Test Repository + +This is a sandbox repository for testing the labcommitr commit command. +Safe to experiment with - nothing affects your real repository. +EOF + +cat > package.json << 'EOF' +{ + "name": "test-project", + "version": "1.0.0", + "description": "Test project for commit command" +} +EOF + +git add . +git commit -m "Initial commit" --no-verify + +# Create various file states for testing +echo -e "${GREEN}✓${NC} Creating test files with various states..." + +# Create directory structure +mkdir -p src docs lib utils + +# ============================================================================ +# SETUP PHASE: Create base files that will be modified/deleted/renamed/copied +# ============================================================================ + +# Files for modification (4 files) +echo "# Component A" > src/component-a.ts +echo "export class ComponentA {}" >> src/component-a.ts +git add src/component-a.ts +git commit -m "Add component A" --no-verify + +echo "# Component B" > src/component-b.ts +echo "export class ComponentB {}" >> src/component-b.ts +git add src/component-b.ts +git commit -m "Add component B" --no-verify + +echo "# Component C" > src/component-c.ts +echo "export class ComponentC {}" >> src/component-c.ts +git add src/component-c.ts +git commit -m "Add component C" --no-verify + +echo "# Component D" > src/component-d.ts +echo "export class ComponentD {}" >> src/component-d.ts +git add src/component-d.ts +git commit -m "Add component D" --no-verify + +# Files for deletion (4 files) +echo "# Old utility 1" > utils/old-util-1.js +git add utils/old-util-1.js +git commit -m "Add old util 1" --no-verify + +echo "# Old utility 2" > utils/old-util-2.js +git add utils/old-util-2.js +git commit -m "Add old util 2" --no-verify + +echo "# Old utility 3" > utils/old-util-3.js +git add utils/old-util-3.js +git commit -m "Add old util 3" --no-verify + +echo "# Old utility 4" > utils/old-util-4.js +git add utils/old-util-4.js +git commit -m "Add old util 4" --no-verify + +# Files for renaming (4 files) +echo "# Helper functions" > lib/helpers.ts +git add lib/helpers.ts +git commit -m "Add helpers" --no-verify + +echo "# Constants" > lib/constants.ts +git add lib/constants.ts +git commit -m "Add constants" --no-verify + +echo "# Types" > lib/types.ts +git add lib/types.ts +git commit -m "Add types" --no-verify + +echo "# Config" > lib/config.ts +git add lib/config.ts +git commit -m "Add config" --no-verify + +# Files for copying (4 files - will copy these) +echo "# Original model 1" > src/model-1.ts +git add src/model-1.ts +git commit -m "Add model 1" --no-verify + +echo "# Original model 2" > src/model-2.ts +git add src/model-2.ts +git commit -m "Add model 2" --no-verify + +echo "# Original model 3" > src/model-3.ts +git add src/model-3.ts +git commit -m "Add model 3" --no-verify + +echo "# Original model 4" > src/model-4.ts +git add src/model-4.ts +git commit -m "Add model 4" --no-verify + +# ============================================================================ +# MODIFICATION PHASE: Apply changes for testing +# ============================================================================ + +# Modify 4 files (M - Modified) and STAGE them +# IMPORTANT: Modify first, then stage all at once to ensure they're staged +cat >> src/component-a.ts << 'EOF' +export function newFeatureA() { return 'new'; } +EOF + +cat >> src/component-b.ts << 'EOF' +export function newFeatureB() { return 'new'; } +EOF + +cat >> src/component-c.ts << 'EOF' +export function newFeatureC() { return 'new'; } +EOF + +cat >> src/component-d.ts << 'EOF' +export function newFeatureD() { return 'new'; } +EOF + +# Stage all modified files together +git add src/component-a.ts src/component-b.ts src/component-c.ts src/component-d.ts + +# Add 4 new files (A - Added) and STAGE them +echo "# New service A" > src/service-a.ts +echo "export class ServiceA {}" >> src/service-a.ts +git add src/service-a.ts + +echo "# New service B" > src/service-b.ts +echo "export class ServiceB {}" >> src/service-b.ts +git add src/service-b.ts + +echo "# New service C" > src/service-c.ts +echo "export class ServiceC {}" >> src/service-c.ts +git add src/service-c.ts + +echo "# New service D" > docs/guide.md +echo "# User Guide" >> docs/guide.md +git add docs/guide.md + +# Delete 4 files (D - Deleted) +git rm utils/old-util-1.js +git rm utils/old-util-2.js +git rm utils/old-util-3.js +git rm utils/old-util-4.js + +# Rename 4 files (R - Renamed) +git mv lib/helpers.ts lib/helper-functions.ts +git mv lib/constants.ts lib/app-constants.ts +git mv lib/types.ts lib/type-definitions.ts +git mv lib/config.ts lib/configuration.ts + +# Copy 4 files (C - Copied) +# IMPORTANT: For Git to detect copies, source files must exist in previous commits +# and copies must have sufficient content (Git needs similarity threshold) +# We'll add more content to ensure detection works +cp src/model-1.ts src/model-1-backup.ts +# Add a comment to make it a proper copy (but still similar enough) +echo "" >> src/model-1-backup.ts +echo "// Backup copy" >> src/model-1-backup.ts +git add src/model-1-backup.ts + +cp src/model-2.ts src/model-2-backup.ts +echo "" >> src/model-2-backup.ts +echo "// Backup copy" >> src/model-2-backup.ts +git add src/model-2-backup.ts + +cp src/model-3.ts lib/model-3-copy.ts +echo "" >> lib/model-3-copy.ts +echo "// Copy in lib directory" >> lib/model-3-copy.ts +git add lib/model-3-copy.ts + +cp src/model-4.ts lib/model-4-copy.ts +echo "" >> lib/model-4-copy.ts +echo "// Copy in lib directory" >> lib/model-4-copy.ts +git add lib/model-4-copy.ts + +# Create one staged file for testing already-staged scenario +echo "# Pre-staged file" > pre-staged.ts +git add pre-staged.ts + +echo "" +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${GREEN}✓${NC} Sandbox ready: $SANDBOX_NAME" +echo "" +echo -e "${YELLOW}Sandbox location:${NC} $SANDBOX_DIR" +echo "" +echo -e "${YELLOW}Current repository state (all staged):${NC}" +echo " • Modified files (4): src/component-{a,b,c,d}.ts" +echo " • Added files (4): src/service-{a,b,c}.ts, docs/guide.md" +echo " • Deleted files (4): utils/old-util-{1,2,3,4}.js" +echo " • Renamed files (4): lib/{helpers→helper-functions, constants→app-constants, types→type-definitions, config→configuration}.ts" +echo " • Copied files (4): src/model-{1,2}-backup.ts, lib/model-{3,4}-copy.ts" +echo " • Pre-staged file: pre-staged.ts" +echo "" +echo -e "${YELLOW}To test:${NC}" +echo " cd $SANDBOX_DIR" +echo " node $PROJECT_ROOT/dist/index.js commit" +echo "" +echo -e "${YELLOW}To reset (quick):${NC}" +echo " bash $SCRIPT_DIR/labcommitr-sandbox.sh --reset" +echo "" +echo -e "${YELLOW}To reset (full recreation):${NC}" +echo " bash $SCRIPT_DIR/labcommitr-sandbox.sh" +echo "" +echo -e "${YELLOW}To clean up:${NC}" +echo " bash $SCRIPT_DIR/labcommitr-sandbox.sh --clean" +echo "" +echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + diff --git a/tsconfig.json b/tsconfig.json index 51c7342..6c69c80 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -109,5 +109,5 @@ "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "scripts", ".test-temp"] + "exclude": ["node_modules", "dist", "scripts", ".sandbox"] } From 2358dedc4c8a9baefe693a15e149e94fcbaa0353 Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Sun, 23 Nov 2025 14:31:23 -0700 Subject: [PATCH 04/10] docs: add testing sandbox section to README - Add Development & Testing section with sandbox commands - Include quick reference for sandbox operations - Link to detailed testing documentation --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 123bd2d..dce4762 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,25 @@ A solution for building standardized git commits! `labcommitr init`, `-i`: Create a file called `.labcommitrc` in the root directory of the current git repo. `labcommitr go [...message]`: Quickly submit a commit of the specified type with a message. If a message is not specified, a generic one will be generated for you (it is not good practice, however its BLAZINGLY FAST). + +## Development & Testing + +### Testing Sandbox + +For safe testing of Labcommitr commands without affecting your real repository, use the testing sandbox: + +```bash +# Create sandbox with config (if available) +pnpm run test:sandbox + +# Create sandbox without config (start from scratch) +pnpm run test:sandbox:bare + +# Quick reset for iterative testing +pnpm run test:sandbox:reset + +# Clean up +pnpm run test:sandbox:clean +``` + +See [`scripts/README.md`](scripts/README.md) for complete testing documentation. From 4b71dcca053975769a727b22f879de0208f8d3ac Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Sun, 23 Nov 2025 14:47:28 -0700 Subject: [PATCH 05/10] docs: Updated .gitignore and Testing README.md --- .gitignore | 1 + scripts/README.md | 370 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 scripts/README.md diff --git a/.gitignore b/.gitignore index ba70f3b..c5bebc2 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ Thumbs.db .project .classpath .settings/ +.cursor/ # Temporary files *.tmp diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..6c3c551 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,370 @@ +# Labcommitr Testing Sandbox + +A testing environment for safely experimenting with Labcommitr commands without affecting your real repository. + +## TLDR; Quick Start + +```bash +# Create sandbox with config (if available in project root) +pnpm run test:sandbox + +# Create sandbox without config (start from scratch) +pnpm run test:sandbox:bare + +# Enter sandbox and test +cd .sandbox/*/ +node ../../dist/index.js commit + +# Quick reset (faster, keeps repo structure) +pnpm run test:sandbox:reset + +# Full recreation (slower, completely fresh) +pnpm run test:sandbox + +# Clean up (remove sandbox) +pnpm run test:sandbox:clean +``` + +--- + +## Table of Contents + +- [Overview](#overview) +- [Quick Start](#tldr-quick-start) +- [Usage](#usage) + - [Creating a Sandbox](#creating-a-sandbox) + - [Testing Commands](#testing-commands) + - [Resetting the Sandbox](#resetting-the-sandbox) + - [Cleaning Up](#cleaning-up) +- [Sandbox Contents](#sandbox-contents) +- [Testing Scenarios](#testing-scenarios) +- [Troubleshooting](#troubleshooting) +- [Safety Guarantees](#safety-guarantees) + +--- + +## Overview + +The testing sandbox creates an isolated git repository with pre-configured file states to test Labcommitr's commit command. Each sandbox gets a randomized scientific name (e.g., `quark`, `photon`, `neutron`) and is stored in `.sandbox/` directory. + +**Key Features:** +- ✅ Completely isolated from your real repository +- ✅ Pre-populated with various git file states (modified, added, deleted, renamed, copied) +- ✅ Automatically copies your project's `.labcommitr.config.yaml` if it exists +- ✅ Safe to delete anytime +- ✅ Quick reset option for iterative testing + +--- + +## Usage + +### Creating a Sandbox + +You have two options for creating a sandbox: + +**Option 1: With Config (Default)** +```bash +# Using npm script (recommended) +pnpm run test:sandbox + +# Or direct script execution +bash scripts/labcommitr-sandbox.sh +``` + +This will: +1. Create a new sandbox directory in `.sandbox//` +2. Initialize a git repository +3. Copy your project's `.labcommitr.config.yaml` if it exists (ready to test immediately) +4. Create test files with various git states +5. Stage all changes ready for testing + +**Option 2: Without Config (Start from Scratch)** +```bash +# Using npm script (recommended) +pnpm run test:sandbox:bare + +# Or direct script execution +bash scripts/labcommitr-sandbox.sh --no-config +``` + +This will: +1. Create a new sandbox directory in `.sandbox//` +2. Initialize a git repository +3. **Skip copying config** (sandbox starts without configuration) +4. Create test files with various git states +5. Stage all changes ready for testing + +After creating a bare sandbox, you can set up configuration: +```bash +cd .sandbox/*/ +lab init # Interactive setup (or lab init --force to overwrite if config exists) +``` + +**Note:** If a sandbox already exists, it will be completely recreated (full reset). + +### Testing Commands + +Once the sandbox is created, navigate to it and test Labcommitr: + +```bash +# Find your sandbox (it has a random scientific name) +cd .sandbox/*/ + +# Test commit command (recommended method) +node ../../dist/index.js commit + +# Alternative: If you've linked globally +lab commit +``` + +**⚠️ Important:** Do NOT use `npx lab commit` - it will use the wrong 'lab' package (Node.js test framework). + +### Resetting the Sandbox + +You have two reset options: + +**Quick Reset** (recommended for iterative testing) +```bash +pnpm run test:sandbox:reset +# or +bash scripts/labcommitr-sandbox.sh --reset +``` +- Faster (keeps repository structure) +- Resets git state and re-applies test file changes +- Use this when you want to test again quickly + +**Full Recreation** (slower but completely fresh) +```bash +pnpm run test:sandbox +# or +bash scripts/labcommitr-sandbox.sh +``` +- Removes entire sandbox and recreates from scratch +- Ensures all file types are properly staged +- Use this if quick reset doesn't work or you want a clean slate + +### Cleaning Up + +To completely remove the sandbox: + +```bash +pnpm run test:sandbox:clean +# or +bash scripts/labcommitr-sandbox.sh --clean +``` + +This removes the sandbox directory entirely. The `.sandbox/` base directory is also removed if empty. + +--- + +## Sandbox Contents + +Each sandbox contains a git repository with the following pre-staged file states: + +### File States (17 total files staged) + +- **Modified (4 files)**: `src/component-{a,b,c,d}.ts` - Files with changes +- **Added (4 files)**: `src/service-{a,b,c}.ts`, `docs/guide.md` - New files +- **Deleted (4 files)**: `utils/old-util-{1,2,3,4}.js` - Files marked for deletion +- **Renamed (4 files)**: `lib/{helpers→helper-functions, constants→app-constants, types→type-definitions, config→configuration}.ts` - Files moved/renamed +- **Copied (4 files)**: `src/model-{1,2}-backup.ts`, `lib/model-{3,4}-copy.ts` - Files copied (Git detects with `-C50` flag) +- **Pre-staged (1 file)**: `pre-staged.ts` - Already staged file for testing + +### Directory Structure + +``` +.sandbox/ +└── / # e.g., quark, photon, neutron + ├── .labcommitr.config.yaml # Copied from project root (if exists) + ├── README.md + ├── package.json + ├── src/ + │ ├── component-{a,b,c,d}.ts + │ ├── service-{a,b,c}.ts + │ ├── model-{1,2,3,4}.ts + │ └── model-{1,2}-backup.ts + ├── docs/ + │ └── guide.md + ├── lib/ + │ ├── helper-functions.ts + │ ├── app-constants.ts + │ ├── type-definitions.ts + │ ├── configuration.ts + │ └── model-{3,4}-copy.ts + ├── utils/ + └── pre-staged.ts +``` + +--- + +## Testing Scenarios + +### Test Different Configurations + +1. **Modify config in sandbox:** + ```bash + cd .sandbox/*/ + # Edit .labcommitr.config.yaml + # Test with different settings: + # - auto_stage: true vs false + # - Different commit types + # - Validation rules + # - editor_preference: "auto" | "inline" | "editor" + ``` + +2. **Test auto-stage behavior:** + - Set `auto_stage: true` - tool should stage files automatically + - Set `auto_stage: false` - tool should only commit already-staged files + +3. **Test validation rules:** + - Try invalid commit types + - Test scope requirements + - Test subject length limits + +4. **Test editor preferences:** + - `inline`: Type body directly in terminal + - `editor`: Opens your default editor + - `auto`: Detects available editor automatically + +### Verify Commit Results + +```bash +# Check git log +git log --oneline -5 + +# See last commit details +git show HEAD + +# Check git status +git status +git status --porcelain # Compact format + +# See staged files +git diff --cached --name-only +``` + +--- + +## Troubleshooting + +### Files Don't Appear Correctly + +If git status doesn't show the expected file states: + +1. **Full recreation:** + ```bash + pnpm run test:sandbox + ``` + +2. **Check git status manually:** + ```bash + cd .sandbox/*/ + git status + git status --porcelain + ``` + +### Config Not Found + +If you see "No config file found" or created a bare sandbox: + +1. **Create config in sandbox (recommended):** + ```bash + cd .sandbox/*/ + lab init # Interactive setup + ``` + +2. **Or create config in project root first, then recreate sandbox:** + ```bash + # From project root + lab init + # Then recreate sandbox to copy the config + pnpm run test:sandbox + ``` + +3. **To overwrite existing config in sandbox:** + ```bash + cd .sandbox/*/ + lab init --force # Overwrites existing config + ``` + +### Reset Not Working + +If quick reset fails: + +1. **Use full recreation instead:** + ```bash + pnpm run test:sandbox + ``` + +2. **Or manually reset:** + ```bash + cd .sandbox/*/ + git reset --hard HEAD + git clean -fd + ``` + +### Can't Find Sandbox + +Sandbox location is randomized. To find it: + +```bash +# List all sandboxes +ls -la .sandbox/ + +# Or use find +find .sandbox -name ".git" -type d +``` + +--- + +## Safety Guarantees + +The sandbox is **100% safe**: + +1. **No push to remote**: Sandbox is completely separate, no remote configured +2. **Isolated**: No connection to your real repository +3. **Easy cleanup**: Delete directory when done (`pnpm run test:sandbox:clean`) +4. **No side effects**: Changes only exist in test environment +5. **Git-ignored**: `.sandbox/` is in `.gitignore`, won't be committed + +--- + +## Pro Tips + +1. **Keep sandbox open in separate terminal** for quick iteration +2. **Use quick reset** (`--reset`) for faster testing cycles +3. **Use bare sandbox** (`--no-config`) to test the full `lab init` flow +4. **Test both `auto_stage: true` and `false`** configurations +5. **Test editor preferences** (`inline`, `editor`, `auto`) +6. **Test validation rules** by intentionally breaking them +7. **Check git log** after commits to verify message formatting +8. **Use `lab init --force`** in sandbox to test different presets and configurations + +--- + +## Script Options + +The `labcommitr-sandbox.sh` script supports the following options: + +```bash +# Create or recreate sandbox with config (default) +bash scripts/labcommitr-sandbox.sh + +# Create sandbox without config (start from scratch) +bash scripts/labcommitr-sandbox.sh --no-config + +# Quick reset (faster, keeps repo structure) +bash scripts/labcommitr-sandbox.sh --reset + +# Remove sandbox completely +bash scripts/labcommitr-sandbox.sh --clean + +# Show help +bash scripts/labcommitr-sandbox.sh --help +``` + +--- + +**Last Updated**: January 2025 +**Script Location**: `scripts/labcommitr-sandbox.sh` +**Sandbox Location**: `.sandbox//` From 018f3326a8f76eb006b5039b8e8dfe89bdc33bde Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Sun, 23 Nov 2025 15:55:16 -0700 Subject: [PATCH 06/10] refactor: improve manual commit workflow and UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Skip file preview when all required fields provided via CLI • Preserve and display original command for manual commits • Fix optional body prompt triggering in direct commit path • Add --body option to commit command with help text • Improve error handling for unquoted CLI arguments • Refactor commit action into two paths: direct (complete) and interactive (incomplete) --- src/cli/commands/commit.ts | 12 + src/cli/commands/commit/index.ts | 388 +++++++++++++++++++++++------ src/cli/commands/commit/prompts.ts | 40 ++- src/index.ts | 17 +- 4 files changed, 364 insertions(+), 93 deletions(-) diff --git a/src/cli/commands/commit.ts b/src/cli/commands/commit.ts index c702f63..86bac9b 100644 --- a/src/cli/commands/commit.ts +++ b/src/cli/commands/commit.ts @@ -17,5 +17,17 @@ export const commitCommand = new Command("commit") .option("-t, --type ", "Commit type (feat, fix, etc.)") .option("-s, --scope ", "Commit scope") .option("-m, --message ", "Commit subject") + .option("-b, --body ", "Commit body/description") .option("--no-verify", "Bypass git hooks") + .addHelpText( + "after", + ` +Examples: + $ lab commit Create commit interactively + $ lab commit -t feat -m "add feature" Quick commit with type and message + $ lab commit -t feat -s api -m "add endpoint" -b "Implements REST endpoint" + +Note: Messages and body with spaces must be quoted. +`, + ) .action(commitAction); diff --git a/src/cli/commands/commit/index.ts b/src/cli/commands/commit/index.ts index cc3ea53..6c13260 100644 --- a/src/cli/commands/commit/index.ts +++ b/src/cli/commands/commit/index.ts @@ -12,6 +12,7 @@ */ import { loadConfig, ConfigError } from "../../../lib/config/index.js"; +import type { LabcommitrConfig } from "../../../lib/config/types.js"; import { Logger } from "../../../lib/logger.js"; import { isGitRepository } from "./git.js"; import { @@ -45,6 +46,44 @@ function clearTerminal(): void { } } +/** + * Reconstruct the original commit command from options + * Used to display what command was run for manual commits + */ +function reconstructCommand(options: { + type?: string; + scope?: string; + message?: string; + body?: string; + verify?: boolean; +}): string { + const parts: string[] = ["lab commit"]; + + if (options.type) { + parts.push(`-t ${options.type}`); + } + + if (options.scope) { + parts.push(`-s ${options.scope}`); + } + + if (options.message) { + // Always quote message for consistency (handles spaces) + parts.push(`-m "${options.message}"`); + } + + if (options.body !== undefined) { + // Always quote body for consistency (handles spaces and newlines) + parts.push(`-b "${options.body}"`); + } + + if (options.verify === false) { + parts.push("--no-verify"); + } + + return parts.join(" "); +} + /** * Handle cleanup: unstage files we staged */ @@ -65,6 +104,67 @@ async function cleanup(state: CommitState): Promise { } } +/** + * Check if all required commit fields are provided via CLI options + * Returns completeness status and list of missing required fields + */ +function hasAllRequiredFields( + config: LabcommitrConfig, + options: { type?: string; scope?: string; message?: string; body?: string }, +): { complete: boolean; missing: string[]; provided: string[] } { + const missing: string[] = []; + const provided: string[] = []; + + // Type is always required + if (!options.type || options.type.trim() === "") { + missing.push("type"); + } else { + provided.push("type"); + } + + // If type is provided, check if scope is required for this type + if (options.type) { + const isScopeRequired = config.validation.require_scope_for.includes( + options.type, + ); + if (isScopeRequired) { + if (!options.scope || options.scope.trim() === "") { + missing.push("scope"); + } else { + provided.push("scope"); + } + } else if (options.scope) { + // Scope provided but not required - still counts as provided + provided.push("scope"); + } + } + + // Subject is always required + if (!options.message || options.message.trim() === "") { + missing.push("subject"); + } else { + provided.push("subject"); + } + + // Body is required if configured + if (config.format.body.required) { + if (!options.body || options.body.trim() === "") { + missing.push("body"); + } else { + provided.push("body"); + } + } else if (options.body) { + // Body provided but not required - still counts as provided + provided.push("body"); + } + + return { + complete: missing.length === 0, + missing, + provided, + }; +} + /** * Main commit action handler */ @@ -72,11 +172,9 @@ export async function commitAction(options: { type?: string; scope?: string; message?: string; + body?: string; verify?: boolean; }): Promise { - // Clear terminal for clean prompt display - clearTerminal(); - try { // Step 1: Load configuration const configResult = await loadConfig(); @@ -205,21 +303,35 @@ export async function commitAction(options: { } } - // Step 4: Display staged files verification and wait for confirmation - const gitStatus = getGitStatus(alreadyStagedFiles); - await displayStagedFiles(gitStatus); - - // Step 5: Collect initial commit data via prompts - let { type, emoji } = await promptType(config, options.type); - let scope = await promptScope(config, type, options.scope); - let subject = await promptSubject(config, options.message); - let body = await promptBody(config); - - // Step 6: Preview/edit loop - let action: "commit" | "edit-type" | "edit-scope" | "edit-subject" | "edit-body" | "cancel"; - - do { - // Format message with current values + // Step 4: Check if all required fields are provided + const requiredCheck = hasAllRequiredFields(config, options); + + if (requiredCheck.complete) { + // ============================================ + // PATH A: All required fields provided + // ============================================ + // Don't clear screen - preserve original command + // Display the command that was run + console.log(reconstructCommand(options)); + console.log(); + + // Validate all provided values upfront (these functions exit on error) + const { type, emoji } = await promptType(config, options.type); + const scope = await promptScope(config, type, options.scope); + const subject = await promptSubject(config, options.message); + + // Body handling: only prompt if required or provided via CLI + let body: string | undefined; + if (config.format.body.required) { + // Body is required - must prompt for it if not provided + body = await promptBody(config, undefined, options.body); + } else if (options.body !== undefined) { + // Body is optional but provided via CLI - validate it + body = await promptBody(config, undefined, options.body); + } + // else: body is optional and not provided, leave as undefined (skip prompt) + + // Skip preview - commit directly const formattedMessage = formatCommitMessage( config, type, @@ -228,27 +340,22 @@ export async function commitAction(options: { subject, ); - // Show preview and get user action - action = await displayPreview(formattedMessage, body); - - // Handle edit actions - if (action === "edit-type") { - const typeResult = await promptType(config, undefined, type); - type = typeResult.type; - emoji = typeResult.emoji; - // Re-validate scope if type changed (scope requirements might have changed) - const isScopeRequired = config.validation.require_scope_for.includes(type); - if (isScopeRequired && !scope) { - // Scope is now required, prompt for it - scope = await promptScope(config, type, undefined, scope); - } - } else if (action === "edit-scope") { - scope = await promptScope(config, type, undefined, scope); - } else if (action === "edit-subject") { - subject = await promptSubject(config, undefined, subject); - } else if (action === "edit-body") { - body = await promptBody(config, body); - } else if (action === "cancel") { + // Step 7: Execute commit immediately + console.log(); + console.log("◐ Creating commit..."); + + try { + const commitHash = createCommit( + formattedMessage, + body, + config.advanced.git.sign_commits, + options.verify === false, + ); + + console.log(`${success("✓")} Commit created successfully!`); + console.log(` ${commitHash} ${formattedMessage}`); + } catch (error: unknown) { + // Cleanup on failure await cleanup({ config, autoStageEnabled, @@ -259,57 +366,174 @@ export async function commitAction(options: { scope, subject, body, - formattedMessage: formatCommitMessage(config, type, emoji, scope, subject), + formattedMessage, }); - console.log("\nCommit cancelled."); - process.exit(0); + + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error(`\n✗ Error: Git commit failed`); + console.error(`\n ${errorMessage}`); + console.error( + "\n Fix: Check 'git status' and verify staged files, then try again\n", + ); + process.exit(1); } - // If action is "commit", exit loop and proceed - } while (action !== "commit"); - - // Final formatted message for commit - const formattedMessage = formatCommitMessage( - config, - type, - emoji, - scope, - subject, - ); + } else { + // ============================================ + // PATH B: Missing required fields + // ============================================ + // Clear terminal for clean interactive prompt display + clearTerminal(); - // Step 7: Execute commit - console.log(); - console.log("◐ Creating commit..."); - - try { - const commitHash = createCommit( - formattedMessage, - body, - config.advanced.git.sign_commits, - options.verify === false, - ); + // Step 4: Display staged files verification and wait for confirmation + const gitStatus = getGitStatus(alreadyStagedFiles); + await displayStagedFiles(gitStatus); + + // Show what's provided vs missing (helpful context) + if (requiredCheck.provided.length > 0) { + console.log(); + console.log("Provided:"); + requiredCheck.provided.forEach((field) => { + const value = + field === "type" + ? options.type + : field === "scope" + ? options.scope + : field === "subject" + ? options.message + : ""; + console.log(` • ${field}: ${value}`); + }); + } + + if (requiredCheck.missing.length > 0) { + console.log(); + console.log("Missing required fields:"); + requiredCheck.missing.forEach((field) => { + console.log(` • ${field}`); + }); + console.log(); + } + + // Use same flow as interactive, but skip provided fields + let { type, emoji } = await promptType(config, options.type); + let scope = await promptScope(config, type, options.scope); + let subject = await promptSubject(config, options.message); + let body = await promptBody(config, undefined, options.body); + + // Step 6: Preview/edit loop + let action: + | "commit" + | "edit-type" + | "edit-scope" + | "edit-subject" + | "edit-body" + | "cancel"; + + do { + // Format message with current values + const formattedMessage = formatCommitMessage( + config, + type, + emoji, + scope, + subject, + ); + + // Show preview and get user action + action = await displayPreview(formattedMessage, body); + + // Handle edit actions + if (action === "edit-type") { + const typeResult = await promptType(config, undefined, type); + type = typeResult.type; + emoji = typeResult.emoji; + // Re-validate scope if type changed (scope requirements might have changed) + const isScopeRequired = config.validation.require_scope_for.includes( + type, + ); + if (isScopeRequired && !scope) { + // Scope is now required, prompt for it + scope = await promptScope(config, type, undefined, scope); + } + } else if (action === "edit-scope") { + scope = await promptScope(config, type, undefined, scope); + } else if (action === "edit-subject") { + subject = await promptSubject(config, undefined, subject); + } else if (action === "edit-body") { + body = await promptBody(config, body); + } else if (action === "cancel") { + await cleanup({ + config, + autoStageEnabled, + alreadyStagedFiles, + newlyStagedFiles, + type, + typeEmoji: emoji, + scope, + subject, + body, + formattedMessage: formatCommitMessage( + config, + type, + emoji, + scope, + subject, + ), + }); + console.log("\nCommit cancelled."); + process.exit(0); + } + // If action is "commit", exit loop and proceed + } while (action !== "commit"); - console.log(`${success("✓")} Commit created successfully!`); - console.log(` ${commitHash} ${formattedMessage}`); - } catch (error: unknown) { - // Cleanup on failure - await cleanup({ + // Final formatted message for commit + const formattedMessage = formatCommitMessage( config, - autoStageEnabled, - alreadyStagedFiles, - newlyStagedFiles, type, - typeEmoji: emoji, + emoji, scope, subject, - body, - formattedMessage, - }); - - const errorMessage = - error instanceof Error ? error.message : String(error); - console.error(`\n✗ Error: Git commit failed`); - console.error(`\n ${errorMessage}\n`); - process.exit(1); + ); + + // Step 7: Execute commit + console.log(); + console.log("◐ Creating commit..."); + + try { + const commitHash = createCommit( + formattedMessage, + body, + config.advanced.git.sign_commits, + options.verify === false, + ); + + console.log(`${success("✓")} Commit created successfully!`); + console.log(` ${commitHash} ${formattedMessage}`); + } catch (error: unknown) { + // Cleanup on failure + await cleanup({ + config, + autoStageEnabled, + alreadyStagedFiles, + newlyStagedFiles, + type, + typeEmoji: emoji, + scope, + subject, + body, + formattedMessage, + }); + + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error(`\n✗ Error: Git commit failed`); + console.error(`\n ${errorMessage}`); + console.error( + "\n Fix: Check 'git status' and verify staged files, then try again\n", + ); + process.exit(1); + } } } catch (error: unknown) { if (error instanceof ConfigError) { diff --git a/src/cli/commands/commit/prompts.ts b/src/cli/commands/commit/prompts.ts index 10828d4..6e5487e 100644 --- a/src/cli/commands/commit/prompts.ts +++ b/src/cli/commands/commit/prompts.ts @@ -72,14 +72,10 @@ export async function promptType( .map((t) => ` • ${t.id} - ${t.description}`) .join("\n"); console.error(`\n✗ Error: Invalid commit type '${providedType}'`); - console.error( - "\n The commit type is not defined in your configuration.", - ); + console.error("\n This type is not defined in your configuration."); console.error("\n Available types:"); console.error(available); - console.error("\n Solutions:"); - console.error(" • Use one of the available types listed above"); - console.error(" • Check your configuration file for custom types\n"); + console.error(`\n Fix: Use one of the types above with -t \n`); process.exit(1); } return { @@ -131,11 +127,15 @@ export async function promptScope( console.error( `\n✗ Error: Scope is required for commit type '${selectedType}'`, ); + console.error("\n Your configuration requires a scope for this commit type."); + console.error(`\n Fix: Add scope with -s or run 'lab commit' interactively\n`); process.exit(1); } if (allowedScopes.length > 0 && !allowedScopes.includes(providedScope)) { console.error(`\n✗ Error: Invalid scope '${providedScope}'`); - console.error(`\n Allowed scopes: ${allowedScopes.join(", ")}\n`); + console.error("\n This scope is not allowed in your configuration."); + console.error(`\n Allowed scopes: ${allowedScopes.join(", ")}`); + console.error(`\n Fix: Use one of the allowed scopes with -s \n`); process.exit(1); } return providedScope || undefined; @@ -263,14 +263,14 @@ export async function promptSubject( if (providedMessage) { const errors = validateSubject(config, providedMessage); if (errors.length > 0) { - console.error("\n✗ Validation failed:"); + console.error("\n✗ Commit subject validation failed:"); for (const error of errors) { - console.error(` • ${error.message}`); + console.error(`\n • ${error.message}`); if (error.context) { console.error(` ${error.context}`); } } - console.error(); + console.error(`\n Fix: Correct the subject and try again, or run 'lab commit' interactively\n`); process.exit(1); } return providedMessage; @@ -387,6 +387,7 @@ function validateBody( export async function promptBody( config: LabcommitrConfig, initialBody?: string | undefined, + providedBody?: string | undefined, ): Promise { const bodyConfig = config.format.body; const editorAvailable = detectEditor() !== null; @@ -395,6 +396,25 @@ export async function promptBody( // Explicitly check if body is required (handle potential type coercion) const isRequired = bodyConfig.required === true; + // If body provided via CLI flag, validate it + if (providedBody !== undefined) { + const errors = validateBody(config, providedBody); + if (errors.length > 0) { + console.error("\n✗ Commit body validation failed:"); + for (const error of errors) { + console.error(`\n • ${error.message}`); + if (error.context) { + console.error(` ${error.context}`); + } + } + console.error( + `\n Fix: Correct the body and try again, or run 'lab commit' interactively\n`, + ); + process.exit(1); + } + return providedBody || undefined; + } + // If editor preference is "editor" but no editor available, fall back to inline if (preference === "editor" && !editorAvailable) { console.log(); diff --git a/src/index.ts b/src/index.ts index 579048b..f88af31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,7 +23,22 @@ import { handleCliError } from "./cli/utils/error-handler.js"; async function main(): Promise { try { await program.parseAsync(process.argv); - } catch (error) { + } catch (error: unknown) { + // Check if error is about too many arguments (likely unquoted message/body) + if ( + error instanceof Error && + error.message.includes("too many arguments") + ) { + console.error("\n✗ Error: Too many arguments"); + console.error("\n Your message or body contains spaces and needs to be quoted."); + console.error("\n Fix: Use quotes around values with spaces:"); + console.error(` • Message: -m "your message here"`); + console.error(` • Body: -b "your body here"`); + console.error( + ` • Example: lab commit -t feat -m "add feature" -b "detailed description"\n`, + ); + process.exit(1); + } handleCliError(error); process.exit(1); } From fce706a085d809b66795b65f54f5849641f0c1d8 Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Sun, 23 Nov 2025 15:55:17 -0700 Subject: [PATCH 07/10] feat: add sandbox reset with config preservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Add --preserve-config option to sandbox reset • Create reset-sandbox.sh helper script for in-sandbox resets • Add sandbox directory detection for running from within sandbox • Automatically update reset script during sandbox reset • Improve reset workflow with preserve/remove config options --- scripts/labcommitr-sandbox.sh | 115 +++++++++++++++++++++++++++++----- scripts/reset-sandbox.sh | 38 +++++++++++ 2 files changed, 139 insertions(+), 14 deletions(-) create mode 100755 scripts/reset-sandbox.sh diff --git a/scripts/labcommitr-sandbox.sh b/scripts/labcommitr-sandbox.sh index f47786d..a52ddc8 100755 --- a/scripts/labcommitr-sandbox.sh +++ b/scripts/labcommitr-sandbox.sh @@ -44,27 +44,65 @@ find_existing_sandbox() { return 1 } +# Function to detect if we're in a sandbox directory +detect_sandbox_dir() { + local current_dir="$(pwd)" + local abs_current_dir="$(cd "$current_dir" && pwd)" + + # Check if current directory is within .sandbox/ + if [[ "$abs_current_dir" == *"/.sandbox/"* ]]; then + # Find the .sandbox base directory + local sandbox_base="${abs_current_dir%/.sandbox/*}/.sandbox" + + # Check if .sandbox directory exists + if [ ! -d "$sandbox_base" ]; then + return 1 + fi + + # Get the path after .sandbox/ + local relative_path="${abs_current_dir#${sandbox_base}/}" + + # Extract the first directory name (sandbox name) + local sandbox_name="${relative_path%%/*}" + + # Construct full sandbox path + local sandbox_dir="$sandbox_base/$sandbox_name" + + # Verify it's actually a git repository (sandbox marker) + if [ -d "$sandbox_dir/.git" ]; then + echo "$sandbox_dir" + return 0 + fi + fi + return 1 +} + # Function to display usage show_usage() { echo "Usage: $0 [OPTIONS]" echo "" echo "Options:" - echo " --reset Quick reset: reset git state without full recreation" - echo " --clean Remove sandbox directory entirely" - echo " --no-config Create sandbox without copying config (start from scratch)" - echo " --help Show this help message" + echo " --reset Quick reset: reset git state without full recreation" + echo " --preserve-config Preserve .labcommitr.config.yaml during reset (use with --reset)" + echo " --clean Remove sandbox directory entirely" + echo " --no-config Create sandbox without copying config (start from scratch)" + echo " --help Show this help message" echo "" echo "Examples:" - echo " $0 # Create or recreate sandbox (with config if available)" - echo " $0 --no-config # Create sandbox without config (run 'lab init' yourself)" - echo " $0 --reset # Quick reset (faster, keeps repo structure)" - echo " $0 --clean # Remove sandbox completely" + echo " $0 # Create or recreate sandbox (with config if available)" + echo " $0 --no-config # Create sandbox without config (run 'lab init' yourself)" + echo " $0 --reset # Quick reset (faster, keeps repo structure)" + echo " $0 --reset --preserve-config # Quick reset, preserve config file" + echo " $0 --clean # Remove sandbox completely" + echo "" + echo "Note: Can be run from within sandbox directory or project root" } # Parse arguments RESET_MODE=false CLEAN_MODE=false NO_CONFIG=false +PRESERVE_CONFIG=false while [[ $# -gt 0 ]]; do case $1 in @@ -72,6 +110,10 @@ while [[ $# -gt 0 ]]; do RESET_MODE=true shift ;; + --preserve-config) + PRESERVE_CONFIG=true + shift + ;; --clean) CLEAN_MODE=true shift @@ -112,8 +154,16 @@ fi # Handle reset mode if [ "$RESET_MODE" = true ]; then - SANDBOX_DIR=$(find_existing_sandbox) - if [ -z "$SANDBOX_DIR" ]; then + # Try to detect if we're in a sandbox directory first + DETECTED_SANDBOX=$(detect_sandbox_dir) + if [ -n "$DETECTED_SANDBOX" ] && [ -d "$DETECTED_SANDBOX" ]; then + SANDBOX_DIR="$DETECTED_SANDBOX" + else + # Fall back to finding existing sandbox from project root + SANDBOX_DIR=$(find_existing_sandbox) + fi + + if [ -z "$SANDBOX_DIR" ] || [ ! -d "$SANDBOX_DIR" ]; then echo -e "${RED}Error: No existing sandbox found. Run without --reset to create one.${NC}" exit 1 fi @@ -125,10 +175,25 @@ if [ "$RESET_MODE" = true ]; then echo -e "${GREEN}✓${NC} Resetting sandbox: $SANDBOX_DIR" cd "$SANDBOX_DIR" + # Preserve config if requested + CONFIG_BACKUP="" + if [ "$PRESERVE_CONFIG" = true ] && [ -f ".labcommitr.config.yaml" ]; then + CONFIG_BACKUP=$(mktemp) + cp ".labcommitr.config.yaml" "$CONFIG_BACKUP" + echo -e "${GREEN}✓${NC} Preserving config file..." + fi + # Reset git state git reset --hard HEAD 2>/dev/null || true git clean -fd 2>/dev/null || true + # Restore config if preserved + if [ -n "$CONFIG_BACKUP" ] && [ -f "$CONFIG_BACKUP" ]; then + cp "$CONFIG_BACKUP" ".labcommitr.config.yaml" + rm "$CONFIG_BACKUP" + echo -e "${GREEN}✓${NC} Config file restored" + fi + # Ensure directories exist (they might have been removed by git clean) mkdir -p src docs lib utils @@ -201,6 +266,13 @@ EOF echo "# Pre-staged file" > pre-staged.ts git add pre-staged.ts + # Always ensure reset script is present and up-to-date + if [ -f "$SCRIPT_DIR/reset-sandbox.sh" ]; then + cp "$SCRIPT_DIR/reset-sandbox.sh" "reset-sandbox.sh" + chmod +x "reset-sandbox.sh" + echo -e "${GREEN}✓${NC} Reset script updated" + fi + echo "" echo -e "${GREEN}✓${NC} Sandbox reset complete!" echo "" @@ -210,6 +282,10 @@ EOF echo " cd $SANDBOX_DIR" echo " node $PROJECT_ROOT/dist/index.js commit" echo "" + echo -e "${YELLOW}To reset again (from within sandbox):${NC}" + echo " bash $SCRIPT_DIR/labcommitr-sandbox.sh --reset" + echo " bash $SCRIPT_DIR/labcommitr-sandbox.sh --reset --preserve-config" + echo "" echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" exit 0 fi @@ -254,6 +330,12 @@ else echo -e "${YELLOW}⚠ Sandbox created without config. Run 'lab init' to set up configuration.${NC}" fi +# Copy reset script for convenience +if [ -f "$SCRIPT_DIR/reset-sandbox.sh" ]; then + cp "$SCRIPT_DIR/reset-sandbox.sh" "$SANDBOX_DIR/reset-sandbox.sh" + chmod +x "$SANDBOX_DIR/reset-sandbox.sh" +fi + # Create initial commit echo -e "${GREEN}✓${NC} Creating initial commit structure..." cat > README.md << 'EOF' @@ -457,14 +539,19 @@ echo -e "${YELLOW}To test:${NC}" echo " cd $SANDBOX_DIR" echo " node $PROJECT_ROOT/dist/index.js commit" echo "" -echo -e "${YELLOW}To reset (quick):${NC}" -echo " bash $SCRIPT_DIR/labcommitr-sandbox.sh --reset" +echo -e "${YELLOW}To reset (from within sandbox):${NC}" +echo " bash reset-sandbox.sh # Reset, remove config" +echo " bash reset-sandbox.sh --preserve-config # Reset, keep config" +echo "" +echo -e "${YELLOW}To reset (from project root):${NC}" +echo " pnpm run test:sandbox:reset # Reset, remove config" +echo " bash $SCRIPT_DIR/labcommitr-sandbox.sh --reset --preserve-config # Reset, keep config" echo "" echo -e "${YELLOW}To reset (full recreation):${NC}" -echo " bash $SCRIPT_DIR/labcommitr-sandbox.sh" +echo " pnpm run test:sandbox" echo "" echo -e "${YELLOW}To clean up:${NC}" -echo " bash $SCRIPT_DIR/labcommitr-sandbox.sh --clean" +echo " pnpm run test:sandbox:clean" echo "" echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" diff --git a/scripts/reset-sandbox.sh b/scripts/reset-sandbox.sh new file mode 100755 index 0000000..56ab68d --- /dev/null +++ b/scripts/reset-sandbox.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Quick reset script for sandbox repositories +# Can be run from within a sandbox directory +# Usage: bash reset-sandbox.sh [--preserve-config] + +# Check if we're in a sandbox directory +CURRENT_DIR="$(pwd)" +if [[ "$CURRENT_DIR" != *"/.sandbox/"* ]]; then + echo "Error: This script must be run from within a sandbox directory" + echo "Current directory: $CURRENT_DIR" + echo "" + echo "To reset from project root, use:" + echo " pnpm run test:sandbox:reset" + exit 1 +fi + +# Find the project root by looking for .sandbox parent +# Current dir is something like: /path/to/project/.sandbox/atom +# We need to go up to find the project root +SANDBOX_DIR="$CURRENT_DIR" +PROJECT_ROOT="${SANDBOX_DIR%/.sandbox/*}" +SCRIPT_DIR="$PROJECT_ROOT/scripts" + +# Verify the script exists +if [ ! -f "$SCRIPT_DIR/labcommitr-sandbox.sh" ]; then + echo "Error: Could not find labcommitr-sandbox.sh script" + echo "Expected at: $SCRIPT_DIR/labcommitr-sandbox.sh" + exit 1 +fi + +# Call the main sandbox script with reset flag +if [ "$1" = "--preserve-config" ]; then + bash "$SCRIPT_DIR/labcommitr-sandbox.sh" --reset --preserve-config +else + bash "$SCRIPT_DIR/labcommitr-sandbox.sh" --reset +fi + From 07e76329f59bf881778621dadb547a432323456c Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Sun, 23 Nov 2025 15:55:19 -0700 Subject: [PATCH 08/10] docs: update sandbox testing documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Document new reset options and --preserve-config flag • Add examples for resetting from within sandbox • Update TLDR section with new reset commands • Clarify script options and usage patterns --- scripts/README.md | 53 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 6c3c551..478c245 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -15,8 +15,13 @@ pnpm run test:sandbox:bare cd .sandbox/*/ node ../../dist/index.js commit -# Quick reset (faster, keeps repo structure) -pnpm run test:sandbox:reset +# Quick reset from within sandbox (easiest!) +bash reset-sandbox.sh # Reset, remove config +bash reset-sandbox.sh --preserve-config # Reset, keep config + +# Quick reset from project root +pnpm run test:sandbox:reset # Reset, remove config +bash scripts/labcommitr-sandbox.sh --reset --preserve-config # Reset, keep config # Full recreation (slower, completely fresh) pnpm run test:sandbox @@ -121,17 +126,34 @@ lab commit ### Resetting the Sandbox -You have two reset options: +You have multiple reset options depending on your needs: -**Quick Reset** (recommended for iterative testing) +**Quick Reset from Within Sandbox** (easiest for iterative testing) ```bash -pnpm run test:sandbox:reset -# or -bash scripts/labcommitr-sandbox.sh --reset +# From within the sandbox directory +cd .sandbox/*/ + +# Reset (removes config file) +bash reset-sandbox.sh + +# Reset and preserve config file +bash reset-sandbox.sh --preserve-config ``` +- Can be run from within the sandbox directory - Faster (keeps repository structure) - Resets git state and re-applies test file changes -- Use this when you want to test again quickly +- Option to preserve `.labcommitr.config.yaml` file + +**Quick Reset from Project Root** +```bash +# Reset (removes config file) +pnpm run test:sandbox:reset + +# Reset and preserve config file +bash scripts/labcommitr-sandbox.sh --reset --preserve-config +``` +- Must be run from project root +- Same functionality as reset from within sandbox **Full Recreation** (slower but completely fresh) ```bash @@ -356,6 +378,9 @@ bash scripts/labcommitr-sandbox.sh --no-config # Quick reset (faster, keeps repo structure) bash scripts/labcommitr-sandbox.sh --reset +# Quick reset with config preservation +bash scripts/labcommitr-sandbox.sh --reset --preserve-config + # Remove sandbox completely bash scripts/labcommitr-sandbox.sh --clean @@ -363,6 +388,18 @@ bash scripts/labcommitr-sandbox.sh --clean bash scripts/labcommitr-sandbox.sh --help ``` +**Note:** The script can detect if it's being run from within a sandbox directory and will automatically use that sandbox for reset operations. + +### Reset Script (Within Sandbox) + +Each sandbox includes a `reset-sandbox.sh` script for convenience: + +```bash +# From within sandbox directory +bash reset-sandbox.sh # Reset, remove config +bash reset-sandbox.sh --preserve-config # Reset, keep config +``` + --- **Last Updated**: January 2025 From d1f65743f943cd185df547caca012d7e14a8dbf8 Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Sun, 23 Nov 2025 15:55:20 -0700 Subject: [PATCH 09/10] chore: clarify .sandbox directory in gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Add comment explaining sandbox directory is for testing • Note that only scripts are tracked, not sandbox contents --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index c5bebc2..8efb2a1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ coverage/ *.lcov # Testing +# Sandbox directory for testing Labcommitr commands (scripts in scripts/ are tracked) .sandbox/ test-results/ @@ -68,6 +69,7 @@ Thumbs.db .cache/ + ### Other # Remove rust files for now rust-src/ From 12b99b4313aaea2ebed8983273495c39d6b4b3c9 Mon Sep 17 00:00:00 2001 From: Satanshu Mishra Date: Sun, 23 Nov 2025 16:58:29 -0700 Subject: [PATCH 10/10] feat: add keyboard shortcuts for faster prompt navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Add keyboard shortcuts module with auto-assignment algorithm • Enable shortcuts by default in generated configurations • Generate default shortcut mappings for commit types in init workflow • Implement input interception for single-character shortcut selection • Add shortcuts support to type, preview, and body input prompts • Include shortcuts configuration in advanced section with validation • Support custom shortcut mappings with auto-assignment fallback • Display shortcut hints in prompt labels when enabled --- .changeset/add-keyboard-shortcuts.md | 15 ++ src/cli/commands/commit/index.ts | 2 +- src/cli/commands/commit/prompts.ts | 297 ++++++++++++++------- src/lib/config/defaults.ts | 16 +- src/lib/config/types.ts | 31 +++ src/lib/config/validator.ts | 144 ++++++++++ src/lib/presets/index.ts | 52 ++++ src/lib/shortcuts/auto-assign.ts | 84 ++++++ src/lib/shortcuts/index.ts | 114 ++++++++ src/lib/shortcuts/input-handler.ts | 48 ++++ src/lib/shortcuts/select-with-shortcuts.ts | 129 +++++++++ src/lib/shortcuts/types.ts | 37 +++ 12 files changed, 871 insertions(+), 98 deletions(-) create mode 100644 .changeset/add-keyboard-shortcuts.md create mode 100644 src/lib/shortcuts/auto-assign.ts create mode 100644 src/lib/shortcuts/index.ts create mode 100644 src/lib/shortcuts/input-handler.ts create mode 100644 src/lib/shortcuts/select-with-shortcuts.ts create mode 100644 src/lib/shortcuts/types.ts diff --git a/.changeset/add-keyboard-shortcuts.md b/.changeset/add-keyboard-shortcuts.md new file mode 100644 index 0000000..03ca473 --- /dev/null +++ b/.changeset/add-keyboard-shortcuts.md @@ -0,0 +1,15 @@ +--- +"@labcatr/labcommitr": minor +--- + +feat: add keyboard shortcuts for faster prompt navigation + +- Add keyboard shortcuts module with auto-assignment algorithm +- Enable shortcuts by default in generated configurations +- Generate default shortcut mappings for commit types in init workflow +- Implement input interception for single-character shortcut selection +- Add shortcuts support to type, preview, and body input prompts +- Include shortcuts configuration in advanced section with validation +- Support custom shortcut mappings with auto-assignment fallback +- Display shortcut hints in prompt labels when enabled + diff --git a/src/cli/commands/commit/index.ts b/src/cli/commands/commit/index.ts index 6c13260..cf83521 100644 --- a/src/cli/commands/commit/index.ts +++ b/src/cli/commands/commit/index.ts @@ -441,7 +441,7 @@ export async function commitAction(options: { ); // Show preview and get user action - action = await displayPreview(formattedMessage, body); + action = await displayPreview(formattedMessage, body, config); // Handle edit actions if (action === "edit-type") { diff --git a/src/cli/commands/commit/prompts.ts b/src/cli/commands/commit/prompts.ts index 6e5487e..632be08 100644 --- a/src/cli/commands/commit/prompts.ts +++ b/src/cli/commands/commit/prompts.ts @@ -13,6 +13,12 @@ import type { } from "../../../lib/config/types.js"; import type { ValidationError } from "./types.js"; import { editInEditor, detectEditor } from "./editor.js"; +import { + processShortcuts, + formatLabelWithShortcut, + getShortcutForValue, +} from "../../../lib/shortcuts/index.js"; +import { selectWithShortcuts } from "../../../lib/shortcuts/select-with-shortcuts.js"; /** * Create compact color-coded label @@ -84,21 +90,45 @@ export async function promptType( }; } + // Process shortcuts for this prompt + const shortcutMapping = processShortcuts( + config.advanced.shortcuts, + "type", + config.types.map((t) => ({ + value: t.id, + label: `${t.id.padEnd(8)} ${t.description}`, + })), + ); + + const displayHints = config.advanced.shortcuts?.display_hints ?? true; + // Find initial type index if provided const initialIndex = initialType ? config.types.findIndex((t) => t.id === initialType) : undefined; - const selected = await select({ - message: `${label("type", "magenta")} ${textColors.pureWhite("Select commit type:")}`, - options: config.types.map((type) => ({ + // Build options with shortcuts + const options = config.types.map((type) => { + const shortcut = getShortcutForValue(type.id, shortcutMapping); + const baseLabel = `${type.id.padEnd(8)} ${type.description}`; + const label = formatLabelWithShortcut(baseLabel, shortcut, displayHints); + + return { value: type.id, - label: `${type.id.padEnd(8)} ${type.description}`, + label, hint: type.description, - })), - initialValue: initialIndex !== undefined && initialIndex >= 0 ? config.types[initialIndex].id : undefined, + }; }); + const selected = await selectWithShortcuts( + { + message: `${label("type", "magenta")} ${textColors.pureWhite("Select commit type:")}`, + options, + initialValue: initialIndex !== undefined && initialIndex >= 0 ? config.types[initialIndex].id : undefined, + }, + shortcutMapping, + ); + handleCancel(selected); const typeId = selected as string; const typeConfig = config.types.find((t) => t.id === typeId)!; @@ -436,24 +466,39 @@ export async function promptBody( if (!isRequired) { // Optional body - offer choice if editor available and preference allows if (editorAvailable && preference === "auto") { - const inputMethod = await select({ - message: `${label("body", "yellow")} ${textColors.pureWhite("Enter commit body (optional):")}`, - options: [ - { - value: "inline", - label: "Type inline (single/multi-line)", - }, - { - value: "editor", - label: "Open in editor", - }, - { - value: "skip", - label: "Skip (no body)", - }, - ], + const bodyOptions = [ + { value: "inline", label: "Type inline (single/multi-line)" }, + { value: "editor", label: "Open in editor" }, + { value: "skip", label: "Skip (no body)" }, + ]; + + const shortcutMapping = processShortcuts( + config.advanced.shortcuts, + "body", + bodyOptions, + ); + const displayHints = config.advanced.shortcuts?.display_hints ?? true; + + const options = bodyOptions.map((option) => { + const shortcut = shortcutMapping + ? getShortcutForValue(option.value, shortcutMapping) + : undefined; + const label = formatLabelWithShortcut(option.label, shortcut, displayHints); + + return { + value: option.value, + label, + }; }); + const inputMethod = await selectWithShortcuts( + { + message: `${label("body", "yellow")} ${textColors.pureWhite("Enter commit body (optional):")}`, + options, + }, + shortcutMapping, + ); + handleCancel(inputMethod); if (inputMethod === "skip") { @@ -501,22 +546,40 @@ export async function promptBody( // For required body, offer editor option if available and preference allows if (editorAvailable && (preference === "auto" || preference === "inline")) { - const inputMethod = await select({ - message: `${label("body", "yellow")} ${textColors.pureWhite( - `Enter commit body (required${bodyConfig.min_length > 0 ? `, min ${bodyConfig.min_length} chars` : ""}):`, - )}`, - options: [ - { - value: "inline", - label: "Type inline", - }, - { - value: "editor", - label: "Open in editor", - }, - ], + const bodyOptions = [ + { value: "inline", label: "Type inline" }, + { value: "editor", label: "Open in editor" }, + ]; + + const shortcutMapping = processShortcuts( + config.advanced.shortcuts, + "body", + bodyOptions, + ); + const displayHints = config.advanced.shortcuts?.display_hints ?? true; + + const options = bodyOptions.map((option) => { + const shortcut = shortcutMapping + ? getShortcutForValue(option.value, shortcutMapping) + : undefined; + const label = formatLabelWithShortcut(option.label, shortcut, displayHints); + + return { + value: option.value, + label, + }; }); + const inputMethod = await selectWithShortcuts( + { + message: `${label("body", "yellow")} ${textColors.pureWhite( + `Enter commit body (required${bodyConfig.min_length > 0 ? `, min ${bodyConfig.min_length} chars` : ""}):`, + )}`, + options, + }, + shortcutMapping, + ); + handleCancel(inputMethod); if (inputMethod === "editor") { @@ -601,24 +664,39 @@ async function promptBodyRequiredWithEditor( const edited = await promptBodyWithEditor(config, body); if (edited === null || edited === undefined) { // Editor cancelled, ask what to do - const choice = await select({ - message: `${label("body", "yellow")} ${textColors.pureWhite("Editor cancelled. What would you like to do?")}`, - options: [ - { - value: "retry", - label: "Try editor again", - }, - { - value: "inline", - label: "Switch to inline input", - }, - { - value: "cancel", - label: "Cancel commit", - }, - ], + const bodyRetryOptions = [ + { value: "retry", label: "Try editor again" }, + { value: "inline", label: "Switch to inline input" }, + { value: "cancel", label: "Cancel commit" }, + ]; + + const shortcutMapping = processShortcuts( + config.advanced.shortcuts, + "body", + bodyRetryOptions, + ); + const displayHints = config.advanced.shortcuts?.display_hints ?? true; + + const options = bodyRetryOptions.map((option) => { + const shortcut = shortcutMapping + ? getShortcutForValue(option.value, shortcutMapping) + : undefined; + const label = formatLabelWithShortcut(option.label, shortcut, displayHints); + + return { + value: option.value, + label, + }; }); + const choice = await selectWithShortcuts( + { + message: `${label("body", "yellow")} ${textColors.pureWhite("Editor cancelled. What would you like to do?")}`, + options, + }, + shortcutMapping, + ); + handleCancel(choice); if (choice === "cancel") { @@ -694,24 +772,39 @@ async function promptBodyWithEditor( console.log(); // Ask if user wants to re-edit or go back to inline - const choice = await select({ - message: `${label("body", "yellow")} ${textColors.pureWhite("Validation failed. What would you like to do?")}`, - options: [ - { - value: "re-edit", - label: "Edit again", - }, - { - value: "inline", - label: "Type inline instead", - }, - { - value: "cancel", - label: "Cancel commit", - }, - ], + const bodyValidationOptions = [ + { value: "re-edit", label: "Edit again" }, + { value: "inline", label: "Type inline instead" }, + { value: "cancel", label: "Cancel commit" }, + ]; + + const shortcutMapping = processShortcuts( + config.advanced.shortcuts, + "body", + bodyValidationOptions, + ); + const displayHints = config.advanced.shortcuts?.display_hints ?? true; + + const options = bodyValidationOptions.map((option) => { + const shortcut = shortcutMapping + ? getShortcutForValue(option.value, shortcutMapping) + : undefined; + const label = formatLabelWithShortcut(option.label, shortcut, displayHints); + + return { + value: option.value, + label, + }; }); + const choice = await selectWithShortcuts( + { + message: `${label("body", "yellow")} ${textColors.pureWhite("Validation failed. What would you like to do?")}`, + options, + }, + shortcutMapping, + ); + handleCancel(choice); if (choice === "cancel") { @@ -951,6 +1044,7 @@ export async function displayStagedFiles(status: { export async function displayPreview( formattedMessage: string, body: string | undefined, + config?: LabcommitrConfig, ): Promise<"commit" | "edit-type" | "edit-scope" | "edit-subject" | "edit-body" | "cancel"> { // Start connector line using @clack/prompts log.info( @@ -976,36 +1070,47 @@ export async function displayPreview( renderWithConnector("─────────────────────────────────────────────"), ); - const action = await select({ - message: `${success("✓")} ${textColors.pureWhite("Ready to commit?")}`, - options: [ - { - value: "commit", - label: "Create commit", - }, - { - value: "edit-type", - label: "Edit type", - }, - { - value: "edit-scope", - label: "Edit scope", - }, - { - value: "edit-subject", - label: "Edit subject", - }, - { - value: "edit-body", - label: "Edit body", - }, - { - value: "cancel", - label: "Cancel", - }, - ], + // Process shortcuts for preview prompt + const previewOptions = [ + { value: "commit", label: "Create commit" }, + { value: "edit-type", label: "Edit type" }, + { value: "edit-scope", label: "Edit scope" }, + { value: "edit-subject", label: "Edit subject" }, + { value: "edit-body", label: "Edit body" }, + { value: "cancel", label: "Cancel" }, + ]; + + const shortcutMapping = config + ? processShortcuts( + config.advanced.shortcuts, + "preview", + previewOptions, + ) + : null; + + const displayHints = config?.advanced.shortcuts?.display_hints ?? true; + + // Build options with shortcuts + const options = previewOptions.map((option) => { + const shortcut = shortcutMapping + ? getShortcutForValue(option.value, shortcutMapping) + : undefined; + const label = formatLabelWithShortcut(option.label, shortcut, displayHints); + + return { + value: option.value, + label, + }; }); + const action = await selectWithShortcuts( + { + message: `${success("✓")} ${textColors.pureWhite("Ready to commit?")}`, + options, + }, + shortcutMapping, + ); + handleCancel(action); return action as "commit" | "edit-type" | "edit-scope" | "edit-subject" | "edit-body" | "cancel"; } diff --git a/src/lib/config/defaults.ts b/src/lib/config/defaults.ts index 5eab263..7bff690 100644 --- a/src/lib/config/defaults.ts +++ b/src/lib/config/defaults.ts @@ -6,7 +6,7 @@ * These defaults are merged with user-provided configuration to create the final config. */ -import type { LabcommitrConfig, RawConfig } from "./types.js"; +import type { LabcommitrConfig, RawConfig, ShortcutsConfig } from "./types.js"; /** * Complete default configuration object @@ -76,6 +76,11 @@ export const DEFAULT_CONFIG: LabcommitrConfig = { // Don't auto-sign commits (user configures as needed) sign_commits: false, }, + // Shortcuts enabled by default for better UX + shortcuts: { + enabled: true, + display_hints: true, + }, }, }; @@ -173,6 +178,15 @@ export function mergeWithDefaults(rawConfig: RawConfig): LabcommitrConfig { ...rawConfig.advanced.git, }; } + + // Handle nested shortcuts configuration + if (rawConfig.advanced.shortcuts) { + merged.advanced.shortcuts = { + enabled: rawConfig.advanced.shortcuts.enabled ?? merged.advanced.shortcuts?.enabled ?? true, + display_hints: rawConfig.advanced.shortcuts.display_hints ?? merged.advanced.shortcuts?.display_hints ?? true, + prompts: rawConfig.advanced.shortcuts.prompts ?? merged.advanced.shortcuts?.prompts, + }; + } } return merged; diff --git a/src/lib/config/types.ts b/src/lib/config/types.ts index c9bf283..b1f1e35 100644 --- a/src/lib/config/types.ts +++ b/src/lib/config/types.ts @@ -33,6 +33,35 @@ export interface BodyConfig { editor_preference: "auto" | "inline" | "editor"; } +/** + * Keyboard shortcuts configuration + * Enables single-character shortcuts for faster prompt navigation + */ +export interface ShortcutsConfig { + /** Master toggle - enables/disables all shortcuts (default: false) */ + enabled: boolean; + /** Whether to display shortcut hints in prompts (default: true) */ + display_hints: boolean; + /** Per-prompt shortcut mappings (optional) */ + prompts?: { + /** Commit type selection shortcuts */ + type?: { + /** Mapping of shortcut key → option value */ + mapping?: Record; + }; + /** Preview action shortcuts */ + preview?: { + /** Mapping of shortcut key → action value */ + mapping?: Record; + }; + /** Body input method selection shortcuts */ + body?: { + /** Mapping of shortcut key → method value */ + mapping?: Record; + }; + }; +} + /** * Main configuration interface - fully resolved with all defaults applied * This represents the complete configuration structure after processing @@ -82,6 +111,8 @@ export interface LabcommitrConfig { /** Sign commits with GPG */ sign_commits: boolean; }; + /** Keyboard shortcuts configuration */ + shortcuts?: ShortcutsConfig; }; } diff --git a/src/lib/config/validator.ts b/src/lib/config/validator.ts index 8f68ad1..a58052a 100644 --- a/src/lib/config/validator.ts +++ b/src/lib/config/validator.ts @@ -370,6 +370,150 @@ export class ConfigValidator { expectedFormat: "object with advanced configuration", issue: "Found non-object value", }); + } else if (config.advanced) { + // Validate shortcuts if present in advanced section + errors.push(...this.validateShortcuts(config)); + } + + return errors; + } + + /** + * Validate shortcuts configuration + * @param config - Configuration object to validate + * @returns Array of validation errors (empty if valid) + */ + private validateShortcuts(config: RawConfig): ValidationError[] { + const errors: ValidationError[] = []; + + if (!config.advanced?.shortcuts) { + return errors; // Optional section, skip if not present + } + + const shortcuts = config.advanced.shortcuts; + + // Validate enabled + if (shortcuts.enabled !== undefined && typeof shortcuts.enabled !== "boolean") { + errors.push({ + field: "advanced.shortcuts.enabled", + fieldDisplay: "Shortcuts → Enabled", + message: 'Field "enabled" must be a boolean', + userMessage: "The shortcuts enabled setting must be true or false", + value: shortcuts.enabled, + expectedFormat: "boolean (true or false)", + issue: "Found non-boolean value", + }); + } + + // Validate display_hints + if (shortcuts.display_hints !== undefined && typeof shortcuts.display_hints !== "boolean") { + errors.push({ + field: "advanced.shortcuts.display_hints", + fieldDisplay: "Shortcuts → Display Hints", + message: 'Field "display_hints" must be a boolean', + userMessage: "The display hints setting must be true or false", + value: shortcuts.display_hints, + expectedFormat: "boolean (true or false)", + issue: "Found non-boolean value", + }); + } + + // Validate prompts structure + if (shortcuts.prompts !== undefined) { + if (typeof shortcuts.prompts !== "object" || Array.isArray(shortcuts.prompts)) { + errors.push({ + field: "advanced.shortcuts.prompts", + fieldDisplay: "Shortcuts → Prompts", + message: 'Field "prompts" must be an object', + userMessage: "The prompts section must be an object with prompt names as keys", + value: shortcuts.prompts, + expectedFormat: "object with prompt configurations", + issue: "Found non-object value", + }); + return errors; // Can't validate further if structure is wrong + } + + // Validate each prompt's mapping + const promptNames = ["type", "preview", "body"] as const; + for (const promptName of promptNames) { + const promptConfig = shortcuts.prompts[promptName]; + if (promptConfig !== undefined) { + if (typeof promptConfig !== "object" || Array.isArray(promptConfig)) { + errors.push({ + field: `advanced.shortcuts.prompts.${promptName}`, + fieldDisplay: `Shortcuts → Prompts → ${promptName}`, + message: `Prompt config for "${promptName}" must be an object`, + userMessage: `The ${promptName} prompt configuration must be an object`, + value: promptConfig, + expectedFormat: "object with mapping field", + issue: "Found non-object value", + }); + continue; + } + + // Validate mapping + if (promptConfig.mapping !== undefined) { + if (typeof promptConfig.mapping !== "object" || Array.isArray(promptConfig.mapping)) { + errors.push({ + field: `advanced.shortcuts.prompts.${promptName}.mapping`, + fieldDisplay: `Shortcuts → Prompts → ${promptName} → Mapping`, + message: `Mapping for "${promptName}" must be an object`, + userMessage: `The mapping for ${promptName} must be an object with key-value pairs`, + value: promptConfig.mapping, + expectedFormat: "object with shortcut keys and option values", + issue: "Found non-object value", + }); + continue; + } + + // Validate mapping keys and values + const usedKeys = new Set(); + for (const [key, value] of Object.entries(promptConfig.mapping)) { + // Validate key (must be single lowercase letter) + if (!/^[a-z]$/.test(key)) { + errors.push({ + field: `advanced.shortcuts.prompts.${promptName}.mapping.${key}`, + fieldDisplay: `Shortcuts → Prompts → ${promptName} → Mapping → Key "${key}"`, + message: `Shortcut key must be a single lowercase letter (a-z)`, + userMessage: `Shortcut keys must be single letters (a-z)`, + value: key, + expectedFormat: "single lowercase letter (a-z)", + issue: `Invalid shortcut key format`, + }); + } + + // Check for duplicate keys (case-insensitive) + const normalizedKey = key.toLowerCase(); + if (usedKeys.has(normalizedKey)) { + errors.push({ + field: `advanced.shortcuts.prompts.${promptName}.mapping`, + fieldDisplay: `Shortcuts → Prompts → ${promptName} → Mapping`, + message: `Duplicate shortcut key "${key}"`, + userMessage: `The shortcut "${key}" is used for multiple options`, + value: promptConfig.mapping, + expectedFormat: "unique shortcut keys", + issue: `Shortcut "${key}" appears multiple times`, + examples: [`Use different letters for each option`], + }); + } + usedKeys.add(normalizedKey); + + // Validate value (must be string) + if (typeof value !== "string") { + errors.push({ + field: `advanced.shortcuts.prompts.${promptName}.mapping.${key}`, + fieldDisplay: `Shortcuts → Prompts → ${promptName} → Mapping → Value for "${key}"`, + message: `Mapping value must be a string`, + userMessage: `The option value for shortcut "${key}" must be text`, + value: value, + expectedFormat: "string (option value)", + issue: "Found non-string value", + }); + } + } + } + } + } } return errors; diff --git a/src/lib/presets/index.ts b/src/lib/presets/index.ts index e0ef966..6978664 100644 --- a/src/lib/presets/index.ts +++ b/src/lib/presets/index.ts @@ -62,6 +62,46 @@ export function getPreset(id: string): Preset { return preset; } +/** + * Generate default shortcut mappings for commit types + * Creates sensible single-letter shortcuts based on type IDs + * + * @param types - Array of commit types from preset + * @returns Record mapping shortcut key → type ID + */ +function generateDefaultShortcuts( + types: Array<{ id: string }>, +): Record { + const mappings: Record = {}; + const usedKeys = new Set(); + + for (const type of types) { + const normalized = type.id.toLowerCase(); + let key: string | null = null; + let index = 0; + + // Try each character in the type ID to find first available letter + while (index < normalized.length && !key) { + const char = normalized[index]; + // Only use letters (a-z) + if (char >= "a" && char <= "z" && !usedKeys.has(char)) { + key = char; + break; + } + index++; + } + + // Assign shortcut if available letter found + if (key) { + mappings[key] = type.id; + usedKeys.add(key); + } + // If no available letter, type works with arrow keys only (no shortcut) + } + + return mappings; +} + /** * Build complete configuration from preset and user choices * Merges preset defaults with user customizations @@ -91,6 +131,9 @@ export function buildConfig( requireScopeFor = customizations.scopeRequiredFor; } + // Generate default shortcut mappings for commit types + const typeShortcuts = generateDefaultShortcuts(preset.types); + return { version: "1.0", config: { @@ -124,6 +167,15 @@ export function buildConfig( // Security best-practice: enable signed commits by default sign_commits: true, }, + shortcuts: { + enabled: true, // Enabled by default for better UX + display_hints: true, // Show hints when enabled + prompts: { + type: { + mapping: typeShortcuts, // Pre-configured shortcuts for commit types + }, + }, + }, }, }; } diff --git a/src/lib/shortcuts/auto-assign.ts b/src/lib/shortcuts/auto-assign.ts new file mode 100644 index 0000000..2ff90a8 --- /dev/null +++ b/src/lib/shortcuts/auto-assign.ts @@ -0,0 +1,84 @@ +/** + * Auto-Assignment Algorithm + * + * Automatically assigns keyboard shortcuts to prompt options when not configured. + * Uses first available letter from option value, skipping already-used keys. + */ + +import type { ShortcutMapping } from "./types.js"; +import { DEFAULT_CHAR_SET } from "./types.js"; + +/** + * Auto-assign shortcuts to options + * + * @param options - Array of prompt options + * @param configuredMappings - User-configured shortcut mappings + * @returns Complete shortcut mapping (configured + auto-assigned) + */ +export function autoAssignShortcuts( + options: Array<{ value: string; label: string }>, + configuredMappings: Record, +): ShortcutMapping { + const keyToValue: Record = {}; + const valueToKey: Record = {}; + const usedKeys = new Set(); + + // Step 1: Process configured mappings + for (const [key, value] of Object.entries(configuredMappings)) { + const normalizedKey = key.toLowerCase(); + const optionExists = options.some((opt) => opt.value === value); + + if (optionExists) { + keyToValue[normalizedKey] = value; + valueToKey[value] = normalizedKey; + usedKeys.add(normalizedKey); + } + // If option doesn't exist, skip (warning logged during config validation) + } + + // Step 2: Find unassigned options + const unassignedOptions = options.filter((opt) => !valueToKey[opt.value]); + + // Step 3: Auto-assign unassigned options + for (const option of unassignedOptions) { + const shortcut = findAvailableShortcut(option.value, usedKeys); + if (shortcut) { + keyToValue[shortcut] = option.value; + valueToKey[option.value] = shortcut; + usedKeys.add(shortcut); + } + // If no shortcut found, option works with arrow keys only + } + + return { keyToValue, valueToKey }; +} + +/** + * Find first available shortcut for an option value + * + * Searches through the option value's characters to find the first + * available letter that isn't already used as a shortcut. + * + * @param value - Option value to find shortcut for + * @param usedKeys - Set of already-used shortcut keys + * @returns Available shortcut key or null if none found + */ +function findAvailableShortcut( + value: string, + usedKeys: Set, +): string | null { + const normalized = value.toLowerCase(); + const allowedChars = DEFAULT_CHAR_SET.allowedChars; + + // Try each character in the option value + for (let i = 0; i < normalized.length; i++) { + const char = normalized[i]; + // Only consider allowed characters (letters by default) + if (allowedChars.includes(char) && !usedKeys.has(char)) { + return char; + } + } + + return null; // No available shortcut +} + diff --git a/src/lib/shortcuts/index.ts b/src/lib/shortcuts/index.ts new file mode 100644 index 0000000..0462c3a --- /dev/null +++ b/src/lib/shortcuts/index.ts @@ -0,0 +1,114 @@ +/** + * Shortcuts Module + * + * Handles keyboard shortcut configuration, auto-assignment, and integration + * with @clack/prompts select() function. + */ + +import type { ShortcutMapping, ShortcutCharacterSet } from "./types.js"; +import { autoAssignShortcuts } from "./auto-assign.js"; +import { DEFAULT_CHAR_SET } from "./types.js"; + +/** + * Shortcuts configuration from user config + * This is a subset of the full ShortcutsConfig interface + */ +export interface ShortcutsConfigInput { + enabled?: boolean; + display_hints?: boolean; + prompts?: { + type?: { + mapping?: Record; + }; + preview?: { + mapping?: Record; + }; + body?: { + mapping?: Record; + }; + }; +} + +/** + * Process shortcuts configuration for a prompt + * + * @param config - Shortcuts configuration from user config + * @param promptName - Name of prompt ("type", "preview", "body") + * @param options - Array of prompt options + * @returns Shortcut mapping or null if shortcuts disabled + */ +export function processShortcuts( + config: ShortcutsConfigInput | undefined, + promptName: "type" | "preview" | "body", + options: Array<{ value: string; label: string }>, +): ShortcutMapping | null { + // Shortcuts disabled or not configured + if (!config || !config.enabled) { + return null; + } + + // Get configured mappings for this prompt + const promptConfig = config.prompts?.[promptName]; + const configuredMappings = promptConfig?.mapping || {}; + + // Auto-assign missing shortcuts + const mapping = autoAssignShortcuts(options, configuredMappings); + + return mapping; +} + +/** + * Format option label with shortcut hint + * + * @param label - Original option label + * @param shortcut - Shortcut key (if available) + * @param displayHints - Whether to show hints + * @returns Formatted label with shortcut + */ +export function formatLabelWithShortcut( + label: string, + shortcut: string | undefined, + displayHints: boolean, +): string { + if (!shortcut || !displayHints) { + return label; + } + return `[${shortcut}] ${label}`; +} + +/** + * Check if input matches a shortcut and return corresponding value + * + * @param input - User input (single character) + * @param mapping - Shortcut mapping + * @returns Option value if match found, null otherwise + */ +export function matchShortcut( + input: string, + mapping: ShortcutMapping | null, +): string | null { + if (!mapping) { + return null; + } + + const normalizedInput = input.toLowerCase(); + return mapping.keyToValue[normalizedInput] || null; +} + +/** + * Get shortcut key for an option value + * + * @param value - Option value + * @param mapping - Shortcut mapping + * @returns Shortcut key if available, undefined otherwise + */ +export function getShortcutForValue( + value: string, + mapping: ShortcutMapping | null, +): string | undefined { + if (!mapping) { + return undefined; + } + return mapping.valueToKey[value]; +} + diff --git a/src/lib/shortcuts/input-handler.ts b/src/lib/shortcuts/input-handler.ts new file mode 100644 index 0000000..57c0e0e --- /dev/null +++ b/src/lib/shortcuts/input-handler.ts @@ -0,0 +1,48 @@ +/** + * Input Handler for Shortcuts + * + * Intercepts keyboard input to support single-character shortcuts + * before passing to @clack/prompts select() function. + * + * Note: This is a simplified implementation. Full input interception + * would require deeper integration with @clack/prompts internals. + * For now, shortcuts are displayed in labels and users can type + * the letter to match (if @clack/prompts supports it) or use arrow keys. + */ + +import type { ShortcutMapping } from "./types.js"; +import { matchShortcut } from "./index.js"; + +/** + * Check if a character input matches a shortcut + * This can be used to pre-process input before it reaches @clack/prompts + * + * @param input - Single character input + * @param mapping - Shortcut mapping + * @returns Option value if shortcut matches, null otherwise + */ +export function handleShortcutInput( + input: string, + mapping: ShortcutMapping | null, +): string | null { + if (!mapping) { + return null; + } + + return matchShortcut(input, mapping); +} + +/** + * Note: Full input interception would require: + * 1. Setting up raw mode on stdin + * 2. Listening to keypress events + * 3. Intercepting before @clack/prompts processes input + * 4. Programmatically selecting the matched option + * + * This is complex and may conflict with @clack/prompts' internal handling. + * For v1, we display shortcuts in labels and rely on @clack/prompts' + * native behavior (if it supports typing to select) or arrow keys. + * + * Future enhancement: Implement full input interception wrapper. + */ + diff --git a/src/lib/shortcuts/select-with-shortcuts.ts b/src/lib/shortcuts/select-with-shortcuts.ts new file mode 100644 index 0000000..0315223 --- /dev/null +++ b/src/lib/shortcuts/select-with-shortcuts.ts @@ -0,0 +1,129 @@ +/** + * Select with Shortcuts Support + * + * Wraps @clack/prompts select() with keyboard shortcut support. + * Intercepts single-character input to match shortcuts before + * passing control to @clack/prompts. + * + * Note: This implementation uses a workaround since @clack/prompts + * doesn't natively support shortcuts. We intercept keypress events + * and programmatically select the option if a shortcut matches. + */ + +import { select, type SelectOptions } from "@clack/prompts"; +import type { ShortcutMapping } from "./types.js"; +import { matchShortcut } from "./index.js"; +import readline from "readline"; + +/** + * Select with shortcut support + * + * Wraps @clack/prompts select() with custom input handling for shortcuts. + * If a shortcut key is pressed, immediately selects that option. + * Otherwise, passes input to @clack/prompts for normal handling. + * + * @param options - Select options from @clack/prompts + * @param shortcutMapping - Shortcut mapping (null if shortcuts disabled) + * @returns Selected value or symbol (cancel) + */ +export async function selectWithShortcuts( + options: SelectOptions, + shortcutMapping: ShortcutMapping | null, +): Promise { + // If no shortcuts, use normal select + if (!shortcutMapping) { + return await select(options); + } + + // Set up input interception using readline + const stdin = process.stdin; + const wasRaw = stdin.isRaw; + + // Enable raw mode and keypress events + if (!wasRaw) { + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding("utf8"); + } + + readline.emitKeypressEvents(stdin); + + // Create promise that resolves when shortcut is pressed or select completes + return new Promise((resolve) => { + let shortcutResolved = false; + let selectResolved = false; + + // Keypress handler for shortcuts + const onKeypress = (char: string, key: readline.Key) => { + // Ignore if already resolved + if (shortcutResolved || selectResolved) { + return; + } + + // Check for escape (cancel) - let @clack/prompts handle it + if (key.name === "escape" || (key.ctrl && key.name === "c")) { + return; // Let @clack/prompts handle cancellation + } + + // Check for Enter - let @clack/prompts handle it + if (key.name === "return" || key.name === "enter") { + return; + } + + // Check for arrow keys - let @clack/prompts handle them + if ( + key.name === "up" || + key.name === "down" || + key.name === "left" || + key.name === "right" + ) { + return; + } + + // Check if single character matches a shortcut + if (char && char.length === 1 && /^[a-z]$/i.test(char)) { + const matchedValue = matchShortcut(char, shortcutMapping); + if (matchedValue) { + shortcutResolved = true; + cleanup(); + // Resolve with the matched value + resolve(matchedValue as T); + return; + } + } + }; + + // Cleanup function + const cleanup = () => { + stdin.removeListener("keypress", onKeypress); + if (!wasRaw) { + stdin.setRawMode(false); + stdin.pause(); + } + }; + + // Set up keypress listener BEFORE starting select + stdin.on("keypress", onKeypress); + + // Start normal select prompt + // Our listener will intercept shortcuts, but @clack/prompts + // will handle everything else (arrows, Enter, etc.) + select(options) + .then((result) => { + if (!shortcutResolved) { + selectResolved = true; + cleanup(); + resolve(result); + } + }) + .catch((error) => { + if (!shortcutResolved) { + selectResolved = true; + cleanup(); + // On error, treat as cancel + resolve(Symbol.for("clack.cancel")); + } + }); + }); +} + diff --git a/src/lib/shortcuts/types.ts b/src/lib/shortcuts/types.ts new file mode 100644 index 0000000..6489b4b --- /dev/null +++ b/src/lib/shortcuts/types.ts @@ -0,0 +1,37 @@ +/** + * Shortcuts Module Types + * + * TypeScript interfaces for keyboard shortcuts functionality + */ + +/** + * Shortcut mapping structure + * Provides bidirectional lookup between shortcut keys and option values + */ +export interface ShortcutMapping { + /** Map of shortcut key → option value */ + keyToValue: Record; + /** Map of option value → shortcut key */ + valueToKey: Record; +} + +/** + * Character set configuration for shortcuts + * Supports future extensions (e.g., numeric shortcuts) + */ +export interface ShortcutCharacterSet { + /** Characters available for shortcuts (default: a-z) */ + allowedChars: string[]; + /** Priority order for character selection */ + priority: "first" | "last" | "custom"; +} + +/** + * Default character set (letters only) + * Future: Can be extended to include digits 0-9 + */ +export const DEFAULT_CHAR_SET: ShortcutCharacterSet = { + allowedChars: "abcdefghijklmnopqrstuvwxyz".split(""), + priority: "first", +}; +