diff --git a/.changeset/add-body-requirement-prompt.md b/.changeset/add-body-requirement-prompt.md deleted file mode 100644 index 4b65e27..0000000 --- a/.changeset/add-body-requirement-prompt.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@labcatr/labcommitr": minor ---- - -feat: add body requirement prompt to init command - -- New prompt in init flow to set commit body as required or optional -- "Yes" option marked as recommended for better commit practices -- Configuration properly respected in commit prompts -- When body is required, commit prompts show "required" and remove "Skip" option -- Defaults to optional for backward compatibility - diff --git a/.changeset/add-command-aliases-and-help-improvements.md b/.changeset/add-command-aliases-and-help-improvements.md deleted file mode 100644 index 6e8463b..0000000 --- a/.changeset/add-command-aliases-and-help-improvements.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@labcatr/labcommitr": minor ---- - -feat: add init command alias and improve help text - -- Add `i` alias for `init` command for faster access -- Update help examples to use `lab` instead of `labcommitr` consistently -- Add concise examples showing both full commands and aliases -- Add note clarifying both `lab` and `labcommitr` can be used -- Update README to document `init|i` alias -- Remove duplicate pagination text from preview and revert commands -- Improve help text clarity and consistency across all commands - diff --git a/.changeset/add-commit-editor-support.md b/.changeset/add-commit-editor-support.md deleted file mode 100644 index 6c70807..0000000 --- a/.changeset/add-commit-editor-support.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@labcatr/labcommitr": minor ---- - -feat: add editor support for commit body input - -- Users can now open their preferred editor for writing commit bodies -- Supports both inline and editor input methods -- Automatically detects available editors (VS Code, Vim, Nano, etc.) -- Improved experience for multi-line commit bodies -- Configurable editor preference in configuration files - diff --git a/.changeset/add-keyboard-shortcuts.md b/.changeset/add-keyboard-shortcuts.md deleted file mode 100644 index 03ca473..0000000 --- a/.changeset/add-keyboard-shortcuts.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@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/.changeset/add-preview-toggle-functionality.md b/.changeset/add-preview-toggle-functionality.md deleted file mode 100644 index 6151f8a..0000000 --- a/.changeset/add-preview-toggle-functionality.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@labcatr/labcommitr": minor ---- - -feat: add toggle functionality for body and files in preview - -- Add toggle state for body and files visibility in commit detail view -- Implement `b` key to toggle body visibility on/off -- Implement `f` key to toggle files visibility on/off -- Reset toggles when viewing new commit or returning to list -- Update prompt text to indicate toggle behavior -- Fixes issue where pressing `b`/`f` caused repeated rendering -- Improves UX by allowing users to hide/show sections as needed - diff --git a/.changeset/fix-init-config-check-timing.md b/.changeset/fix-init-config-check-timing.md deleted file mode 100644 index 34c7891..0000000 --- a/.changeset/fix-init-config-check-timing.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@labcatr/labcommitr": patch ---- - -fix: move config existence check before Clef intro animation - -- Perform early validation before any UI/animation in init command -- Check for existing config immediately after project root detection -- Only show Clef intro animation if initialization will proceed -- Provides better UX by failing fast with clear error message -- Prevents unnecessary animation when config already exists - diff --git a/.changeset/fix-label-truncation.md b/.changeset/fix-label-truncation.md deleted file mode 100644 index 12af765..0000000 --- a/.changeset/fix-label-truncation.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@labcatr/labcommitr": patch ---- - -fix: prevent label text truncation in prompts - -- Increased label width from 6 to 7 characters to accommodate longer labels -- Fixes issue where "subject" label was being truncated to "subjec" -- Applied to both commit and init command prompts for consistency -- All labels now properly display full text with centered alignment - diff --git a/.changeset/fix-preview-body-extraction.md b/.changeset/fix-preview-body-extraction.md deleted file mode 100644 index 2634cdb..0000000 --- a/.changeset/fix-preview-body-extraction.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@labcatr/labcommitr": patch ---- - -fix: exclude subject line from commit body extraction - -- Split commit message by first blank line to separate subject and body -- Only return content after blank line as body in preview command -- Prevents subject line from appearing in body section -- Fixes incorrect display where commit subject was shown as part of body - diff --git a/.changeset/improve-commit-command-ux.md b/.changeset/improve-commit-command-ux.md deleted file mode 100644 index 478537b..0000000 --- a/.changeset/improve-commit-command-ux.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@labcatr/labcommitr": minor ---- - -feat: enhance commit command user experience - -- Terminal automatically clears at command start for maximum available space -- Improved staged file detection with support for renamed and copied files -- Color-coded Git status indicators (A, M, D, R, C) matching Git's default colors -- Connector lines added to files and preview displays for better visual flow -- More accurate file status reporting with copy detection using -C50 flag - diff --git a/.npmignore b/.npmignore index 5a01cf7..4d95af8 100644 --- a/.npmignore +++ b/.npmignore @@ -24,6 +24,8 @@ docs/ # Exclude build artifacts *.map +**/*.map +dist/**/*.map tsconfig.json .prettierrc* diff --git a/CHANGELOG.md b/CHANGELOG.md index de6d50d..9aa641c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,107 @@ # @labcatr/labcommitr +## 0.4.0 + +### Minor Changes + +- 18d5a56: feat: implement terminal emoji detection and display adaptation + - Add emoji detection utility with industry-standard heuristics (CI, TERM, NO_COLOR, Windows Terminal) + - Implement automatic emoji stripping for non-emoji terminals in Labcommitr UI + - Always store Unicode emojis in Git commits regardless of terminal support + - Update commit, preview, and revert commands to adapt display based on terminal capabilities + - Ensure GitHub and emoji-capable terminals always show emojis correctly + - Improve user experience by cleaning up broken emoji symbols on non-emoji terminals + +### Patch Changes + +- 48bd866: fix: include emoji placeholder in generated config template + - Add {emoji} placeholder to template in buildConfig function + - Generated configs now include {emoji} in format.template field + - Fixes issue where emojis didn't appear in commits even when enabled + - Template now matches default config structure with emoji support + - Ensures formatCommitMessage can properly replace emoji placeholder + +- 250dcc5: fix: show actual commit message with emojis in preview + - Preview now displays the exact commit message as it will be stored in Git + - Removed emoji stripping from preview display logic + - Users can see emojis even if terminal doesn't support emoji display + - Ensures preview accurately reflects what will be committed to Git/GitHub + - Fixes issue where emojis were hidden in preview on non-emoji terminals + +## 0.3.0 + +### Minor Changes + +- 5b707eb: feat: add body requirement prompt to init command + - New prompt in init flow to set commit body as required or optional + - "Yes" option marked as recommended for better commit practices + - Configuration properly respected in commit prompts + - When body is required, commit prompts show "required" and remove "Skip" option + - Defaults to optional for backward compatibility + +- c435d38: feat: add init command alias and improve help text + - Add `i` alias for `init` command for faster access + - Update help examples to use `lab` instead of `labcommitr` consistently + - Add concise examples showing both full commands and aliases + - Add note clarifying both `lab` and `labcommitr` can be used + - Update README to document `init|i` alias + - Remove duplicate pagination text from preview and revert commands + - Improve help text clarity and consistency across all commands + +- 5b707eb: feat: add editor support for commit body input + - Users can now open their preferred editor for writing commit bodies + - Supports both inline and editor input methods + - Automatically detects available editors (VS Code, Vim, Nano, etc.) + - Improved experience for multi-line commit bodies + - Configurable editor preference in configuration files + +- 12b99b4: 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 + +- 8a8d29c: feat: add toggle functionality for body and files in preview + - Add toggle state for body and files visibility in commit detail view + - Implement `b` key to toggle body visibility on/off + - Implement `f` key to toggle files visibility on/off + - Reset toggles when viewing new commit or returning to list + - Update prompt text to indicate toggle behavior + - Fixes issue where pressing `b`/`f` caused repeated rendering + - Improves UX by allowing users to hide/show sections as needed + +- 5b707eb: feat: enhance commit command user experience + - Terminal automatically clears at command start for maximum available space + - Improved staged file detection with support for renamed and copied files + - Color-coded Git status indicators (A, M, D, R, C) matching Git's default colors + - Connector lines added to files and preview displays for better visual flow + - More accurate file status reporting with copy detection using -C50 flag + +### Patch Changes + +- 43df95c: fix: move config existence check before Clef intro animation + - Perform early validation before any UI/animation in init command + - Check for existing config immediately after project root detection + - Only show Clef intro animation if initialization will proceed + - Provides better UX by failing fast with clear error message + - Prevents unnecessary animation when config already exists + +- 5b707eb: fix: prevent label text truncation in prompts + - Increased label width from 6 to 7 characters to accommodate longer labels + - Fixes issue where "subject" label was being truncated to "subjec" + - Applied to both commit and init command prompts for consistency + - All labels now properly display full text with centered alignment + +- 4597502: fix: exclude subject line from commit body extraction + - Split commit message by first blank line to separate subject and body + - Only return content after blank line as body in preview command + - Prevents subject line from appearing in body section + - Fixes incorrect display where commit subject was shown as part of body + ## 0.1.0 ### Minor Changes diff --git a/README.md b/README.md index f8b6e6f..4ec55e1 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,13 @@ After installation, use either `labcommitr` or `lab` to run commands. ## Quick Start 1. **Initialize configuration** in your project: + ```bash lab init ``` 2. **Create your first commit**: + ```bash lab commit ``` @@ -52,12 +54,14 @@ After installation, use either `labcommitr` or `lab` to run commands. Create a standardized commit following your project's configuration. **Usage:** + ```bash lab commit [options] lab c [options] # Short alias ``` **Options:** + - `-t, --type ` - Commit type (e.g., `feat`, `fix`, `docs`) - `-s, --scope ` - Commit scope (e.g., `api`, `auth`, `ui`) - `-m, --message ` - Commit subject/message @@ -65,6 +69,7 @@ lab c [options] # Short alias - `--no-verify` - Bypass Git hooks **Examples:** + ```bash # Interactive commit (prompts for missing fields) lab commit @@ -80,6 +85,7 @@ lab commit -t fix -m "fix bug" --no-verify ``` **Notes:** + - Messages and body with spaces must be quoted - If all required fields are provided via flags, the commit is created immediately - If any required fields are missing, an interactive prompt guides you through completion @@ -92,16 +98,19 @@ lab commit -t fix -m "fix bug" --no-verify Initialize Labcommitr configuration in your project. This creates a `.labcommitr.config.yaml` file with your chosen preset and preferences. **Usage:** + ```bash lab init [options] lab i [options] # Short alias ``` **Options:** + - `-f, --force` - Overwrite existing configuration file - `--preset ` - Use a specific preset without prompts (options: `conventional`, `gitmoji`, `angular`, `minimal`) **Examples:** + ```bash # Interactive setup (recommended) lab init @@ -114,6 +123,7 @@ lab init --preset conventional ``` **What it does:** + - Guides you through selecting a commit convention preset - Configures emoji support based on terminal capabilities - Sets up auto-staging preferences @@ -121,6 +131,7 @@ lab init --preset conventional - Validates the generated configuration **Presets available:** + - **Conventional Commits** - Popular across open-source and personal projects - **Angular Convention** - Strict format used by Angular and enterprise teams (includes `perf`, `build`, `ci` types) - **Gitmoji** - Emoji-based commits for visual clarity @@ -133,6 +144,7 @@ lab init --preset conventional Manage and inspect Labcommitr configuration. **Usage:** + ```bash lab config ``` @@ -144,14 +156,17 @@ lab config Display the currently loaded configuration with source information. **Usage:** + ```bash lab config show [options] ``` **Options:** + - `-p, --path ` - Start configuration search from a specific directory **Examples:** + ```bash # Show current configuration lab config show @@ -161,6 +176,7 @@ lab config show --path /path/to/project ``` **What it shows:** + - Configuration source (file path or "defaults") - Emoji mode status - Full configuration in JSON format @@ -173,15 +189,18 @@ lab config show --path /path/to/project Browse and inspect commit history interactively without modifying your repository. **Usage:** + ```bash lab preview [options] ``` **Options:** + - `-l, --limit ` - Maximum commits to fetch (default: 50, max: 100) - `-b, --branch ` - Branch to preview (default: current branch) **Examples:** + ```bash # Browse commits on current branch lab preview @@ -194,6 +213,7 @@ lab preview --limit 25 ``` **Interactive Features:** + - **Commit List View:** - Navigate through commits with pagination (10 per page) - Press `0-9` to view details of a specific commit @@ -211,6 +231,7 @@ lab preview --limit 25 - Press `?` for help **Notes:** + - Read-only operation - does not modify your repository - Fetches commits in batches of 50 (up to 100 total) - Works on current branch by default @@ -223,11 +244,13 @@ lab preview --limit 25 Revert a commit using the project's commit workflow. Select a commit interactively and create a revert commit following your project's commit message format. **Usage:** + ```bash lab revert [options] ``` **Options:** + - `-l, --limit ` - Maximum commits to fetch (default: 50, max: 100) - `-b, --branch ` - Branch to revert from (default: current branch) - `--no-edit` - Skip commit message editing (use Git's default revert message) @@ -235,6 +258,7 @@ lab revert [options] - `--abort` - Abort revert in progress **Examples:** + ```bash # Interactive revert (uses commit workflow) lab revert @@ -253,6 +277,7 @@ lab revert --abort ``` **Interactive Features:** + - **Commit Selection:** - Browse commits with pagination (10 per page) - Press `0-9` to select a commit to revert @@ -268,6 +293,7 @@ lab revert --abort - Handles conflicts with `--continue` and `--abort` options **Notes:** + - Requires `.labcommitr.config.yaml` (unless using `--no-edit`) - Creates a new commit that undoes the selected commit - For merge commits, you'll be prompted to select which parent to revert to @@ -288,9 +314,10 @@ Labcommitr uses a `.labcommitr.config.yaml` file in your project root. The confi See [`docs/CONFIG_SCHEMA.md`](docs/CONFIG_SCHEMA.md) for complete configuration documentation. **Configuration discovery:** + - Searches from current directory up to project root -- Falls back to global configuration if available - Uses sensible defaults if no configuration found +- Global configuration support is planned for future releases (see [Planned Features](#planned-features)) --- @@ -322,12 +349,14 @@ pnpm run dev:cli test clean ``` **Alternative:** You can also use `node dist/index-dev.js` instead of `pnpm run dev:cli`: + ```bash node dist/index-dev.js test setup node dist/index-dev.js test shell ``` **Available Scenarios:** + - `existing-project` - Test adding Labcommitr to existing project - `with-changes` - Test commit command with various file states (default) - `with-history` - Test preview and revert with rich history @@ -335,6 +364,7 @@ node dist/index-dev.js test shell - `with-conflicts` - Test conflict resolution workflows **Examples:** + ```bash # Set up specific scenario pnpm run dev:cli test setup --scenario with-history @@ -374,10 +404,6 @@ Before implementing any changes, please follow this process: - Follow the project's development guidelines - Ensure your commits follow the project's commit message format (you can set up using `lab init`) -### Development Guidelines - -For detailed development guidelines, coding standards, and architecture information, see [`docs/DEVELOPMENT_GUIDELINES.md`](docs/DEVELOPMENT_GUIDELINES.md). - ### Questions? If you have questions or need clarification, feel free to open a discussion or issue. @@ -386,4 +412,27 @@ If you have questions or need clarification, feel free to open a discussion or i ## Planned Features -_No planned features at this time. Check back later or open an issue to suggest new features!_ +### Global Configuration + +Support for user-level global configuration files to enable consistent commit conventions across multiple projects. This will allow you to: + +- Set default commit types and preferences in a single location +- Apply your preferred commit conventions to all projects automatically +- Override global settings on a per-project basis when needed + +**Use cases:** + +- Developers working across multiple repositories who want consistent commit message formats +- Teams that want to standardize commit conventions organization-wide +- Personal projects where you want the same commit types everywhere + +The global configuration will be stored in OS-specific locations: + +- **macOS/Linux**: `~/.labcommitr.config.yaml` or XDG config directory +- **Windows**: `%USERPROFILE%\.labcommitr.config.yaml` or AppData directory + +Project-specific configurations will always take precedence over global settings. + +--- + +_Have a feature idea? Open an issue to suggest new features!_ diff --git a/TESTING.md b/TESTING.md index 67b1bfb..db40801 100644 --- a/TESTING.md +++ b/TESTING.md @@ -23,6 +23,7 @@ exit ``` **Alternative:** You can also use `node dist/index-dev.js` instead of `pnpm run dev:cli`: + ```bash node dist/index-dev.js test setup node dist/index-dev.js test shell @@ -46,6 +47,7 @@ node dist/index-dev.js test shell The testing environment provides isolated git repositories with predefined states (scenarios) for testing Labcommitr commands. Each scenario represents a different git repository state that you can use to test various commands and workflows. **Key Features:** + - ✅ Simple command interface - ✅ Multiple scenarios for different testing needs - ✅ Real-world git states (no artificial staging) @@ -53,6 +55,7 @@ The testing environment provides isolated git repositories with predefined state - ✅ Safe and isolated (doesn't affect your real repository) **Sandbox Location:** + - Single sandbox: `.sandbox/test/` - Predictable location (easy to find) - Git-ignored (won't be committed) @@ -68,17 +71,20 @@ Scenarios represent different git repository states. Each scenario is designed t **Purpose:** Test adding Labcommitr to an existing project **State:** + - Pre-existing commit history (20-30 commits) - Uncommitted changes (modified, added, deleted, renamed files) - Changes are **not staged** (natural git state) - **No** `.labcommitr.config.yaml` file **Use Cases:** + - Test `lab init` on existing project - Test first commit after adding Labcommitr - Test config creation workflow **Setup:** + ```bash pnpm run dev:cli test setup --scenario existing-project ``` @@ -90,18 +96,21 @@ pnpm run dev:cli test setup --scenario existing-project **Purpose:** Test commit command with various file states **State:** + - Pre-existing commit history (20-30 commits) - Uncommitted changes (modified, added, deleted, renamed files) - Changes are **not staged** (natural git state) - `.labcommitr.config.yaml` file present **Use Cases:** + - Test `lab commit` with various file states - Test auto-stage behavior (if enabled in config) - Test commit message prompts - Test validation rules **Setup:** + ```bash pnpm run dev:cli test setup --scenario with-changes ``` @@ -115,6 +124,7 @@ pnpm run dev:cli test setup --scenario with-changes **Purpose:** Test preview and revert commands with rich history **State:** + - Extensive commit history (100+ commits) - Varied commit messages (feat, fix, docs, refactor, etc.) - Commits with and without bodies @@ -123,6 +133,7 @@ pnpm run dev:cli test setup --scenario with-changes - Clean working directory **Use Cases:** + - Test `lab preview` pagination - Test `lab preview` detail view - Test `lab preview` navigation @@ -130,6 +141,7 @@ pnpm run dev:cli test setup --scenario with-changes - Test `lab revert` workflow **Setup:** + ```bash pnpm run dev:cli test setup --scenario with-history ``` @@ -141,6 +153,7 @@ pnpm run dev:cli test setup --scenario with-history **Purpose:** Test revert with merge commits **State:** + - Git repository with merge commits - Multiple branches merged into main - Merge commits with multiple parents @@ -149,11 +162,13 @@ pnpm run dev:cli test setup --scenario with-history - Clean working directory **Use Cases:** + - Test `lab revert` with merge commits - Test parent selection for merge commits - Test merge commit handling **Setup:** + ```bash pnpm run dev:cli test setup --scenario with-merge ``` @@ -165,6 +180,7 @@ pnpm run dev:cli test setup --scenario with-merge **Purpose:** Test conflict resolution workflows **State:** + - Git repository in conflict state - Unmerged files (conflict markers present) - Revert operation in progress (optional) @@ -172,11 +188,13 @@ pnpm run dev:cli test setup --scenario with-merge - Conflict state ready for resolution **Use Cases:** + - Test `lab revert --continue` after conflict resolution - Test `lab revert --abort` to cancel revert - Test conflict resolution workflow **Setup:** + ```bash pnpm run dev:cli test setup --scenario with-conflicts ``` @@ -192,9 +210,11 @@ Set up test environment with specified scenario. **Note:** This command is only available in development. Use `pnpm run dev:cli test setup` or `node dist/index-dev.js test setup`. **Options:** + - `-s, --scenario ` - Scenario name (default: `with-changes`) **Examples:** + ```bash # Set up default scenario (with-changes) pnpm run dev:cli test setup @@ -206,6 +226,7 @@ pnpm run dev:cli test setup --scenario with-merge ``` **What it does:** + - Builds project if needed - Creates/updates sandbox in `.sandbox/test/` - Generates scenario with appropriate git state @@ -221,11 +242,13 @@ Open interactive shell in test environment. **Note:** This command is only available in development. Use `pnpm run dev:cli test shell` or `node dist/index-dev.js test shell`. **Examples:** + ```bash pnpm run dev:cli test shell ``` **What it does:** + - Opens shell in test environment directory - Changes working directory to sandbox - You can run commands normally (`lab commit`, `lab preview`, etc.) @@ -242,11 +265,13 @@ Reset current scenario to initial state. **Note:** This command is only available in development. Use `pnpm run dev:cli test reset` or `node dist/index-dev.js test reset`. **Examples:** + ```bash pnpm run dev:cli test reset ``` **What it does:** + - Resets current scenario to initial state - Keeps same scenario active - Fast reset (preserves repo structure) @@ -261,11 +286,13 @@ Remove test environment completely. **Note:** This command is only available in development. Use `pnpm run dev:cli test clean` or `node dist/index-dev.js test clean`. **Examples:** + ```bash pnpm run dev:cli test clean ``` **What it does:** + - Removes sandbox directory - Cleans up all test artifacts - Returns to clean state @@ -279,11 +306,13 @@ Show current test environment status. **Note:** This command is only available in development. Use `pnpm run dev:cli test status` or `node dist/index-dev.js test status`. **Examples:** + ```bash pnpm run dev:cli test status ``` **What it shows:** + - Current scenario name - Scenario description - Sandbox location @@ -299,11 +328,13 @@ List all available scenarios. **Note:** This command is only available in development. Use `pnpm run dev:cli test list-scenarios` or `node dist/index-dev.js test list-scenarios`. **Examples:** + ```bash pnpm run dev:cli test list-scenarios ``` **What it shows:** + - All available scenarios - Description for each scenario - Use cases for each scenario @@ -452,6 +483,7 @@ lab revert --continue **Problem:** You're trying to use test commands but no environment is set up. **Solution:** + ```bash pnpm run dev:cli test setup ``` @@ -463,6 +495,7 @@ pnpm run dev:cli test setup **Problem:** You specified a scenario name that doesn't exist. **Solution:** + ```bash # List available scenarios pnpm run dev:cli test list-scenarios @@ -495,6 +528,7 @@ pnpm run dev:cli test setup **Problem:** Can't find sandbox or want to access it directly. **Solution:** + - Sandbox is always at: `.sandbox/test/` - Use `pnpm run dev:cli test shell` to enter it - Or navigate manually: `cd .sandbox/test/` @@ -506,6 +540,7 @@ pnpm run dev:cli test setup **Problem:** Reset doesn't restore scenario properly. **Solution:** + ```bash # Clean and recreate pnpm run dev:cli test clean @@ -528,6 +563,7 @@ pnpm run dev:cli test setup --scenario ## Safety The test environment is **100% safe**: + - ✅ Isolated from your real repository - ✅ No remote configured (can't push) - ✅ Easy cleanup (`lab test clean`) @@ -537,4 +573,3 @@ The test environment is **100% safe**: **Last Updated:** January 2025 **Sandbox Location:** `.sandbox/test/` - diff --git a/package.json b/package.json index f1e0120..d6387bd 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,19 @@ { "name": "@labcatr/labcommitr", - "version": "0.1.0", + "version": "0.4.0", "description": "Labcommitr is a solution for building standardized git commits, hassle-free!", "main": "dist/index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "build": "npx tsc", + "build:prod": "npx tsc && pnpm run clean:maps", + "clean:maps": "node scripts/clean-maps.js", "format": "pnpm run format:code", "format:ci": "pnpm run format:code", "format:code": "prettier -w \"**/*\" --ignore-unknown --cache", "version": "changeset version && pnpm install --no-frozen-lockfile && pnpm run format", - "dev:cli": "node dist/index-dev.js" + "dev:cli": "node dist/index-dev.js", + "prepublishOnly": "pnpm run build:prod" }, "type": "module", "bin": { @@ -29,6 +32,9 @@ "homepage": "https://github.com/labcatr/labcommitr#readme", "author": "Trevor Fox", "license": "ISC", + "engines": { + "node": ">=18.0.0" + }, "dependencies": { "@changesets/cli": "^2.29.7", "@clack/prompts": "^0.11.0", @@ -56,7 +62,10 @@ "dist/cli/commands/revert", "dist/cli/commands/shared", "dist/cli/utils", - "dist/lib" + "dist/lib", + "README.md", + "CHANGELOG.md", + "TESTING.md" ], "publishConfig": { "access": "public" diff --git a/scripts/clean-maps.js b/scripts/clean-maps.js new file mode 100644 index 0000000..3c6bf08 --- /dev/null +++ b/scripts/clean-maps.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +/** + * Remove all .map files from dist directory + * Cross-platform solution for cleaning source maps before publishing + */ + +import { readdir, stat, unlink } from "fs/promises"; +import { join } from "path"; + +async function removeMaps(dir) { + try { + const entries = await readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + await removeMaps(fullPath); + } else if (entry.isFile() && entry.name.endsWith(".map")) { + await unlink(fullPath); + console.log(`Removed: ${fullPath}`); + } + } + } catch (error) { + if (error.code !== "ENOENT") { + throw error; + } + } +} + +const distDir = join(process.cwd(), "dist"); +removeMaps(distDir).catch((error) => { + console.error("Error removing source maps:", error); + process.exit(1); +}); diff --git a/src/cli/commands/commit/editor.ts b/src/cli/commands/commit/editor.ts index 37a4995..c75127c 100644 --- a/src/cli/commands/commit/editor.ts +++ b/src/cli/commands/commit/editor.ts @@ -11,32 +11,75 @@ import { unlinkSync, mkdtempSync, rmdirSync, + accessSync, + constants, } from "fs"; import { join, dirname } from "path"; -import { tmpdir } from "os"; +import { tmpdir, platform } from "os"; import { Logger } from "../../../lib/logger.js"; +/** + * Cross-platform command resolver + * On Windows: uses 'where' command + * On Unix: uses 'which' command + * + * @param command - Command name to find + * @returns Full path to command or null if not found + */ +function findCommand(command: string): string | null { + const isWindows = platform() === "win32"; + const findCommand = isWindows ? "where" : "which"; + + try { + const result = spawnSync(findCommand, [command], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }); + + if (result.status === 0 && result.stdout) { + // On Windows, 'where' may return multiple paths, take the first one + const path = result.stdout.trim().split("\n")[0].trim(); + return path || null; + } + } catch { + // Command not found or error occurred + } + + return null; +} + /** * Detect available editor in priority order: nvim → vim → vi * Also checks $EDITOR and $VISUAL environment variables + * Cross-platform: works on Windows, macOS, and Linux */ export function detectEditor(): string | null { // Check environment variables first (user preference) const envEditor = process.env.EDITOR || process.env.VISUAL; if (envEditor) { - // Verify the editor exists - const check = spawnSync("which", [envEditor], { encoding: "utf-8" }); - if (check.status === 0) { - return envEditor.trim(); + // If it's already a full path, verify it exists + if (envEditor.includes("/") || envEditor.includes("\\")) { + try { + accessSync(envEditor, constants.F_OK); + return envEditor.trim(); + } catch { + // Path doesn't exist, try to find it as a command + } + } + + // Try to find it as a command in PATH + const found = findCommand(envEditor); + if (found) { + return found; } } // Try nvim, vim, vi in order const editors = ["nvim", "vim", "vi"]; for (const editor of editors) { - const check = spawnSync("which", [editor], { encoding: "utf-8" }); - if (check.status === 0) { - return editor; + const found = findCommand(editor); + if (found) { + return found; } } @@ -58,9 +101,11 @@ export function editInEditor( if (!editorCommand) { Logger.error("No editor found"); + const isWindows = platform() === "win32"; + const envVar = isWindows ? "%EDITOR%" : "$EDITOR"; console.error("\n No editor available (nvim, vim, or vi)"); console.error( - " Set $EDITOR environment variable to your preferred editor\n", + ` Set ${envVar} environment variable to your preferred editor\n`, ); return null; } diff --git a/src/cli/commands/commit/index.ts b/src/cli/commands/commit/index.ts index cf83521..9c6a12e 100644 --- a/src/cli/commands/commit/index.ts +++ b/src/cli/commands/commit/index.ts @@ -14,6 +14,7 @@ import { loadConfig, ConfigError } from "../../../lib/config/index.js"; import type { LabcommitrConfig } from "../../../lib/config/types.js"; import { Logger } from "../../../lib/logger.js"; +import { formatForDisplay } from "../../../lib/util/emoji.js"; import { isGitRepository } from "./git.js"; import { stageAllTrackedFiles, @@ -193,6 +194,7 @@ export async function commitAction(options: { } const config = configResult.config; + const emojiModeActive = configResult.emojiModeActive; // Step 2: Verify git repository if (!isGitRepository()) { @@ -353,7 +355,11 @@ export async function commitAction(options: { ); console.log(`${success("✓")} Commit created successfully!`); - console.log(` ${commitHash} ${formattedMessage}`); + const displayMessage = formatForDisplay( + formattedMessage, + emojiModeActive, + ); + console.log(` ${commitHash} ${displayMessage}`); } catch (error: unknown) { // Cleanup on failure await cleanup({ @@ -441,7 +447,12 @@ export async function commitAction(options: { ); // Show preview and get user action - action = await displayPreview(formattedMessage, body, config); + action = await displayPreview( + formattedMessage, + body, + config, + emojiModeActive, + ); // Handle edit actions if (action === "edit-type") { @@ -449,9 +460,8 @@ export async function commitAction(options: { 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, - ); + 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); @@ -509,7 +519,11 @@ export async function commitAction(options: { ); console.log(`${success("✓")} Commit created successfully!`); - console.log(` ${commitHash} ${formattedMessage}`); + const displayMessage = formatForDisplay( + formattedMessage, + emojiModeActive, + ); + console.log(` ${commitHash} ${displayMessage}`); } catch (error: unknown) { // Cleanup on failure await cleanup({ diff --git a/src/cli/commands/commit/prompts.ts b/src/cli/commands/commit/prompts.ts index 632be08..68e08b5 100644 --- a/src/cli/commands/commit/prompts.ts +++ b/src/cli/commands/commit/prompts.ts @@ -124,7 +124,10 @@ export async function promptType( { message: `${label("type", "magenta")} ${textColors.pureWhite("Select commit type:")}`, options, - initialValue: initialIndex !== undefined && initialIndex >= 0 ? config.types[initialIndex].id : undefined, + initialValue: + initialIndex !== undefined && initialIndex >= 0 + ? config.types[initialIndex].id + : undefined, }, shortcutMapping, ); @@ -157,8 +160,12 @@ 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`); + 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)) { @@ -194,7 +201,10 @@ export async function promptScope( `Enter scope ${isRequired ? "(required for '" + selectedType + "')" : "(optional)"}:`, )}`, options, - initialValue: initialIndex !== undefined && initialIndex >= 0 ? allowedScopes[initialIndex] : initialScope || undefined, + initialValue: + initialIndex !== undefined && initialIndex >= 0 + ? allowedScopes[initialIndex] + : initialScope || undefined, }); handleCancel(selected); @@ -300,7 +310,9 @@ export async function promptSubject( console.error(` ${error.context}`); } } - console.error(`\n Fix: Correct the subject and try again, or run 'lab commit' interactively\n`); + console.error( + `\n Fix: Correct the subject and try again, or run 'lab commit' interactively\n`, + ); process.exit(1); } return providedMessage; @@ -483,7 +495,11 @@ export async function promptBody( const shortcut = shortcutMapping ? getShortcutForValue(option.value, shortcutMapping) : undefined; - const label = formatLabelWithShortcut(option.label, shortcut, displayHints); + const label = formatLabelWithShortcut( + option.label, + shortcut, + displayHints, + ); return { value: option.value, @@ -562,7 +578,11 @@ export async function promptBody( const shortcut = shortcutMapping ? getShortcutForValue(option.value, shortcutMapping) : undefined; - const label = formatLabelWithShortcut(option.label, shortcut, displayHints); + const label = formatLabelWithShortcut( + option.label, + shortcut, + displayHints, + ); return { value: option.value, @@ -583,7 +603,10 @@ export async function promptBody( handleCancel(inputMethod); if (inputMethod === "editor") { - const editorBody = await promptBodyWithEditor(config, typeof body === "string" ? body : initialBody || ""); + const editorBody = await promptBodyWithEditor( + config, + typeof body === "string" ? body : initialBody || "", + ); if (editorBody !== null && editorBody !== undefined) { body = editorBody; } else { @@ -681,7 +704,11 @@ async function promptBodyRequiredWithEditor( const shortcut = shortcutMapping ? getShortcutForValue(option.value, shortcutMapping) : undefined; - const label = formatLabelWithShortcut(option.label, shortcut, displayHints); + const label = formatLabelWithShortcut( + option.label, + shortcut, + displayHints, + ); return { value: option.value, @@ -789,7 +816,11 @@ async function promptBodyWithEditor( const shortcut = shortcutMapping ? getShortcutForValue(option.value, shortcutMapping) : undefined; - const label = formatLabelWithShortcut(option.label, shortcut, displayHints); + const label = formatLabelWithShortcut( + option.label, + shortcut, + displayHints, + ); return { value: option.value, @@ -1045,7 +1076,21 @@ export async function displayPreview( formattedMessage: string, body: string | undefined, config?: LabcommitrConfig, -): Promise<"commit" | "edit-type" | "edit-scope" | "edit-subject" | "edit-body" | "cancel"> { + emojiModeActive: boolean = true, +): Promise< + | "commit" + | "edit-type" + | "edit-scope" + | "edit-subject" + | "edit-body" + | "cancel" +> { + // Preview shows the actual commit message as it will be stored in Git + // We don't strip emojis here because the user needs to see what will be committed + // even if their terminal doesn't support emoji display + const displayMessage = formattedMessage; + const displayBody = body; + // Start connector line using @clack/prompts log.info( `${label("preview", "green")} ${textColors.pureWhite("Commit message preview:")}`, @@ -1054,11 +1099,11 @@ export async function displayPreview( // Render content with connector lines // Empty line after header console.log(renderWithConnector("")); - console.log(renderWithConnector(textColors.brightCyan(formattedMessage))); + console.log(renderWithConnector(textColors.brightCyan(displayMessage))); - if (body) { + if (displayBody) { console.log(renderWithConnector("")); - const bodyLines = body.split("\n"); + const bodyLines = displayBody.split("\n"); for (const line of bodyLines) { console.log(renderWithConnector(textColors.white(line))); } @@ -1081,11 +1126,7 @@ export async function displayPreview( ]; const shortcutMapping = config - ? processShortcuts( - config.advanced.shortcuts, - "preview", - previewOptions, - ) + ? processShortcuts(config.advanced.shortcuts, "preview", previewOptions) : null; const displayHints = config?.advanced.shortcuts?.display_hints ?? true; @@ -1112,5 +1153,11 @@ export async function displayPreview( ); handleCancel(action); - return action as "commit" | "edit-type" | "edit-scope" | "edit-subject" | "edit-body" | "cancel"; + return action as + | "commit" + | "edit-type" + | "edit-scope" + | "edit-subject" + | "edit-body" + | "cancel"; } diff --git a/src/cli/commands/preview/index.ts b/src/cli/commands/preview/index.ts index 9c74ea7..a4db6d0 100644 --- a/src/cli/commands/preview/index.ts +++ b/src/cli/commands/preview/index.ts @@ -6,6 +6,7 @@ import { Command } from "commander"; import { Logger } from "../../../lib/logger.js"; +import { detectEmojiSupport } from "../../../lib/util/emoji.js"; import { isGitRepository, getCurrentBranch, @@ -72,10 +73,13 @@ async function previewAction(options: { const remaining = maxCommits - totalFetched; const toFetch = Math.min(remaining, 50); - + // Get the last commit hash we've already fetched to exclude it from next fetch - const lastHash = allCommits.length > 0 ? allCommits[allCommits.length - 1].hash : undefined; - + const lastHash = + allCommits.length > 0 + ? allCommits[allCommits.length - 1].hash + : undefined; + const newCommits = fetchCommits(toFetch, branch, lastHash); allCommits = [...allCommits, ...newCommits]; totalFetched = allCommits.length; @@ -90,6 +94,9 @@ async function previewAction(options: { process.exit(0); } + // Detect emoji support for display + const emojiModeActive = detectEmojiSupport(); + // Main loop let exit = false; let viewingDetails = false; @@ -102,7 +109,12 @@ async function previewAction(options: { if (viewingDetails && currentDetailCommit) { // Detail view - displayCommitDetails(currentDetailCommit, showBody, showFiles); + displayCommitDetails( + currentDetailCommit, + showBody, + showFiles, + emojiModeActive, + ); console.log( ` ${textColors.white("Press")} ${textColors.brightYellow("b")} ${textColors.white("to toggle body,")} ${textColors.brightYellow("f")} ${textColors.white("to toggle files,")} ${textColors.brightYellow("d")} ${textColors.white("for diff,")} ${textColors.brightYellow("r")} ${textColors.white("to revert,")} ${textColors.brightYellow("←")} ${textColors.white("to go back")}`, ); @@ -131,9 +143,7 @@ async function previewAction(options: { ); const diff = getCommitDiff(currentDetailCommit.hash); console.log(diff); - console.log( - `\n${textColors.white("Press any key to go back...")}`, - ); + console.log(`\n${textColors.white("Press any key to go back...")}`); await new Promise((resolve) => { process.stdin.setRawMode(true); process.stdin.resume(); @@ -176,12 +186,25 @@ async function previewAction(options: { const maxIndex = pageCommits.length - 1; // Check if there are more pages to show (either already loaded or can be fetched) - const hasMorePages = (currentPage + 1) * pageSize < allCommits.length || hasMore; + const hasMorePages = + (currentPage + 1) * pageSize < allCommits.length || hasMore; const hasPreviousPage = currentPage > 0; - displayCommitList(pageCommits, startIndex, totalFetched, hasMore, hasPreviousPage, hasMorePages); + displayCommitList( + pageCommits, + startIndex, + totalFetched, + hasMore, + hasPreviousPage, + hasMorePages, + emojiModeActive, + ); - const action = await waitForListAction(maxIndex, hasMorePages, hasPreviousPage); + const action = await waitForListAction( + maxIndex, + hasMorePages, + hasPreviousPage, + ); if (typeof action === "number") { // View commit details @@ -214,7 +237,7 @@ async function previewAction(options: { } else if (action === "next") { // Move to next page const nextPageStart = (currentPage + 1) * pageSize; - + // If we need more commits and they're available, load them if (nextPageStart >= allCommits.length && hasMore) { console.log("\n Loading next batch..."); @@ -226,7 +249,7 @@ async function previewAction(options: { continue; } } - + // Increment page if we have commits to show if (nextPageStart < allCommits.length) { currentPage++; @@ -263,7 +286,13 @@ async function previewAction(options: { */ export const previewCommand = new Command("preview") .description("Browse and inspect commit history") - .option("-l, --limit ", "Maximum commits to fetch (default: 50, max: 100)", "50") - .option("-b, --branch ", "Branch to preview (default: current branch)") + .option( + "-l, --limit ", + "Maximum commits to fetch (default: 50, max: 100)", + "50", + ) + .option( + "-b, --branch ", + "Branch to preview (default: current branch)", + ) .action(previewAction); - diff --git a/src/cli/commands/preview/prompts.ts b/src/cli/commands/preview/prompts.ts index 651a91d..cd6180e 100644 --- a/src/cli/commands/preview/prompts.ts +++ b/src/cli/commands/preview/prompts.ts @@ -6,6 +6,7 @@ import { select, isCancel } from "@clack/prompts"; import { labelColors, textColors } from "../init/colors.js"; +import { formatForDisplay } from "../../../lib/util/emoji.js"; import type { CommitInfo } from "../shared/types.js"; import { getCommitDetails, getCommitDiff } from "../shared/git-operations.js"; import readline from "readline"; @@ -46,6 +47,7 @@ export function displayCommitList( hasMore: boolean, hasPreviousPage: boolean = false, hasMorePages: boolean = false, + emojiModeActive: boolean = true, ): void { console.log(); console.log( @@ -64,10 +66,11 @@ export function displayCommitList( const commit = commits[i]; const number = i.toString(); const mergeIndicator = commit.isMerge ? " [Merge]" : ""; + const displaySubject = formatForDisplay(commit.subject, emojiModeActive); const truncatedSubject = - commit.subject.length > 50 - ? commit.subject.substring(0, 47) + "..." - : commit.subject; + displaySubject.length > 50 + ? displaySubject.substring(0, 47) + "..." + : displaySubject; console.log( ` ${textColors.brightCyan(`[${number}]`)} ${textColors.brightWhite(commit.shortHash)} ${truncatedSubject}${mergeIndicator}`, @@ -80,7 +83,7 @@ export function displayCommitList( // Pagination info const endIndex = startIndex + displayCount; console.log(); - + if (hasMore) { console.log( ` Showing commits ${startIndex + 1}-${endIndex} of ${totalFetched}+`, @@ -91,22 +94,30 @@ export function displayCommitList( ); } console.log(); - + // Build navigation hints const navHints: string[] = []; - navHints.push(`${textColors.brightCyan("0-9")} ${textColors.white("to view details")}`); + navHints.push( + `${textColors.brightCyan("0-9")} ${textColors.white("to view details")}`, + ); if (hasPreviousPage) { - navHints.push(`${textColors.brightYellow("p")} ${textColors.white("for previous batch")}`); + navHints.push( + `${textColors.brightYellow("p")} ${textColors.white("for previous batch")}`, + ); } if (hasMorePages) { - navHints.push(`${textColors.brightYellow("n")} ${textColors.white("for next batch")}`); + navHints.push( + `${textColors.brightYellow("n")} ${textColors.white("for next batch")}`, + ); } - navHints.push(`${textColors.brightYellow("?")} ${textColors.white("for help")}`); - navHints.push(`${textColors.brightYellow("Esc")} ${textColors.white("to exit")}`); - - console.log( - ` ${textColors.white("Press")} ${navHints.join(`, `)}`, + navHints.push( + `${textColors.brightYellow("?")} ${textColors.white("for help")}`, + ); + navHints.push( + `${textColors.brightYellow("Esc")} ${textColors.white("to exit")}`, ); + + console.log(` ${textColors.white("Press")} ${navHints.join(`, `)}`); } /** @@ -116,6 +127,7 @@ export function displayCommitDetails( commit: CommitInfo, showBody: boolean = true, showFiles: boolean = true, + emojiModeActive: boolean = true, ): void { console.log(); console.log( @@ -123,11 +135,16 @@ export function displayCommitDetails( ); console.log(); console.log(` ${textColors.brightWhite("Hash:")} ${commit.hash}`); - console.log(` ${textColors.brightWhite("Subject:")} ${commit.subject}`); + const displaySubject = formatForDisplay(commit.subject, emojiModeActive); + console.log(` ${textColors.brightWhite("Subject:")} ${displaySubject}`); console.log(); - console.log(` ${textColors.brightWhite("Author:")} ${commit.author.name} <${commit.author.email}>`); + console.log( + ` ${textColors.brightWhite("Author:")} ${commit.author.name} <${commit.author.email}>`, + ); console.log(` ${textColors.brightWhite("Date:")} ${commit.date.absolute}`); - console.log(` ${textColors.brightWhite("Relative:")} ${commit.date.relative}`); + console.log( + ` ${textColors.brightWhite("Relative:")} ${commit.date.relative}`, + ); console.log(); if (commit.parents.length > 0) { @@ -147,10 +164,14 @@ export function displayCommitDetails( console.log(` ${textColors.brightWhite("File Statistics:")}`); console.log(` Files changed: ${commit.fileStats.filesChanged}`); if (commit.fileStats.additions !== undefined) { - console.log(` Additions: ${textColors.gitAdded(`+${commit.fileStats.additions}`)}`); + console.log( + ` Additions: ${textColors.gitAdded(`+${commit.fileStats.additions}`)}`, + ); } if (commit.fileStats.deletions !== undefined) { - console.log(` Deletions: ${textColors.gitDeleted(`-${commit.fileStats.deletions}`)}`); + console.log( + ` Deletions: ${textColors.gitDeleted(`-${commit.fileStats.deletions}`)}`, + ); } console.log(); } @@ -158,7 +179,8 @@ export function displayCommitDetails( if (showBody) { if (commit.body) { console.log(` ${textColors.brightWhite("Body:")}`); - const bodyLines = commit.body.split("\n"); + const displayBody = formatForDisplay(commit.body, emojiModeActive); + const bodyLines = displayBody.split("\n"); bodyLines.forEach((line) => { console.log(` ${line}`); }); @@ -362,4 +384,3 @@ export async function waitForListAction( stdin.on("keypress", onKeypress); }); } - diff --git a/src/cli/commands/preview/types.ts b/src/cli/commands/preview/types.ts index 7104bed..87e7aa9 100644 --- a/src/cli/commands/preview/types.ts +++ b/src/cli/commands/preview/types.ts @@ -11,4 +11,3 @@ export interface PreviewState { hasMore: boolean; currentIndex: number; } - diff --git a/src/cli/commands/revert/index.ts b/src/cli/commands/revert/index.ts index 9a136f7..78976ed 100644 --- a/src/cli/commands/revert/index.ts +++ b/src/cli/commands/revert/index.ts @@ -7,6 +7,10 @@ import { Command } from "commander"; import { Logger } from "../../../lib/logger.js"; import { loadConfig } from "../../../lib/config/index.js"; +import { + detectEmojiSupport, + formatForDisplay, +} from "../../../lib/util/emoji.js"; import { isGitRepository, getCurrentBranch, @@ -16,7 +20,10 @@ import { getMergeParents, hasUncommittedChanges, } from "../shared/git-operations.js"; -import { parseCommitMessage, generateRevertSubject } from "../shared/commit-parser.js"; +import { + parseCommitMessage, + generateRevertSubject, +} from "../shared/commit-parser.js"; import type { CommitInfo } from "../shared/types.js"; import { displayRevertCommitList, @@ -130,6 +137,7 @@ export async function revertCommit( } const config = configResult.config; + const emojiModeActive = configResult.emojiModeActive; // Get commit details const commit = getCommitDetails(commitHash); @@ -148,7 +156,7 @@ export async function revertCommit( // Show confirmation clearTerminal(); - displayRevertConfirmation(commit); + displayRevertConfirmation(commit, emojiModeActive); let useWorkflow = !options?.noEdit; if (useWorkflow) { @@ -220,7 +228,13 @@ export async function revertCommit( subject, ); - let action: "commit" | "edit-type" | "edit-scope" | "edit-subject" | "edit-body" | "cancel"; + let action: + | "commit" + | "edit-type" + | "edit-scope" + | "edit-subject" + | "edit-body" + | "cancel"; do { // Regenerate formatted message with current values @@ -232,13 +246,19 @@ export async function revertCommit( subject, ); - action = await displayPreview(formattedMessage, body, config); + action = await displayPreview( + formattedMessage, + body, + config, + emojiModeActive, + ); if (action === "edit-type") { const typeResult = await promptType(config, undefined, type); type = typeResult.type; emoji = typeResult.emoji; - const isScopeRequired = config.validation.require_scope_for.includes(type); + const isScopeRequired = + config.validation.require_scope_for.includes(type); if (isScopeRequired && !scope) { scope = await promptScope(config, type, undefined, scope); } @@ -281,7 +301,9 @@ export async function revertCommit( }); if (amendResult.status !== 0) { - throw new Error(`Failed to amend commit: ${amendResult.stderr?.toString() || "Unknown error"}`); + throw new Error( + `Failed to amend commit: ${amendResult.stderr?.toString() || "Unknown error"}`, + ); } // Get commit hash @@ -289,21 +311,28 @@ export async function revertCommit( encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], }); - const revertHash = hashResult.stdout?.toString().trim().substring(0, 7) || "unknown"; + const revertHash = + hashResult.stdout?.toString().trim().substring(0, 7) || "unknown"; console.log(`${success("✓")} Revert commit created successfully!`); - console.log(` ${revertHash} ${formattedMessage}`); + const displayMessage = formatForDisplay( + formattedMessage, + emojiModeActive, + ); + console.log(` ${revertHash} ${displayMessage}`); } catch (error: unknown) { // Check if it's a conflict if (error instanceof Error && error.message.includes("conflict")) { console.log(); - console.log( - `${attention("⚠ Conflicts detected during revert.")}`, - ); + console.log(`${attention("⚠ Conflicts detected during revert.")}`); console.log(); console.log(" Resolve conflicts manually, then:"); - console.log(` ${textColors.brightCyan("lab revert --continue")} - Continue after resolution`); - console.log(` ${textColors.brightCyan("lab revert --abort")} - Abort revert`); + console.log( + ` ${textColors.brightCyan("lab revert --continue")} - Continue after resolution`, + ); + console.log( + ` ${textColors.brightCyan("lab revert --abort")} - Abort revert`, + ); process.exit(1); } throw error; @@ -319,20 +348,23 @@ export async function revertCommit( encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], }); - const revertHash = hashResult.stdout?.toString().trim().substring(0, 7) || "unknown"; + const revertHash = + hashResult.stdout?.toString().trim().substring(0, 7) || "unknown"; console.log(`${success("✓")} Revert commit created successfully!`); console.log(` ${revertHash}`); } catch (error: unknown) { if (error instanceof Error && error.message.includes("conflict")) { console.log(); - console.log( - `${attention("⚠ Conflicts detected during revert.")}`, - ); + console.log(`${attention("⚠ Conflicts detected during revert.")}`); console.log(); console.log(" Resolve conflicts manually, then:"); - console.log(` ${textColors.brightCyan("lab revert --continue")} - Continue after resolution`); - console.log(` ${textColors.brightCyan("lab revert --abort")} - Abort revert`); + console.log( + ` ${textColors.brightCyan("lab revert --continue")} - Continue after resolution`, + ); + console.log( + ` ${textColors.brightCyan("lab revert --abort")} - Abort revert`, + ); process.exit(1); } throw error; @@ -376,13 +408,18 @@ async function revertAction(options: { process.exit(1); } + // Detect emoji support for display (always detect, even if no config) + const emojiModeActive = detectEmojiSupport(); + // Check for config if --no-edit is not used (config needed for commit workflow) if (!options.noEdit) { const configResult = await loadConfig(); if (configResult.source === "defaults") { Logger.error("Configuration not found"); console.error("\n Run 'lab init' to create configuration file."); - console.error(" Or use --no-edit to use Git's default revert message.\n"); + console.error( + " Or use --no-edit to use Git's default revert message.\n", + ); process.exit(1); } } @@ -390,9 +427,7 @@ async function revertAction(options: { // Check for uncommitted changes if (hasUncommittedChanges()) { console.log(); - console.log( - `${attention("⚠ You have uncommitted changes.")}`, - ); + console.log(`${attention("⚠ You have uncommitted changes.")}`); console.log(" Revert may cause conflicts."); console.log(); } @@ -404,7 +439,10 @@ async function revertAction(options: { } const branch = options.branch || currentBranch; - const maxCommits = Math.min(parseInt(options.limit?.toString() || "50", 10), 100); + const maxCommits = Math.min( + parseInt(options.limit?.toString() || "50", 10), + 100, + ); const pageSize = 10; // Initial fetch @@ -420,10 +458,13 @@ async function revertAction(options: { const remaining = maxCommits - totalFetched; const toFetch = Math.min(remaining, 50); - + // Get the last commit hash we've already fetched to exclude it from next fetch - const lastHash = allCommits.length > 0 ? allCommits[allCommits.length - 1].hash : undefined; - + const lastHash = + allCommits.length > 0 + ? allCommits[allCommits.length - 1].hash + : undefined; + const newCommits = fetchCommits(toFetch, branch, lastHash); allCommits = [...allCommits, ...newCommits]; totalFetched = allCommits.length; @@ -448,26 +489,41 @@ async function revertAction(options: { const pageCommits = allCommits.slice(startIndex, endIndex); // Check if there are more pages to show (either already loaded or can be fetched) - const hasMorePages = (currentPage + 1) * pageSize < allCommits.length || hasMore; + const hasMorePages = + (currentPage + 1) * pageSize < allCommits.length || hasMore; const hasPreviousPage = currentPage > 0; - displayRevertCommitList(pageCommits, startIndex, totalFetched, hasMore, hasPreviousPage, hasMorePages); + displayRevertCommitList( + pageCommits, + startIndex, + totalFetched, + hasMore, + hasPreviousPage, + hasMorePages, + emojiModeActive, + ); // Build navigation hints const navHints: string[] = []; - navHints.push(`${textColors.brightCyan("0-9")} ${textColors.white("to select commit")}`); + navHints.push( + `${textColors.brightCyan("0-9")} ${textColors.white("to select commit")}`, + ); if (hasPreviousPage) { - navHints.push(`${textColors.brightYellow("p")} ${textColors.white("for previous batch")}`); + navHints.push( + `${textColors.brightYellow("p")} ${textColors.white("for previous batch")}`, + ); } if (hasMorePages) { - navHints.push(`${textColors.brightYellow("n")} ${textColors.white("for next batch")}`); + navHints.push( + `${textColors.brightYellow("n")} ${textColors.white("for next batch")}`, + ); } - navHints.push(`${textColors.brightYellow("Esc")} ${textColors.white("to cancel")}`); - - console.log( - ` ${textColors.white("Press")} ${navHints.join(`, `)}`, + navHints.push( + `${textColors.brightYellow("Esc")} ${textColors.white("to cancel")}`, ); + console.log(` ${textColors.white("Press")} ${navHints.join(`, `)}`); + // Wait for input const stdin = process.stdin; const wasRaw = stdin.isRaw; @@ -480,7 +536,9 @@ async function revertAction(options: { readline.emitKeypressEvents(stdin); - const selection = await new Promise((resolve) => { + const selection = await new Promise< + number | "next" | "previous" | "cancel" + >((resolve) => { const onKeypress = (char: string, key: readline.Key) => { if (key.name === "escape" || (key.ctrl && key.name === "c")) { cleanup(); @@ -534,7 +592,7 @@ async function revertAction(options: { } else if (selection === "next") { // Move to next page const nextPageStart = (currentPage + 1) * pageSize; - + // If we need more commits and they're available, load them if (nextPageStart >= allCommits.length && hasMore) { console.log("\n Loading next batch..."); @@ -546,7 +604,7 @@ async function revertAction(options: { continue; } } - + // Increment page if we have commits to show if (nextPageStart < allCommits.length) { currentPage++; @@ -583,10 +641,16 @@ async function revertAction(options: { */ export const revertCommand = new Command("revert") .description("Revert a commit using the project's commit workflow") - .option("-l, --limit ", "Maximum commits to fetch (default: 50, max: 100)", "50") - .option("-b, --branch ", "Branch to revert from (default: current branch)") + .option( + "-l, --limit ", + "Maximum commits to fetch (default: 50, max: 100)", + "50", + ) + .option( + "-b, --branch ", + "Branch to revert from (default: current branch)", + ) .option("--no-edit", "Skip commit message editing (use Git defaults)") .option("--continue", "Continue revert after conflict resolution") .option("--abort", "Abort revert in progress") .action(revertAction); - diff --git a/src/cli/commands/revert/prompts.ts b/src/cli/commands/revert/prompts.ts index 3c3165f..5ef0b11 100644 --- a/src/cli/commands/revert/prompts.ts +++ b/src/cli/commands/revert/prompts.ts @@ -6,6 +6,7 @@ import { select, confirm, isCancel } from "@clack/prompts"; import { labelColors, textColors, success, attention } from "../init/colors.js"; +import { formatForDisplay } from "../../../lib/util/emoji.js"; import type { CommitInfo, MergeParent } from "../shared/types.js"; /** @@ -54,6 +55,7 @@ export function displayRevertCommitList( hasMore: boolean, hasPreviousPage: boolean = false, hasMorePages: boolean = false, + emojiModeActive: boolean = true, ): void { console.log(); console.log( @@ -71,10 +73,11 @@ export function displayRevertCommitList( const commit = commits[i]; const number = i.toString(); const mergeIndicator = commit.isMerge ? " [Merge]" : ""; + const displaySubject = formatForDisplay(commit.subject, emojiModeActive); const truncatedSubject = - commit.subject.length > 50 - ? commit.subject.substring(0, 47) + "..." - : commit.subject; + displaySubject.length > 50 + ? displaySubject.substring(0, 47) + "..." + : displaySubject; console.log( ` ${textColors.brightCyan(`[${number}]`)} ${textColors.brightWhite(commit.shortHash)} ${truncatedSubject}${mergeIndicator}`, @@ -87,7 +90,7 @@ export function displayRevertCommitList( // Pagination info const endIndex = startIndex + displayCount; console.log(); - + if (hasMore) { console.log( ` Showing commits ${startIndex + 1}-${endIndex} of ${totalFetched}+`, @@ -124,14 +127,20 @@ export async function promptMergeParent( /** * Display revert confirmation */ -export function displayRevertConfirmation(commit: CommitInfo): void { +export function displayRevertConfirmation( + commit: CommitInfo, + emojiModeActive: boolean = true, +): void { console.log(); console.log( `${label("confirm", "green")} ${textColors.pureWhite("Revert Confirmation")}`, ); console.log(); - console.log(` ${textColors.brightWhite("Reverting commit:")} ${commit.shortHash}`); - console.log(` ${textColors.brightWhite("Original:")} ${commit.subject}`); + console.log( + ` ${textColors.brightWhite("Reverting commit:")} ${commit.shortHash}`, + ); + const displaySubject = formatForDisplay(commit.subject, emojiModeActive); + console.log(` ${textColors.brightWhite("Original:")} ${displaySubject}`); console.log(); console.log( ` ${attention("This will create a new commit that undoes these changes.")}`, @@ -142,7 +151,9 @@ export function displayRevertConfirmation(commit: CommitInfo): void { /** * Prompt for revert confirmation */ -export async function promptRevertConfirmation(): Promise<"confirm" | "edit" | "cancel"> { +export async function promptRevertConfirmation(): Promise< + "confirm" | "edit" | "cancel" +> { const confirmed = await confirm({ message: `${label("confirm", "green")} ${textColors.pureWhite("Proceed with revert?")}`, initialValue: true, @@ -163,4 +174,3 @@ export async function promptRevertConfirmation(): Promise<"confirm" | "edit" | " return "cancel"; } - diff --git a/src/cli/commands/revert/types.ts b/src/cli/commands/revert/types.ts index de4c42c..14ed6c4 100644 --- a/src/cli/commands/revert/types.ts +++ b/src/cli/commands/revert/types.ts @@ -10,4 +10,3 @@ export interface RevertState { useCommitWorkflow: boolean; // true unless --no-edit conflictDetected: boolean; } - diff --git a/src/cli/commands/shared/commit-parser.ts b/src/cli/commands/shared/commit-parser.ts index d28b8fc..3fda9cf 100644 --- a/src/cli/commands/shared/commit-parser.ts +++ b/src/cli/commands/shared/commit-parser.ts @@ -90,10 +90,12 @@ export function generateRevertSubject( if (base.length > maxLength) { // Reserve space for "Revert \"...\"" const availableLength = maxLength - 15; // "Revert \"...\"" - const truncated = originalSubject.substring(0, Math.max(0, availableLength)); + const truncated = originalSubject.substring( + 0, + Math.max(0, availableLength), + ); return `Revert "${truncated}..."`; } return base; } - diff --git a/src/cli/commands/shared/git-operations.ts b/src/cli/commands/shared/git-operations.ts index 676014f..d39f902 100644 --- a/src/cli/commands/shared/git-operations.ts +++ b/src/cli/commands/shared/git-operations.ts @@ -75,12 +75,17 @@ function formatRelativeTime(date: Date): string { const diffMonths = Math.floor(diffDays / 30); const diffYears = Math.floor(diffDays / 365); - if (diffSecs < 60) return `${diffSecs} second${diffSecs !== 1 ? "s" : ""} ago`; - if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? "s" : ""} ago`; - if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`; + if (diffSecs < 60) + return `${diffSecs} second${diffSecs !== 1 ? "s" : ""} ago`; + if (diffMins < 60) + return `${diffMins} minute${diffMins !== 1 ? "s" : ""} ago`; + if (diffHours < 24) + return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`; if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? "s" : ""} ago`; - if (diffWeeks < 4) return `${diffWeeks} week${diffWeeks !== 1 ? "s" : ""} ago`; - if (diffMonths < 12) return `${diffMonths} month${diffMonths !== 1 ? "s" : ""} ago`; + if (diffWeeks < 4) + return `${diffWeeks} week${diffWeeks !== 1 ? "s" : ""} ago`; + if (diffMonths < 12) + return `${diffMonths} month${diffMonths !== 1 ? "s" : ""} ago`; return `${diffYears} year${diffYears !== 1 ? "s" : ""} ago`; } @@ -174,7 +179,8 @@ export function getCommitDetails(hash: string): CommitInfo { throw new Error(`Invalid commit format: ${hash}`); } - const [fullHash, subject, authorName, authorEmail, dateStr, parentsStr] = parts; + const [fullHash, subject, authorName, authorEmail, dateStr, parentsStr] = + parts; const shortHash = fullHash.substring(0, 7); const parents = parentsStr ? parentsStr.trim().split(/\s+/) : []; const isMerge = parents.length > 1; @@ -198,7 +204,9 @@ export function getCommitDetails(hash: string): CommitInfo { if (statOutput) { const statLines = statOutput.split("\n").filter((l) => l.trim()); const lastLine = statLines[statLines.length - 1]; - const match = lastLine.match(/(\d+) file(?:s)? changed(?:, (\d+) insertion(?:s)?)?(?:, (\d+) deletion(?:s)?)?/); + const match = lastLine.match( + /(\d+) file(?:s)? changed(?:, (\d+) insertion(?:s)?)?(?:, (\d+) deletion(?:s)?)?/, + ); if (match) { fileStats = { filesChanged: parseInt(match[1], 10) || 0, @@ -252,7 +260,10 @@ export function isMergeCommit(hash: string): boolean { export function getMergeParents(hash: string): MergeParent[] { try { const parentsStr = execGit(["log", "-1", "--format=%P", hash]); - const parentHashes = parentsStr.trim().split(/\s+/).filter((h) => h); + const parentHashes = parentsStr + .trim() + .split(/\s+/) + .filter((h) => h); return parentHashes.map((parentHash, index) => { const shortHash = parentHash.substring(0, 7); @@ -305,4 +316,3 @@ export function hasUncommittedChanges(): boolean { return false; } } - diff --git a/src/cli/commands/shared/types.ts b/src/cli/commands/shared/types.ts index 7898745..147a1fb 100644 --- a/src/cli/commands/shared/types.ts +++ b/src/cli/commands/shared/types.ts @@ -83,4 +83,3 @@ export interface MergeParent { /** Full hash */ hash: string; } - diff --git a/src/cli/commands/test/index.ts b/src/cli/commands/test/index.ts index c26de8c..a540e24 100644 --- a/src/cli/commands/test/index.ts +++ b/src/cli/commands/test/index.ts @@ -18,7 +18,12 @@ import { clearState, isSandboxValid, } from "./state-manager.js"; -import { SCENARIOS, DEFAULT_SCENARIO, listScenarios, getScenario } from "./scenarios.js"; +import { + SCENARIOS, + DEFAULT_SCENARIO, + listScenarios, + getScenario, +} from "./scenarios.js"; import type { ScenarioName } from "./types.js"; import { textColors, success, attention } from "../init/colors.js"; @@ -163,7 +168,9 @@ async function statusAction(): Promise { if (!state || !isSandboxValid(sandboxPath)) { console.log(" No active test environment."); console.log(); - console.log(` Run ${textColors.brightCyan("pnpm run dev:cli test setup")} to create one.`); + console.log( + ` Run ${textColors.brightCyan("pnpm run dev:cli test setup")} to create one.`, + ); console.log(); return; } @@ -172,7 +179,9 @@ async function statusAction(): Promise { console.log(` ${textColors.brightWhite("Scenario:")} ${state.scenario}`); if (scenario) { - console.log(` ${textColors.brightWhite("Description:")} ${scenario.description}`); + console.log( + ` ${textColors.brightWhite("Description:")} ${scenario.description}`, + ); } console.log(` ${textColors.brightWhite("Sandbox:")} ${sandboxPath}`); console.log(); @@ -187,7 +196,9 @@ async function statusAction(): Promise { if (gitStatus) { const lines = gitStatus.split("\n").length; - console.log(` ${textColors.brightWhite("Uncommitted changes:")} ${lines} file(s)`); + console.log( + ` ${textColors.brightWhite("Uncommitted changes:")} ${lines} file(s)`, + ); } else { console.log(` ${textColors.brightWhite("Working directory:")} clean`); } @@ -207,7 +218,9 @@ function listScenariosAction(): void { console.log(); listScenarios().forEach((scenario) => { - console.log(` ${textColors.brightCyan("•")} ${textColors.brightWhite(scenario.name)}`); + console.log( + ` ${textColors.brightCyan("•")} ${textColors.brightWhite(scenario.name)}`, + ); console.log(` ${textColors.white(scenario.description)}`); console.log(); }); @@ -230,9 +243,7 @@ function shellAction(): void { `${textColors.brightCyan("◐")} Opening shell in test environment...`, ); console.log(); - console.log( - ` ${textColors.white("Sandbox:")} ${sandboxPath}`, - ); + console.log(` ${textColors.white("Sandbox:")} ${sandboxPath}`); console.log( ` ${textColors.white("Exit with:")} ${textColors.brightCyan("exit")} or ${textColors.brightCyan("Ctrl+D")}`, ); @@ -286,4 +297,3 @@ export const testCommand = new Command("test") .description("Open interactive shell in test environment") .action(shellAction), ); - diff --git a/src/cli/commands/test/scenario-generator.ts b/src/cli/commands/test/scenario-generator.ts index 04d1b26..9cb3a25 100644 --- a/src/cli/commands/test/scenario-generator.ts +++ b/src/cli/commands/test/scenario-generator.ts @@ -30,7 +30,8 @@ function execGit(sandboxPath: string, args: string[]): void { // With encoding: "utf-8", stderr/stdout are strings or null const stderr = result.stderr || ""; const stdout = result.stdout || ""; - const errorMessage = stderr || stdout || `Command exited with status ${result.status}`; + const errorMessage = + stderr || stdout || `Command exited with status ${result.status}`; throw new Error( `Git command failed: git ${args.join(" ")}\n${errorMessage}`, ); @@ -182,7 +183,11 @@ function generateCommitHistory( async function createUncommittedChanges(sandboxPath: string): Promise { // Modified files for (let i = 1; i <= 4; i++) { - const filePath = join(sandboxPath, "src", `component-${String.fromCharCode(96 + i)}.ts`); + const filePath = join( + sandboxPath, + "src", + `component-${String.fromCharCode(96 + i)}.ts`, + ); writeFileSync( filePath, `// Component ${String.fromCharCode(96 + i)}\nexport class Component${String.fromCharCode(96 + i).toUpperCase()} {}\n// Modified\n`, @@ -191,7 +196,11 @@ async function createUncommittedChanges(sandboxPath: string): Promise { // Added files for (let i = 1; i <= 3; i++) { - const filePath = join(sandboxPath, "src", `service-${String.fromCharCode(96 + i)}.ts`); + const filePath = join( + sandboxPath, + "src", + `service-${String.fromCharCode(96 + i)}.ts`, + ); writeFileSync( filePath, `// New service ${String.fromCharCode(96 + i)}\nexport class Service${String.fromCharCode(96 + i).toUpperCase()} {}\n`, @@ -208,17 +217,17 @@ async function createUncommittedChanges(sandboxPath: string): Promise { const fileName = `old-util-${i}.js`; const filePath = join(sandboxPath, "lib", fileName); const relativePath = `lib/${fileName}`; - + // Create file if it doesn't exist if (!existsSync(filePath)) { writeFileSync(filePath, `// Old utility ${i}\n`); filesToDelete.push({ filePath, relativePath }); } } - + // Add and commit all files together if (filesToDelete.length > 0) { - const relativePaths = filesToDelete.map(f => f.relativePath); + const relativePaths = filesToDelete.map((f) => f.relativePath); execGit(sandboxPath, ["add", ...relativePaths]); execGit(sandboxPath, [ "commit", @@ -227,7 +236,7 @@ async function createUncommittedChanges(sandboxPath: string): Promise { "--no-verify", ]); } - + // Now remove each file (they should all exist at this point) // Use git rm to stage the deletion, then unstage it to create unstaged deletion for (const { filePath, relativePath } of filesToDelete) { @@ -258,16 +267,16 @@ async function createUncommittedChanges(sandboxPath: string): Promise { const oldPath = join(sandboxPath, "lib", oldName); const oldRelativePath = `lib/${oldName}`; const newRelativePath = `lib/${newName}`; - + if (!existsSync(oldPath)) { writeFileSync(oldPath, `// ${oldName}\n`); filesToRename.push({ oldPath, oldRelativePath, newRelativePath }); } } - + // Add and commit all files together if (filesToRename.length > 0) { - const relativePaths = filesToRename.map(f => f.oldRelativePath); + const relativePaths = filesToRename.map((f) => f.oldRelativePath); execGit(sandboxPath, ["add", ...relativePaths]); execGit(sandboxPath, [ "commit", @@ -276,7 +285,7 @@ async function createUncommittedChanges(sandboxPath: string): Promise { "--no-verify", ]); } - + // Now rename each file (they should all exist at this point) // For unstaged renames, we manually move the file (not git mv) so git sees it as // an unstaged deletion of old file and unstaged addition of new file @@ -338,12 +347,7 @@ function createConflictState(sandboxPath: string): void { execGit(sandboxPath, ["checkout", "main"]); writeFileSync(conflictFile, "// Modified on main\n"); execGit(sandboxPath, ["add", conflictFile]); - execGit(sandboxPath, [ - "commit", - "-m", - "feat: modify on main", - "--no-verify", - ]); + execGit(sandboxPath, ["commit", "-m", "feat: modify on main", "--no-verify"]); // Attempt merge to create conflict try { @@ -370,7 +374,10 @@ async function copyConfig(sandboxPath: string): Promise { if (existsSync(configPath)) { const configContent = readFileSync(configPath, "utf-8"); - writeFileSync(join(sandboxPath, ".labcommitr.config.yaml"), configContent); + writeFileSync( + join(sandboxPath, ".labcommitr.config.yaml"), + configContent, + ); } } } @@ -430,4 +437,3 @@ export async function generateScenario( break; } } - diff --git a/src/cli/commands/test/scenarios.ts b/src/cli/commands/test/scenarios.ts index 29b00ee..326c398 100644 --- a/src/cli/commands/test/scenarios.ts +++ b/src/cli/commands/test/scenarios.ts @@ -68,4 +68,3 @@ export function getScenario(name: string): ScenarioMetadata | null { export function listScenarios(): ScenarioMetadata[] { return Object.values(SCENARIOS); } - diff --git a/src/cli/commands/test/state-manager.ts b/src/cli/commands/test/state-manager.ts index 6ae0b9c..241cce4 100644 --- a/src/cli/commands/test/state-manager.ts +++ b/src/cli/commands/test/state-manager.ts @@ -4,7 +4,13 @@ * Manages test environment state and metadata */ -import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs"; +import { + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + unlinkSync, +} from "fs"; import { join } from "path"; import type { TestState, ScenarioName } from "./types.js"; @@ -45,10 +51,7 @@ export function loadState(sandboxPath: string): TestState | null { /** * Save test state */ -export function saveState( - sandboxPath: string, - scenario: ScenarioName, -): void { +export function saveState(sandboxPath: string, scenario: ScenarioName): void { mkdirSync(sandboxPath, { recursive: true }); const state: TestState = { @@ -81,4 +84,3 @@ export function isSandboxValid(sandboxPath: string): boolean { existsSync(getStateFilePath(sandboxPath)) ); } - diff --git a/src/cli/commands/test/types.ts b/src/cli/commands/test/types.ts index 6fda8b0..9596e83 100644 --- a/src/cli/commands/test/types.ts +++ b/src/cli/commands/test/types.ts @@ -24,4 +24,3 @@ export interface TestState { sandboxPath: string; isActive: boolean; } - diff --git a/src/cli/program-dev.ts b/src/cli/program-dev.ts index cad45f5..d54919f 100644 --- a/src/cli/program-dev.ts +++ b/src/cli/program-dev.ts @@ -73,4 +73,3 @@ Documentation: // Error on unknown commands program.showSuggestionAfterError(true); - diff --git a/src/index-dev.ts b/src/index-dev.ts index 5715b5c..b219670 100644 --- a/src/index-dev.ts +++ b/src/index-dev.ts @@ -27,7 +27,9 @@ async function main(): Promise { 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 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"`); @@ -43,4 +45,3 @@ async function main(): Promise { // Execute CLI main(); - diff --git a/src/index.ts b/src/index.ts index f88af31..7503391 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,7 +30,9 @@ async function main(): Promise { 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 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"`); diff --git a/src/lib/config/defaults.ts b/src/lib/config/defaults.ts index 7bff690..c543a52 100644 --- a/src/lib/config/defaults.ts +++ b/src/lib/config/defaults.ts @@ -182,9 +182,17 @@ export function mergeWithDefaults(rawConfig: RawConfig): LabcommitrConfig { // 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, + 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, }; } } diff --git a/src/lib/config/loader.ts b/src/lib/config/loader.ts index bd9cac4..38e74ab 100644 --- a/src/lib/config/loader.ts +++ b/src/lib/config/loader.ts @@ -20,6 +20,7 @@ import type { import { ConfigError } from "./types.js"; import { mergeWithDefaults, createFallbackConfig } from "./defaults.js"; import { ConfigValidator } from "./validator.js"; +import { detectEmojiSupport } from "../util/emoji.js"; /** * Configuration file names to search for (in priority order) @@ -231,7 +232,7 @@ export class ConfigLoader { config: fallbackConfig, source: "defaults", loadedAt: Date.now(), - emojiModeActive: this.detectEmojiSupport(), // TODO: Implement emoji detection + emojiModeActive: this.detectEmojiSupport(fallbackConfig), }; } @@ -309,7 +310,7 @@ export class ConfigLoader { source: "project", path: configPath, loadedAt: Date.now(), - emojiModeActive: this.detectEmojiSupport(), // TODO: Implement emoji detection + emojiModeActive: this.detectEmojiSupport(processedConfig), }; } @@ -346,14 +347,23 @@ export class ConfigLoader { /** * Detects whether the current terminal supports emoji display * - * TODO: Implement proper emoji detection logic - * For now, returns true as a placeholder + * Combines user preference (force_emoji_detection) with terminal capability detection. + * User preference takes precedence over automatic detection. * - * @returns Whether emojis should be displayed + * @param config - The loaded configuration (may contain force_emoji_detection override) + * @returns Whether emojis should be displayed in the terminal */ - private detectEmojiSupport(): boolean { - // Placeholder implementation - will be enhanced later - return true; + private detectEmojiSupport(config?: LabcommitrConfig): boolean { + // User override takes highest priority + if ( + config?.config.force_emoji_detection !== null && + config?.config.force_emoji_detection !== undefined + ) { + return config.config.force_emoji_detection; + } + + // Automatic terminal detection + return detectEmojiSupport(); } /** @@ -444,7 +454,7 @@ export class ConfigLoader { } catch (error: any) { if (error.code === "ENOENT") { // File not found - this is handled upstream, but provide clear error if called directly - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Configuration file not found: ${filePath}`, "The file does not exist", ["Run 'lab init' to create a configuration file"], @@ -452,7 +462,7 @@ export class ConfigLoader { ); } else if (error.code === "EACCES") { // Permission denied - provide actionable solutions - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Cannot read configuration file: ${filePath}`, "Permission denied - insufficient file permissions", [ @@ -464,7 +474,7 @@ export class ConfigLoader { ); } else if (error.code === "ENOTDIR") { // Path component is not a directory - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Invalid path to configuration file: ${filePath}`, "A component in the path is not a directory", [ @@ -476,7 +486,7 @@ export class ConfigLoader { } // Re-throw unexpected errors with additional context - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Failed to access configuration file: ${filePath}`, `System error: ${error.message}`, [ @@ -507,7 +517,7 @@ export class ConfigLoader { // Check for empty file (common user error) if (!fileContent.trim()) { - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Configuration file is empty: ${filePath}`, "The file contains no content or only whitespace", [ @@ -529,7 +539,7 @@ export class ConfigLoader { // Validate that result is an object (not null, string, array, etc.) if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { const actualType = Array.isArray(parsed) ? "array" : typeof parsed; - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Invalid configuration structure in ${filePath}`, `Configuration must be a YAML object, but got ${actualType}`, [ @@ -544,7 +554,7 @@ export class ConfigLoader { // Basic structure validation - ensure required 'types' field exists const config = parsed as any; if (!Array.isArray(config.types)) { - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Missing required 'types' field in ${filePath}`, "Configuration must include a 'types' array defining commit types", [ @@ -565,7 +575,7 @@ export class ConfigLoader { // Extract line and column information if available if (mark) { const lineInfo = `line ${mark.line + 1}, column ${mark.column + 1}`; - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Invalid YAML syntax in ${filePath} at ${lineInfo}`, `Parsing error: ${message}`, [ @@ -578,7 +588,7 @@ export class ConfigLoader { ); } else { // YAML error without specific location - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Invalid YAML syntax in ${filePath}`, `Parsing error: ${message}`, [ @@ -599,7 +609,7 @@ export class ConfigLoader { // Handle file system errors that might occur during reading if (error.code === "EISDIR") { - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Cannot read configuration: ${filePath} is a directory`, "Expected a file but found a directory", [ @@ -611,7 +621,7 @@ export class ConfigLoader { } // Generic error fallback with context - throw new (Error as any)( // TODO: Use proper ConfigError import + throw new ConfigError( `Failed to parse configuration file: ${filePath}`, `Unexpected error: ${error.message}`, [ @@ -643,7 +653,7 @@ export class ConfigLoader { // Handle common file system errors with specific guidance if (error.code === "ENOENT") { - return new (Error as any)( // TODO: Use proper ConfigError import + return new ConfigError( `No configuration found starting from ${context}`, "Could not locate a labcommitr configuration file in the project", [ @@ -655,7 +665,7 @@ export class ConfigLoader { } if (error.code === "EACCES") { - return new (Error as any)( // TODO: Use proper ConfigError import + return new ConfigError( `Permission denied while searching for configuration`, `Cannot access directory or file: ${error.path || context}`, [ @@ -667,7 +677,7 @@ export class ConfigLoader { } if (error.code === "ENOTDIR") { - return new (Error as any)( // TODO: Use proper ConfigError import + return new ConfigError( `Invalid directory structure encountered`, `Expected directory but found file: ${error.path || context}`, [ @@ -679,7 +689,7 @@ export class ConfigLoader { // Handle YAML-related errors (these should typically be caught upstream) if (error instanceof yaml.YAMLException) { - return new (Error as any)( // TODO: Use proper ConfigError import + return new ConfigError( `Configuration file contains invalid YAML syntax`, `YAML parsing error: ${error.message}`, [ @@ -692,7 +702,7 @@ export class ConfigLoader { // Handle timeout errors (e.g., from slow file systems) if (error.code === "ETIMEDOUT") { - return new (Error as any)( // TODO: Use proper ConfigError import + return new ConfigError( `Timeout while accessing configuration files`, "File system operation took too long to complete", [ @@ -709,7 +719,7 @@ export class ConfigLoader { ? `\n\nTechnical details:\n${error.stack}` : ""; - return new (Error as any)( // TODO: Use proper ConfigError import + return new ConfigError( `Configuration loading failed`, `${errorMessage}${errorContext}`, [ diff --git a/src/lib/config/validator.ts b/src/lib/config/validator.ts index f7ded4b..ee48e68 100644 --- a/src/lib/config/validator.ts +++ b/src/lib/config/validator.ts @@ -393,7 +393,10 @@ export class ConfigValidator { const shortcuts = config.advanced.shortcuts; // Validate enabled - if (shortcuts.enabled !== undefined && typeof shortcuts.enabled !== "boolean") { + if ( + shortcuts.enabled !== undefined && + typeof shortcuts.enabled !== "boolean" + ) { errors.push({ field: "advanced.shortcuts.enabled", fieldDisplay: "Shortcuts → Enabled", @@ -406,7 +409,10 @@ export class ConfigValidator { } // Validate display_hints - if (shortcuts.display_hints !== undefined && typeof shortcuts.display_hints !== "boolean") { + if ( + shortcuts.display_hints !== undefined && + typeof shortcuts.display_hints !== "boolean" + ) { errors.push({ field: "advanced.shortcuts.display_hints", fieldDisplay: "Shortcuts → Display Hints", @@ -420,12 +426,16 @@ export class ConfigValidator { // Validate prompts structure if (shortcuts.prompts !== undefined) { - if (typeof shortcuts.prompts !== "object" || Array.isArray(shortcuts.prompts)) { + 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", + 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", @@ -453,7 +463,10 @@ export class ConfigValidator { // Validate mapping if (promptConfig.mapping !== undefined) { - if (typeof promptConfig.mapping !== "object" || Array.isArray(promptConfig.mapping)) { + if ( + typeof promptConfig.mapping !== "object" || + Array.isArray(promptConfig.mapping) + ) { errors.push({ field: `advanced.shortcuts.prompts.${promptName}.mapping`, fieldDisplay: `Shortcuts → Prompts → ${promptName} → Mapping`, diff --git a/src/lib/presets/index.ts b/src/lib/presets/index.ts index 6978664..d4432d0 100644 --- a/src/lib/presets/index.ts +++ b/src/lib/presets/index.ts @@ -141,8 +141,8 @@ export function buildConfig( force_emoji_detection: null, }, format: { - // Template is determined by style; emoji is handled at render time - template: "{type}({scope}): {subject}", + // Template includes {emoji} placeholder - will be replaced with emoji or empty string + template: "{emoji}{type}({scope}): {subject}", subject_max_length: 50, // Body configuration (respects user choice, defaults to optional) body: { diff --git a/src/lib/shortcuts/auto-assign.ts b/src/lib/shortcuts/auto-assign.ts index 2ff90a8..42ee81c 100644 --- a/src/lib/shortcuts/auto-assign.ts +++ b/src/lib/shortcuts/auto-assign.ts @@ -81,4 +81,3 @@ function findAvailableShortcut( return null; // No available shortcut } - diff --git a/src/lib/shortcuts/index.ts b/src/lib/shortcuts/index.ts index 0462c3a..8ebd8c2 100644 --- a/src/lib/shortcuts/index.ts +++ b/src/lib/shortcuts/index.ts @@ -111,4 +111,3 @@ export function getShortcutForValue( } return mapping.valueToKey[value]; } - diff --git a/src/lib/shortcuts/input-handler.ts b/src/lib/shortcuts/input-handler.ts index 57c0e0e..39e7891 100644 --- a/src/lib/shortcuts/input-handler.ts +++ b/src/lib/shortcuts/input-handler.ts @@ -45,4 +45,3 @@ export function handleShortcutInput( * * 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 index 0315223..0e94f27 100644 --- a/src/lib/shortcuts/select-with-shortcuts.ts +++ b/src/lib/shortcuts/select-with-shortcuts.ts @@ -126,4 +126,3 @@ export async function selectWithShortcuts( }); }); } - diff --git a/src/lib/shortcuts/types.ts b/src/lib/shortcuts/types.ts index 6489b4b..ac23b51 100644 --- a/src/lib/shortcuts/types.ts +++ b/src/lib/shortcuts/types.ts @@ -34,4 +34,3 @@ export const DEFAULT_CHAR_SET: ShortcutCharacterSet = { allowedChars: "abcdefghijklmnopqrstuvwxyz".split(""), priority: "first", }; - diff --git a/src/lib/util/emoji.ts b/src/lib/util/emoji.ts new file mode 100644 index 0000000..46c4e63 --- /dev/null +++ b/src/lib/util/emoji.ts @@ -0,0 +1,138 @@ +/** + * Emoji Detection and Display Utilities + * + * Provides terminal emoji support detection and emoji stripping + * functionality for clean display on non-emoji terminals. + * + * Industry Standard Approach: + * - Always store Unicode emojis in Git commits + * - Strip emojis from Labcommitr's UI display when terminal doesn't support them + * - This ensures GitHub and emoji-capable terminals show emojis correctly + */ + +import { platform } from "os"; + +/** + * Detects whether the current terminal supports emoji display + * + * Uses industry-standard heuristics: + * - Disable in CI environments (CI=true) + * - Disable for dumb terminals (TERM=dumb) + * - Disable on older Windows terminals + * - Check for NO_COLOR environment variable + * - Allow user override via FORCE_EMOJI_DETECTION + * + * @returns Whether emojis should be displayed in the terminal + */ +export function detectEmojiSupport(): boolean { + // User override (highest priority) + const forceDetection = process.env.FORCE_EMOJI_DETECTION; + if (forceDetection !== undefined) { + return forceDetection.toLowerCase() === "true" || forceDetection === "1"; + } + + // NO_COLOR standard (https://no-color.org/) + if (process.env.NO_COLOR) { + return false; + } + + // CI environments typically don't support emojis well + if (process.env.CI === "true" || process.env.CI === "1") { + return false; + } + + // Dumb terminals don't support emojis + const term = process.env.TERM; + if (term === "dumb" || term === "unknown") { + return false; + } + + // Windows terminal detection + const isWindows = platform() === "win32"; + if (isWindows) { + // Modern Windows Terminal (10+) supports emojis + // Older cmd.exe and PowerShell may not + // Check for Windows Terminal specific environment variables + const wtSession = process.env.WT_SESSION; + if (wtSession) { + // Windows Terminal detected - supports emojis + return true; + } + + // Check for ConEmu or other modern terminals + const conEmu = process.env.CONEMUANSI; + if (conEmu === "ON") { + return true; + } + + // For older Windows terminals, be conservative + // Check if we're in a TTY (interactive terminal) + if (!process.stdout.isTTY) { + return false; + } + + // Default to false for older Windows (can be overridden by FORCE_EMOJI_DETECTION) + return false; + } + + // Unix-like systems: check TERM variable + // Most modern terminals support emojis + if (term) { + // Known non-emoji terminals + const nonEmojiTerms = ["linux", "vt100", "vt220", "xterm-mono"]; + if (nonEmojiTerms.includes(term.toLowerCase())) { + return false; + } + + // Modern terminals typically support emojis + // xterm-256color, screen-256color, tmux-256color, etc. + return true; + } + + // Default: assume emoji support if we have a TTY + return process.stdout.isTTY === true; +} + +/** + * Strips Unicode emojis from a string for display on non-emoji terminals + * + * Uses Unicode emoji pattern matching to remove emoji characters + * while preserving the rest of the text. + * + * @param text - Text that may contain emojis + * @returns Text with emojis removed + */ +export function stripEmojis(text: string): string { + // Unicode emoji pattern matching + // Matches emoji characters including: + // - Emoticons (😀-🙏) + // - Symbols & Pictographs (🌀-🗿) + // - Transport & Map Symbols (🚀-🛿) + // - Flags (country flags) + // - Regional indicators + // - Variation selectors + const emojiPattern = + /[\p{Emoji}\p{Emoji_Presentation}\p{Emoji_Modifier_Base}\p{Emoji_Modifier}\p{Emoji_Component}]/gu; + + return text.replace(emojiPattern, "").trim(); +} + +/** + * Conditionally strips emojis from text based on terminal support + * + * If terminal supports emojis, returns original text. + * If terminal doesn't support emojis, returns text with emojis stripped. + * + * @param text - Text that may contain emojis + * @param terminalSupportsEmojis - Whether terminal supports emoji display + * @returns Text with emojis conditionally stripped + */ +export function formatForDisplay( + text: string, + terminalSupportsEmojis: boolean, +): string { + if (terminalSupportsEmojis) { + return text; + } + return stripEmojis(text); +}