diff --git a/.changeset/add-command-aliases-and-help-improvements.md b/.changeset/add-command-aliases-and-help-improvements.md new file mode 100644 index 0000000..6e8463b --- /dev/null +++ b/.changeset/add-command-aliases-and-help-improvements.md @@ -0,0 +1,14 @@ +--- +"@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-preview-toggle-functionality.md b/.changeset/add-preview-toggle-functionality.md new file mode 100644 index 0000000..6151f8a --- /dev/null +++ b/.changeset/add-preview-toggle-functionality.md @@ -0,0 +1,14 @@ +--- +"@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 new file mode 100644 index 0000000..34c7891 --- /dev/null +++ b/.changeset/fix-init-config-check-timing.md @@ -0,0 +1,12 @@ +--- +"@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-preview-body-extraction.md b/.changeset/fix-preview-body-extraction.md new file mode 100644 index 0000000..2634cdb --- /dev/null +++ b/.changeset/fix-preview-body-extraction.md @@ -0,0 +1,11 @@ +--- +"@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/.github/workflows/label.yml b/.github/workflows/label.yml index a493bf4..c80e759 100644 --- a/.github/workflows/label.yml +++ b/.github/workflows/label.yml @@ -15,5 +15,5 @@ jobs: steps: - uses: actions/labeler@v4 with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" + repo-token: "${{ secrets.LAB_ACTIONS_TOKEN }}" sync-labels: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41d8fb1..244b0d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,6 +51,6 @@ jobs: title: "[ci] release" env: # Uses built-in GITHUB_TOKEN (automatically available, no secret needed) - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.LAB_ACTIONS_TOKEN }} # Needs access to publish to npm NPM_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} diff --git a/.gitignore b/.gitignore index 8efb2a1..4e55520 100644 --- a/.gitignore +++ b/.gitignore @@ -36,10 +36,14 @@ coverage/ *.lcov # Testing -# Sandbox directory for testing Labcommitr commands (scripts in scripts/ are tracked) +# Sandbox directory for testing Labcommitr commands .sandbox/ test-results/ +# Documentation +# Architecture and development documentation (not tracked) +docs/ + # npm/yarn/pnpm npm-debug.log* yarn-debug.log* @@ -75,10 +79,11 @@ Thumbs.db rust-src/ ### Documentation -# Ignore all .md files except README.md, CHANGELOG.md, and .changeset/*.md (local reference only) +# Ignore all .md files except README.md, CHANGELOG.md, TESTING.md, and .changeset/*.md *.md !README.md !CHANGELOG.md +!TESTING.md !.changeset/*.md ### Labcommitr Configuration diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..5a01cf7 --- /dev/null +++ b/.npmignore @@ -0,0 +1,32 @@ +# Exclude development-only files from published package +dist/cli/commands/test/ +dist/cli/program-dev.js +dist/cli/program-dev.d.ts +dist/cli/program-dev.js.map +dist/index-dev.js +dist/index-dev.d.ts +dist/index-dev.js.map + +# Exclude source files +src/ +*.ts +!*.d.ts + +# Exclude development files +.git/ +.sandbox/ +docs/ +*.md +!README.md +!CHANGELOG.md +!TESTING.md +!.changeset/*.md + +# Exclude build artifacts +*.map +tsconfig.json +.prettierrc* + +# Exclude test and development scripts +scripts/ + diff --git a/README.md b/README.md index dce4762..f8b6e6f 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,389 @@ -# `@labcatr/labcommitr` +# Labcommitr -A solution for building standardized git commits! +A CLI tool for creating standardized Git commits with customizable workflows and presets. **Labcommitr** is used internally for all @labcatr projects. However, feel free to use it for your own projects! -## Plans +## Table of Contents -**Command**: `labcommitr` (alias `lc`) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Commands](#commands) + - [commit](#commit) + - [init](#init) + - [config](#config) + - [preview](#preview) + - [revert](#revert) +- [Configuration](#configuration) +- [Development & Testing](#development--testing) +- [Contributing](#contributing) -### Commands +## Installation -`labcommitr commit` : Start a `labcommitr` commit builder. +```bash +npm install -g @labcatr/labcommitr +# or +pnpm add -g @labcatr/labcommitr +``` + +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 + ``` + +3. **Or commit quickly with flags**: + ```bash + lab commit -t feat -m "add new feature" + ``` + +## Commands + +### commit + +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 +- `-b, --body ` - Commit body/description +- `--no-verify` - Bypass Git hooks + +**Examples:** +```bash +# Interactive commit (prompts for missing fields) +lab commit + +# Quick commit with type and message +lab commit -t feat -m "add user authentication" + +# Full commit with all fields +lab commit -t feat -s api -m "add endpoint" -b "Implements REST endpoint for user data" + +# Commit without running Git hooks +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 +- Supports keyboard shortcuts for faster navigation (see configuration) + +--- + +### init + +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 + +# Overwrite existing configuration +lab init --force + +# Use a specific preset without prompts +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 +- Generates `.labcommitr.config.yaml` with default shortcuts enabled +- 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 +- **Minimal** - Start with basics, customize everything yourself later + +--- + +### config + +Manage and inspect Labcommitr configuration. + +**Usage:** +```bash +lab config +``` + +**Subcommands:** + +#### show + +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 + +# Show configuration from a specific directory +lab config show --path /path/to/project +``` + +**What it shows:** +- Configuration source (file path or "defaults") +- Emoji mode status +- Full configuration in JSON format +- Helpful warnings if using default configuration + +--- + +### preview + +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 + +# Preview commits from a specific branch +lab preview --branch main + +# Limit the number of commits fetched +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 + - Press `n` to load next batch (if available) + - Press `p` to go to previous batch (if available) + - Press `Esc` to exit + +- **Commit Detail View:** + - View full commit information (hash, author, date, subject, body, files) + - Press `b` to toggle body visibility + - Press `f` to toggle changed files visibility + - Press `d` to view diff + - Press `r` to revert this commit (switches to revert command) + - Press `←` or `Esc` to go back to list + - 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 +- No configuration file required (read-only operation) + +--- + +### revert + +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) +- `--continue` - Continue revert after conflict resolution +- `--abort` - Abort revert in progress + +**Examples:** +```bash +# Interactive revert (uses commit workflow) +lab revert + +# Revert from specific branch +lab revert --branch main + +# Revert without using commit workflow +lab revert --no-edit + +# Continue after resolving conflicts +lab revert --continue + +# Abort a revert in progress +lab revert --abort +``` + +**Interactive Features:** +- **Commit Selection:** + - Browse commits with pagination (10 per page) + - Press `0-9` to select a commit to revert + - Press `n` to load next batch (if available) + - Press `p` to go to previous batch (if available) + - Press `Esc` to cancel + +- **Revert Workflow:** + - Shows commit details before reverting + - For merge commits, prompts to select parent + - Uses your project's commit workflow to create revert commit message + - Allows editing commit message before finalizing + - 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 +- If conflicts occur, resolve them manually and use `--continue` +- Use `--abort` to cancel a revert in progress + +--- + +## Configuration -`labcommitr init`, `-i`: Create a file called `.labcommitrc` in the root directory of the current git repo. +Labcommitr uses a `.labcommitr.config.yaml` file in your project root. The configuration file supports: -`labcommitr go [...message]`: Quickly submit a commit of the specified type with a message. If a message is not specified, a generic one will be generated for you (it is not good practice, however its BLAZINGLY FAST). +- **Commit types** - Define custom commit types with descriptions +- **Format options** - Configure scope, body, and emoji requirements +- **Keyboard shortcuts** - Enable and customize shortcuts for faster navigation +- **Git integration** - Auto-staging and commit signing preferences + +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 + +--- ## Development & Testing -### Testing Sandbox +### Testing Environment + +**Note:** Test commands are **development-only** and are not available in the published package. They can only be used when running from the source repository. -For safe testing of Labcommitr commands without affecting your real repository, use the testing sandbox: +For safe testing of Labcommitr commands without affecting your real repository, use the built-in testing environment: ```bash -# Create sandbox with config (if available) -pnpm run test:sandbox +# Set up test environment (default scenario) +pnpm run dev:cli test setup -# Create sandbox without config (start from scratch) -pnpm run test:sandbox:bare +# Open shell in test environment +pnpm run dev:cli test shell -# Quick reset for iterative testing -pnpm run test:sandbox:reset +# Run commands normally (use regular lab command in shell) +lab commit +lab preview +lab revert + +# Reset environment for another test +pnpm run dev:cli test reset # Clean up -pnpm run test:sandbox:clean +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 ``` -See [`scripts/README.md`](scripts/README.md) for complete testing documentation. +**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 +- `with-merge` - Test revert with merge commits +- `with-conflicts` - Test conflict resolution workflows + +**Examples:** +```bash +# Set up specific scenario +pnpm run dev:cli test setup --scenario with-history + +# List all scenarios +pnpm run dev:cli test list-scenarios + +# Check current status +pnpm run dev:cli test status +``` + +See [`TESTING.md`](TESTING.md) for complete testing documentation. + +--- + +## Contributing + +Contributions are welcome! We appreciate your interest in improving Labcommitr. + +### How to Contribute + +Before implementing any changes, please follow this process: + +1. **Open an issue** describing your proposed change + - Clearly explain what you want to add or modify + - Provide **use cases** that demonstrate the value of your change + - Include **justification** for why this change would benefit users + - Discuss potential implementation approaches if relevant + +2. **Wait for review and discussion** + - Maintainers will review your proposal + - Community feedback is encouraged + - We'll discuss whether the change aligns with project goals + +3. **Proceed with implementation** (if approved) + - Once the proposal is accepted, you can start implementing + - 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. + +--- + +## Planned Features + +_No planned features at this time. Check back later or open an issue to suggest new features!_ diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..67b1bfb --- /dev/null +++ b/TESTING.md @@ -0,0 +1,540 @@ +# Labcommitr Testing Environment + +A simple, flexible testing environment for testing Labcommitr commands in isolated git repositories. + +**Note:** Test commands are **development-only** and are not available in the published package. They can only be used when running from the source repository. + +## Quick Start + +```bash +# Set up test environment (default scenario) +pnpm run dev:cli test setup + +# Open shell in test environment +pnpm run dev:cli test shell + +# Run commands normally (use regular lab command in shell) +lab commit +lab preview +lab revert + +# Exit shell when done +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 +``` + +--- + +## Table of Contents + +- [Overview](#overview) +- [Quick Start](#quick-start) +- [Scenarios](#scenarios) +- [Commands](#commands) +- [Workflows](#workflows) +- [Troubleshooting](#troubleshooting) + +--- + +## Overview + +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) +- ✅ Quick reset and scenario switching +- ✅ 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) + +--- + +## Scenarios + +Scenarios represent different git repository states. Each scenario is designed to test specific commands or workflows. + +### `existing-project` + +**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 +``` + +--- + +### `with-changes` + +**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 +``` + +**Default Scenario:** This is the default scenario if none is specified. + +--- + +### `with-history` + +**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 +- `.labcommitr.config.yaml` file present +- No uncommitted changes +- Clean working directory + +**Use Cases:** +- Test `lab preview` pagination +- Test `lab preview` detail view +- Test `lab preview` navigation +- Test `lab revert` commit selection +- Test `lab revert` workflow + +**Setup:** +```bash +pnpm run dev:cli test setup --scenario with-history +``` + +--- + +### `with-merge` + +**Purpose:** Test revert with merge commits + +**State:** +- Git repository with merge commits +- Multiple branches merged into main +- Merge commits with multiple parents +- `.labcommitr.config.yaml` file present +- No uncommitted changes +- 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 +``` + +--- + +### `with-conflicts` + +**Purpose:** Test conflict resolution workflows + +**State:** +- Git repository in conflict state +- Unmerged files (conflict markers present) +- Revert operation in progress (optional) +- `.labcommitr.config.yaml` file present +- 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 +``` + +--- + +## Commands + +### `lab test setup [--scenario ]` + +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 + +# Set up specific scenario +pnpm run dev:cli test setup --scenario existing-project +pnpm run dev:cli test setup --scenario with-history +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 +- Sets up config file (if scenario requires it) +- Ready for testing + +--- + +### `lab test shell` + +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.) +- Exit with `exit` or `Ctrl+D` + +**Note:** Make sure you've run `pnpm run dev:cli test setup` first. + +--- + +### `lab test reset` + +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) +- Ready for testing again + +--- + +### `lab test clean` + +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 + +--- + +### `lab test status` + +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 +- Git status summary +- Uncommitted changes count + +--- + +### `lab test list-scenarios` + +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 + +--- + +## Workflows + +### Workflow 1: Testing Commit Command + +```bash +# Set up environment +pnpm run dev:cli test setup --scenario with-changes + +# Enter test environment +pnpm run dev:cli test shell + +# Test commit +lab commit +# [interactive commit prompts] + +# Exit and reset for another test +exit +pnpm run dev:cli test reset +pnpm run dev:cli test shell +lab commit -t feat -m "quick commit" +``` + +--- + +### Workflow 2: Testing Preview Command + +```bash +# Set up environment with history +pnpm run dev:cli test setup --scenario with-history + +# Enter test environment +pnpm run dev:cli test shell + +# Test preview +lab preview +# [browse 100+ commits] +# [test pagination, detail view, navigation] +``` + +--- + +### Workflow 3: Testing Revert Command + +```bash +# Set up environment +pnpm run dev:cli test setup --scenario with-history + +# Enter test environment +pnpm run dev:cli test shell + +# Test revert +lab revert +# [select commit to revert] +# [go through revert workflow] + +# Test revert with merge commits +exit +pnpm run dev:cli test setup --scenario with-merge +pnpm run dev:cli test shell +lab revert +# [select merge commit] +# [select parent] +# [complete revert] +``` + +--- + +### Workflow 4: Testing Init on Existing Project + +```bash +# Set up environment +pnpm run dev:cli test setup --scenario existing-project + +# Enter test environment +pnpm run dev:cli test shell + +# Test init workflow +lab init +# [interactive init prompts] +# [config file created] + +# Test first commit +lab commit +# [commit with new config] +``` + +--- + +### Workflow 5: Real-World End-to-End Workflow + +```bash +# Set up environment +pnpm run dev:cli test setup --scenario with-changes + +# Enter test environment +pnpm run dev:cli test shell + +# Real workflow +lab commit -t feat -s api -m "add new endpoint" +lab preview +lab commit -t fix -m "fix bug in endpoint" +lab preview +lab revert +# [select commit to revert] +# [complete revert workflow] +lab preview +# [verify revert commit] +``` + +--- + +### Workflow 6: Testing Conflict Resolution + +```bash +# Set up environment +pnpm run dev:cli test setup --scenario with-conflicts + +# Enter test environment +pnpm run dev:cli test shell + +# Test abort +lab revert --abort +# [revert cancelled] + +# Reset and test continue +exit +pnpm run dev:cli test reset +pnpm run dev:cli test shell +# [manually resolve conflicts] +lab revert --continue +# [revert completed] +``` + +--- + +## Troubleshooting + +### "No active test environment found" + +**Problem:** You're trying to use test commands but no environment is set up. + +**Solution:** +```bash +pnpm run dev:cli test setup +``` + +--- + +### "Invalid scenario" + +**Problem:** You specified a scenario name that doesn't exist. + +**Solution:** +```bash +# List available scenarios +pnpm run dev:cli test list-scenarios + +# Use correct scenario name +pnpm run dev:cli test setup --scenario with-changes +``` + +--- + +### Build Required + +**Problem:** Project needs to be built before testing. + +**Solution:** +The `pnpm run dev:cli test setup` command automatically builds the project if needed. If you encounter build issues: + +```bash +# Build manually +pnpm run build + +# Then set up test environment +pnpm run dev:cli test setup +``` + +--- + +### Sandbox Location + +**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/` + +--- + +### Reset Not Working + +**Problem:** Reset doesn't restore scenario properly. + +**Solution:** +```bash +# Clean and recreate +pnpm run dev:cli test clean +pnpm run dev:cli test setup --scenario +``` + +--- + +## Tips + +1. **Use `pnpm run dev:cli test shell`** - Easiest way to test commands in the environment +2. **Check status** - Use `pnpm run dev:cli test status` to see current state +3. **Quick reset** - Use `pnpm run dev:cli test reset` for fast iteration +4. **Test workflows** - Chain multiple commands to test real-world usage +5. **Switch scenarios** - Use different scenarios for different testing needs +6. **Dev-only access** - Test commands are only available when running from source repository + +--- + +## Safety + +The test environment is **100% safe**: +- ✅ Isolated from your real repository +- ✅ No remote configured (can't push) +- ✅ Easy cleanup (`lab test clean`) +- ✅ Git-ignored (won't be committed) + +--- + +**Last Updated:** January 2025 +**Sandbox Location:** `.sandbox/test/` + diff --git a/package.json b/package.json index e0a57f1..f1e0120 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,7 @@ "format:ci": "pnpm run format:code", "format:code": "prettier -w \"**/*\" --ignore-unknown --cache", "version": "changeset version && pnpm install --no-frozen-lockfile && pnpm run format", - "test:sandbox": "bash scripts/labcommitr-sandbox.sh", - "test:sandbox:bare": "bash scripts/labcommitr-sandbox.sh --no-config", - "test:sandbox:reset": "bash scripts/labcommitr-sandbox.sh --reset", - "test:sandbox:clean": "bash scripts/labcommitr-sandbox.sh --clean" + "dev:cli": "node dist/index-dev.js" }, "type": "module", "bin": { @@ -47,6 +44,20 @@ "typescript": "^5.9.2", "ufo": "^1.6.1" }, + "files": [ + "dist/index.js", + "dist/index.d.ts", + "dist/cli/program.js", + "dist/cli/program.d.ts", + "dist/cli/commands/commit", + "dist/cli/commands/config", + "dist/cli/commands/init", + "dist/cli/commands/preview", + "dist/cli/commands/revert", + "dist/cli/commands/shared", + "dist/cli/utils", + "dist/lib" + ], "publishConfig": { "access": "public" }, diff --git a/scripts/README.md b/scripts/README.md deleted file mode 100644 index 478c245..0000000 --- a/scripts/README.md +++ /dev/null @@ -1,407 +0,0 @@ -# Labcommitr Testing Sandbox - -A testing environment for safely experimenting with Labcommitr commands without affecting your real repository. - -## TLDR; Quick Start - -```bash -# Create sandbox with config (if available in project root) -pnpm run test:sandbox - -# Create sandbox without config (start from scratch) -pnpm run test:sandbox:bare - -# Enter sandbox and test -cd .sandbox/*/ -node ../../dist/index.js commit - -# Quick reset from within sandbox (easiest!) -bash reset-sandbox.sh # Reset, remove config -bash reset-sandbox.sh --preserve-config # Reset, keep config - -# Quick reset from project root -pnpm run test:sandbox:reset # Reset, remove config -bash scripts/labcommitr-sandbox.sh --reset --preserve-config # Reset, keep config - -# Full recreation (slower, completely fresh) -pnpm run test:sandbox - -# Clean up (remove sandbox) -pnpm run test:sandbox:clean -``` - ---- - -## Table of Contents - -- [Overview](#overview) -- [Quick Start](#tldr-quick-start) -- [Usage](#usage) - - [Creating a Sandbox](#creating-a-sandbox) - - [Testing Commands](#testing-commands) - - [Resetting the Sandbox](#resetting-the-sandbox) - - [Cleaning Up](#cleaning-up) -- [Sandbox Contents](#sandbox-contents) -- [Testing Scenarios](#testing-scenarios) -- [Troubleshooting](#troubleshooting) -- [Safety Guarantees](#safety-guarantees) - ---- - -## Overview - -The testing sandbox creates an isolated git repository with pre-configured file states to test Labcommitr's commit command. Each sandbox gets a randomized scientific name (e.g., `quark`, `photon`, `neutron`) and is stored in `.sandbox/` directory. - -**Key Features:** -- ✅ Completely isolated from your real repository -- ✅ Pre-populated with various git file states (modified, added, deleted, renamed, copied) -- ✅ Automatically copies your project's `.labcommitr.config.yaml` if it exists -- ✅ Safe to delete anytime -- ✅ Quick reset option for iterative testing - ---- - -## Usage - -### Creating a Sandbox - -You have two options for creating a sandbox: - -**Option 1: With Config (Default)** -```bash -# Using npm script (recommended) -pnpm run test:sandbox - -# Or direct script execution -bash scripts/labcommitr-sandbox.sh -``` - -This will: -1. Create a new sandbox directory in `.sandbox//` -2. Initialize a git repository -3. Copy your project's `.labcommitr.config.yaml` if it exists (ready to test immediately) -4. Create test files with various git states -5. Stage all changes ready for testing - -**Option 2: Without Config (Start from Scratch)** -```bash -# Using npm script (recommended) -pnpm run test:sandbox:bare - -# Or direct script execution -bash scripts/labcommitr-sandbox.sh --no-config -``` - -This will: -1. Create a new sandbox directory in `.sandbox//` -2. Initialize a git repository -3. **Skip copying config** (sandbox starts without configuration) -4. Create test files with various git states -5. Stage all changes ready for testing - -After creating a bare sandbox, you can set up configuration: -```bash -cd .sandbox/*/ -lab init # Interactive setup (or lab init --force to overwrite if config exists) -``` - -**Note:** If a sandbox already exists, it will be completely recreated (full reset). - -### Testing Commands - -Once the sandbox is created, navigate to it and test Labcommitr: - -```bash -# Find your sandbox (it has a random scientific name) -cd .sandbox/*/ - -# Test commit command (recommended method) -node ../../dist/index.js commit - -# Alternative: If you've linked globally -lab commit -``` - -**⚠️ Important:** Do NOT use `npx lab commit` - it will use the wrong 'lab' package (Node.js test framework). - -### Resetting the Sandbox - -You have multiple reset options depending on your needs: - -**Quick Reset from Within Sandbox** (easiest for iterative testing) -```bash -# From within the sandbox directory -cd .sandbox/*/ - -# Reset (removes config file) -bash reset-sandbox.sh - -# Reset and preserve config file -bash reset-sandbox.sh --preserve-config -``` -- Can be run from within the sandbox directory -- Faster (keeps repository structure) -- Resets git state and re-applies test file changes -- Option to preserve `.labcommitr.config.yaml` file - -**Quick Reset from Project Root** -```bash -# Reset (removes config file) -pnpm run test:sandbox:reset - -# Reset and preserve config file -bash scripts/labcommitr-sandbox.sh --reset --preserve-config -``` -- Must be run from project root -- Same functionality as reset from within sandbox - -**Full Recreation** (slower but completely fresh) -```bash -pnpm run test:sandbox -# or -bash scripts/labcommitr-sandbox.sh -``` -- Removes entire sandbox and recreates from scratch -- Ensures all file types are properly staged -- Use this if quick reset doesn't work or you want a clean slate - -### Cleaning Up - -To completely remove the sandbox: - -```bash -pnpm run test:sandbox:clean -# or -bash scripts/labcommitr-sandbox.sh --clean -``` - -This removes the sandbox directory entirely. The `.sandbox/` base directory is also removed if empty. - ---- - -## Sandbox Contents - -Each sandbox contains a git repository with the following pre-staged file states: - -### File States (17 total files staged) - -- **Modified (4 files)**: `src/component-{a,b,c,d}.ts` - Files with changes -- **Added (4 files)**: `src/service-{a,b,c}.ts`, `docs/guide.md` - New files -- **Deleted (4 files)**: `utils/old-util-{1,2,3,4}.js` - Files marked for deletion -- **Renamed (4 files)**: `lib/{helpers→helper-functions, constants→app-constants, types→type-definitions, config→configuration}.ts` - Files moved/renamed -- **Copied (4 files)**: `src/model-{1,2}-backup.ts`, `lib/model-{3,4}-copy.ts` - Files copied (Git detects with `-C50` flag) -- **Pre-staged (1 file)**: `pre-staged.ts` - Already staged file for testing - -### Directory Structure - -``` -.sandbox/ -└── / # e.g., quark, photon, neutron - ├── .labcommitr.config.yaml # Copied from project root (if exists) - ├── README.md - ├── package.json - ├── src/ - │ ├── component-{a,b,c,d}.ts - │ ├── service-{a,b,c}.ts - │ ├── model-{1,2,3,4}.ts - │ └── model-{1,2}-backup.ts - ├── docs/ - │ └── guide.md - ├── lib/ - │ ├── helper-functions.ts - │ ├── app-constants.ts - │ ├── type-definitions.ts - │ ├── configuration.ts - │ └── model-{3,4}-copy.ts - ├── utils/ - └── pre-staged.ts -``` - ---- - -## Testing Scenarios - -### Test Different Configurations - -1. **Modify config in sandbox:** - ```bash - cd .sandbox/*/ - # Edit .labcommitr.config.yaml - # Test with different settings: - # - auto_stage: true vs false - # - Different commit types - # - Validation rules - # - editor_preference: "auto" | "inline" | "editor" - ``` - -2. **Test auto-stage behavior:** - - Set `auto_stage: true` - tool should stage files automatically - - Set `auto_stage: false` - tool should only commit already-staged files - -3. **Test validation rules:** - - Try invalid commit types - - Test scope requirements - - Test subject length limits - -4. **Test editor preferences:** - - `inline`: Type body directly in terminal - - `editor`: Opens your default editor - - `auto`: Detects available editor automatically - -### Verify Commit Results - -```bash -# Check git log -git log --oneline -5 - -# See last commit details -git show HEAD - -# Check git status -git status -git status --porcelain # Compact format - -# See staged files -git diff --cached --name-only -``` - ---- - -## Troubleshooting - -### Files Don't Appear Correctly - -If git status doesn't show the expected file states: - -1. **Full recreation:** - ```bash - pnpm run test:sandbox - ``` - -2. **Check git status manually:** - ```bash - cd .sandbox/*/ - git status - git status --porcelain - ``` - -### Config Not Found - -If you see "No config file found" or created a bare sandbox: - -1. **Create config in sandbox (recommended):** - ```bash - cd .sandbox/*/ - lab init # Interactive setup - ``` - -2. **Or create config in project root first, then recreate sandbox:** - ```bash - # From project root - lab init - # Then recreate sandbox to copy the config - pnpm run test:sandbox - ``` - -3. **To overwrite existing config in sandbox:** - ```bash - cd .sandbox/*/ - lab init --force # Overwrites existing config - ``` - -### Reset Not Working - -If quick reset fails: - -1. **Use full recreation instead:** - ```bash - pnpm run test:sandbox - ``` - -2. **Or manually reset:** - ```bash - cd .sandbox/*/ - git reset --hard HEAD - git clean -fd - ``` - -### Can't Find Sandbox - -Sandbox location is randomized. To find it: - -```bash -# List all sandboxes -ls -la .sandbox/ - -# Or use find -find .sandbox -name ".git" -type d -``` - ---- - -## Safety Guarantees - -The sandbox is **100% safe**: - -1. **No push to remote**: Sandbox is completely separate, no remote configured -2. **Isolated**: No connection to your real repository -3. **Easy cleanup**: Delete directory when done (`pnpm run test:sandbox:clean`) -4. **No side effects**: Changes only exist in test environment -5. **Git-ignored**: `.sandbox/` is in `.gitignore`, won't be committed - ---- - -## Pro Tips - -1. **Keep sandbox open in separate terminal** for quick iteration -2. **Use quick reset** (`--reset`) for faster testing cycles -3. **Use bare sandbox** (`--no-config`) to test the full `lab init` flow -4. **Test both `auto_stage: true` and `false`** configurations -5. **Test editor preferences** (`inline`, `editor`, `auto`) -6. **Test validation rules** by intentionally breaking them -7. **Check git log** after commits to verify message formatting -8. **Use `lab init --force`** in sandbox to test different presets and configurations - ---- - -## Script Options - -The `labcommitr-sandbox.sh` script supports the following options: - -```bash -# Create or recreate sandbox with config (default) -bash scripts/labcommitr-sandbox.sh - -# Create sandbox without config (start from scratch) -bash scripts/labcommitr-sandbox.sh --no-config - -# Quick reset (faster, keeps repo structure) -bash scripts/labcommitr-sandbox.sh --reset - -# Quick reset with config preservation -bash scripts/labcommitr-sandbox.sh --reset --preserve-config - -# Remove sandbox completely -bash scripts/labcommitr-sandbox.sh --clean - -# Show help -bash scripts/labcommitr-sandbox.sh --help -``` - -**Note:** The script can detect if it's being run from within a sandbox directory and will automatically use that sandbox for reset operations. - -### Reset Script (Within Sandbox) - -Each sandbox includes a `reset-sandbox.sh` script for convenience: - -```bash -# From within sandbox directory -bash reset-sandbox.sh # Reset, remove config -bash reset-sandbox.sh --preserve-config # Reset, keep config -``` - ---- - -**Last Updated**: January 2025 -**Script Location**: `scripts/labcommitr-sandbox.sh` -**Sandbox Location**: `.sandbox//` diff --git a/scripts/labcommitr-sandbox.sh b/scripts/labcommitr-sandbox.sh deleted file mode 100755 index a52ddc8..0000000 --- a/scripts/labcommitr-sandbox.sh +++ /dev/null @@ -1,557 +0,0 @@ -#!/bin/bash - -# Labcommitr Testing Sandbox -# Creates an isolated git repository for testing Labcommitr commands -# Safe to use - nothing affects your real repository - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -SANDBOX_BASE="$PROJECT_ROOT/.sandbox" - -# Colors for output -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -# Scientific names for fun repository names -SCIENTIFIC_NAMES=( - "quark" "photon" "neutron" "electron" "atom" "molecule" - "proton" "boson" "fermion" "quantum" "plasma" "ion" - "catalyst" "enzyme" "polymer" "crystal" "isotope" "nucleus" - "chromosome" "genome" "protein" "dna" "rna" "enzyme" - "nebula" "galaxy" "asteroid" "comet" "pulsar" "quasar" -) - -# Function to generate random scientific name -generate_sandbox_name() { - local random_index=$((RANDOM % ${#SCIENTIFIC_NAMES[@]})) - echo "${SCIENTIFIC_NAMES[$random_index]}" -} - -# Function to find existing sandbox -find_existing_sandbox() { - if [ -d "$SANDBOX_BASE" ]; then - local dirs=("$SANDBOX_BASE"/*) - if [ -d "${dirs[0]}" ]; then - echo "${dirs[0]}" - return 0 - fi - fi - return 1 -} - -# Function to detect if we're in a sandbox directory -detect_sandbox_dir() { - local current_dir="$(pwd)" - local abs_current_dir="$(cd "$current_dir" && pwd)" - - # Check if current directory is within .sandbox/ - if [[ "$abs_current_dir" == *"/.sandbox/"* ]]; then - # Find the .sandbox base directory - local sandbox_base="${abs_current_dir%/.sandbox/*}/.sandbox" - - # Check if .sandbox directory exists - if [ ! -d "$sandbox_base" ]; then - return 1 - fi - - # Get the path after .sandbox/ - local relative_path="${abs_current_dir#${sandbox_base}/}" - - # Extract the first directory name (sandbox name) - local sandbox_name="${relative_path%%/*}" - - # Construct full sandbox path - local sandbox_dir="$sandbox_base/$sandbox_name" - - # Verify it's actually a git repository (sandbox marker) - if [ -d "$sandbox_dir/.git" ]; then - echo "$sandbox_dir" - return 0 - fi - fi - return 1 -} - -# Function to display usage -show_usage() { - echo "Usage: $0 [OPTIONS]" - echo "" - echo "Options:" - echo " --reset Quick reset: reset git state without full recreation" - echo " --preserve-config Preserve .labcommitr.config.yaml during reset (use with --reset)" - echo " --clean Remove sandbox directory entirely" - echo " --no-config Create sandbox without copying config (start from scratch)" - echo " --help Show this help message" - echo "" - echo "Examples:" - echo " $0 # Create or recreate sandbox (with config if available)" - echo " $0 --no-config # Create sandbox without config (run 'lab init' yourself)" - echo " $0 --reset # Quick reset (faster, keeps repo structure)" - echo " $0 --reset --preserve-config # Quick reset, preserve config file" - echo " $0 --clean # Remove sandbox completely" - echo "" - echo "Note: Can be run from within sandbox directory or project root" -} - -# Parse arguments -RESET_MODE=false -CLEAN_MODE=false -NO_CONFIG=false -PRESERVE_CONFIG=false - -while [[ $# -gt 0 ]]; do - case $1 in - --reset) - RESET_MODE=true - shift - ;; - --preserve-config) - PRESERVE_CONFIG=true - shift - ;; - --clean) - CLEAN_MODE=true - shift - ;; - --no-config) - NO_CONFIG=true - shift - ;; - --help|-h) - show_usage - exit 0 - ;; - *) - echo -e "${RED}Error: Unknown option: $1${NC}" - show_usage - exit 1 - ;; - esac -done - -# Handle clean mode -if [ "$CLEAN_MODE" = true ]; then - SANDBOX_DIR=$(find_existing_sandbox) - if [ -n "$SANDBOX_DIR" ]; then - echo -e "${YELLOW}Removing sandbox: $SANDBOX_DIR${NC}" - rm -rf "$SANDBOX_DIR" - echo -e "${GREEN}✓${NC} Sandbox removed" - - # Clean up base directory if empty - if [ -d "$SANDBOX_BASE" ] && [ -z "$(ls -A "$SANDBOX_BASE")" ]; then - rmdir "$SANDBOX_BASE" - fi - else - echo -e "${YELLOW}No sandbox found to clean${NC}" - fi - exit 0 -fi - -# Handle reset mode -if [ "$RESET_MODE" = true ]; then - # Try to detect if we're in a sandbox directory first - DETECTED_SANDBOX=$(detect_sandbox_dir) - if [ -n "$DETECTED_SANDBOX" ] && [ -d "$DETECTED_SANDBOX" ]; then - SANDBOX_DIR="$DETECTED_SANDBOX" - else - # Fall back to finding existing sandbox from project root - SANDBOX_DIR=$(find_existing_sandbox) - fi - - if [ -z "$SANDBOX_DIR" ] || [ ! -d "$SANDBOX_DIR" ]; then - echo -e "${RED}Error: No existing sandbox found. Run without --reset to create one.${NC}" - exit 1 - fi - - echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" - echo -e "${BLUE} Labcommitr Testing Sandbox - Quick Reset${NC}" - echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" - echo "" - echo -e "${GREEN}✓${NC} Resetting sandbox: $SANDBOX_DIR" - cd "$SANDBOX_DIR" - - # Preserve config if requested - CONFIG_BACKUP="" - if [ "$PRESERVE_CONFIG" = true ] && [ -f ".labcommitr.config.yaml" ]; then - CONFIG_BACKUP=$(mktemp) - cp ".labcommitr.config.yaml" "$CONFIG_BACKUP" - echo -e "${GREEN}✓${NC} Preserving config file..." - fi - - # Reset git state - git reset --hard HEAD 2>/dev/null || true - git clean -fd 2>/dev/null || true - - # Restore config if preserved - if [ -n "$CONFIG_BACKUP" ] && [ -f "$CONFIG_BACKUP" ]; then - cp "$CONFIG_BACKUP" ".labcommitr.config.yaml" - rm "$CONFIG_BACKUP" - echo -e "${GREEN}✓${NC} Config file restored" - fi - - # Ensure directories exist (they might have been removed by git clean) - mkdir -p src docs lib utils - - # Re-apply changes (same as modification phase) - echo -e "${GREEN}✓${NC} Re-applying test file states..." - - # Modify 4 files - cat >> src/component-a.ts << 'EOF' -export function newFeatureA() { return 'new'; } -EOF - cat >> src/component-b.ts << 'EOF' -export function newFeatureB() { return 'new'; } -EOF - cat >> src/component-c.ts << 'EOF' -export function newFeatureC() { return 'new'; } -EOF - cat >> src/component-d.ts << 'EOF' -export function newFeatureD() { return 'new'; } -EOF - git add src/component-a.ts src/component-b.ts src/component-c.ts src/component-d.ts - - # Add 4 new files - echo "# New service A" > src/service-a.ts - echo "export class ServiceA {}" >> src/service-a.ts - git add src/service-a.ts - - echo "# New service B" > src/service-b.ts - echo "export class ServiceB {}" >> src/service-b.ts - git add src/service-b.ts - - echo "# New service C" > src/service-c.ts - echo "export class ServiceC {}" >> src/service-c.ts - git add src/service-c.ts - - echo "# New service D" > docs/guide.md - echo "# User Guide" >> docs/guide.md - git add docs/guide.md - - # Delete 4 files - git rm -f utils/old-util-1.js utils/old-util-2.js utils/old-util-3.js utils/old-util-4.js 2>/dev/null || true - - # Rename 4 files - git mv -f lib/helpers.ts lib/helper-functions.ts 2>/dev/null || true - git mv -f lib/constants.ts lib/app-constants.ts 2>/dev/null || true - git mv -f lib/types.ts lib/type-definitions.ts 2>/dev/null || true - git mv -f lib/config.ts lib/configuration.ts 2>/dev/null || true - - # Copy 4 files - cp src/model-1.ts src/model-1-backup.ts - echo "" >> src/model-1-backup.ts - echo "// Backup copy" >> src/model-1-backup.ts - git add src/model-1-backup.ts - - cp src/model-2.ts src/model-2-backup.ts - echo "" >> src/model-2-backup.ts - echo "// Backup copy" >> src/model-2-backup.ts - git add src/model-2-backup.ts - - cp src/model-3.ts lib/model-3-copy.ts - echo "" >> lib/model-3-copy.ts - echo "// Copy in lib directory" >> lib/model-3-copy.ts - git add lib/model-3-copy.ts - - cp src/model-4.ts lib/model-4-copy.ts - echo "" >> lib/model-4-copy.ts - echo "// Copy in lib directory" >> lib/model-4-copy.ts - git add lib/model-4-copy.ts - - # Pre-staged file - echo "# Pre-staged file" > pre-staged.ts - git add pre-staged.ts - - # Always ensure reset script is present and up-to-date - if [ -f "$SCRIPT_DIR/reset-sandbox.sh" ]; then - cp "$SCRIPT_DIR/reset-sandbox.sh" "reset-sandbox.sh" - chmod +x "reset-sandbox.sh" - echo -e "${GREEN}✓${NC} Reset script updated" - fi - - echo "" - echo -e "${GREEN}✓${NC} Sandbox reset complete!" - echo "" - echo -e "${YELLOW}Sandbox location:${NC} $SANDBOX_DIR" - echo "" - echo -e "${YELLOW}To test:${NC}" - echo " cd $SANDBOX_DIR" - echo " node $PROJECT_ROOT/dist/index.js commit" - echo "" - echo -e "${YELLOW}To reset again (from within sandbox):${NC}" - echo " bash $SCRIPT_DIR/labcommitr-sandbox.sh --reset" - echo " bash $SCRIPT_DIR/labcommitr-sandbox.sh --reset --preserve-config" - echo "" - echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" - exit 0 -fi - -# Normal mode: create or recreate sandbox -echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" -echo -e "${BLUE} Labcommitr Testing Sandbox${NC}" -echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" -echo "" - -# Generate sandbox name -SANDBOX_NAME=$(generate_sandbox_name) -SANDBOX_DIR="$SANDBOX_BASE/$SANDBOX_NAME" - -# Clean up existing sandbox if it exists -if [ -d "$SANDBOX_DIR" ]; then - echo -e "${YELLOW}⚠ Cleaning up existing sandbox...${NC}" - echo -e "${YELLOW} (This ensures all file types are properly staged)${NC}" - rm -rf "$SANDBOX_DIR" -fi - -# Create sandbox directory -echo -e "${GREEN}✓${NC} Creating sandbox: $SANDBOX_NAME" -mkdir -p "$SANDBOX_DIR" -cd "$SANDBOX_DIR" - -# Initialize git repository -echo -e "${GREEN}✓${NC} Initializing git repository..." -git init --initial-branch=main -git config user.name "Test User" -git config user.email "test@example.com" - -# Copy config file if it exists (unless --no-config flag is set) -if [ "$NO_CONFIG" = false ]; then - if [ -f "$PROJECT_ROOT/.labcommitr.config.yaml" ]; then - echo -e "${GREEN}✓${NC} Copying config file..." - cp "$PROJECT_ROOT/.labcommitr.config.yaml" "$SANDBOX_DIR/.labcommitr.config.yaml" - else - echo -e "${YELLOW}⚠ No config file found. Run 'lab init' in sandbox after setup.${NC}" - fi -else - echo -e "${YELLOW}⚠ Sandbox created without config. Run 'lab init' to set up configuration.${NC}" -fi - -# Copy reset script for convenience -if [ -f "$SCRIPT_DIR/reset-sandbox.sh" ]; then - cp "$SCRIPT_DIR/reset-sandbox.sh" "$SANDBOX_DIR/reset-sandbox.sh" - chmod +x "$SANDBOX_DIR/reset-sandbox.sh" -fi - -# Create initial commit -echo -e "${GREEN}✓${NC} Creating initial commit structure..." -cat > README.md << 'EOF' -# Test Repository - -This is a sandbox repository for testing the labcommitr commit command. -Safe to experiment with - nothing affects your real repository. -EOF - -cat > package.json << 'EOF' -{ - "name": "test-project", - "version": "1.0.0", - "description": "Test project for commit command" -} -EOF - -git add . -git commit -m "Initial commit" --no-verify - -# Create various file states for testing -echo -e "${GREEN}✓${NC} Creating test files with various states..." - -# Create directory structure -mkdir -p src docs lib utils - -# ============================================================================ -# SETUP PHASE: Create base files that will be modified/deleted/renamed/copied -# ============================================================================ - -# Files for modification (4 files) -echo "# Component A" > src/component-a.ts -echo "export class ComponentA {}" >> src/component-a.ts -git add src/component-a.ts -git commit -m "Add component A" --no-verify - -echo "# Component B" > src/component-b.ts -echo "export class ComponentB {}" >> src/component-b.ts -git add src/component-b.ts -git commit -m "Add component B" --no-verify - -echo "# Component C" > src/component-c.ts -echo "export class ComponentC {}" >> src/component-c.ts -git add src/component-c.ts -git commit -m "Add component C" --no-verify - -echo "# Component D" > src/component-d.ts -echo "export class ComponentD {}" >> src/component-d.ts -git add src/component-d.ts -git commit -m "Add component D" --no-verify - -# Files for deletion (4 files) -echo "# Old utility 1" > utils/old-util-1.js -git add utils/old-util-1.js -git commit -m "Add old util 1" --no-verify - -echo "# Old utility 2" > utils/old-util-2.js -git add utils/old-util-2.js -git commit -m "Add old util 2" --no-verify - -echo "# Old utility 3" > utils/old-util-3.js -git add utils/old-util-3.js -git commit -m "Add old util 3" --no-verify - -echo "# Old utility 4" > utils/old-util-4.js -git add utils/old-util-4.js -git commit -m "Add old util 4" --no-verify - -# Files for renaming (4 files) -echo "# Helper functions" > lib/helpers.ts -git add lib/helpers.ts -git commit -m "Add helpers" --no-verify - -echo "# Constants" > lib/constants.ts -git add lib/constants.ts -git commit -m "Add constants" --no-verify - -echo "# Types" > lib/types.ts -git add lib/types.ts -git commit -m "Add types" --no-verify - -echo "# Config" > lib/config.ts -git add lib/config.ts -git commit -m "Add config" --no-verify - -# Files for copying (4 files - will copy these) -echo "# Original model 1" > src/model-1.ts -git add src/model-1.ts -git commit -m "Add model 1" --no-verify - -echo "# Original model 2" > src/model-2.ts -git add src/model-2.ts -git commit -m "Add model 2" --no-verify - -echo "# Original model 3" > src/model-3.ts -git add src/model-3.ts -git commit -m "Add model 3" --no-verify - -echo "# Original model 4" > src/model-4.ts -git add src/model-4.ts -git commit -m "Add model 4" --no-verify - -# ============================================================================ -# MODIFICATION PHASE: Apply changes for testing -# ============================================================================ - -# Modify 4 files (M - Modified) and STAGE them -# IMPORTANT: Modify first, then stage all at once to ensure they're staged -cat >> src/component-a.ts << 'EOF' -export function newFeatureA() { return 'new'; } -EOF - -cat >> src/component-b.ts << 'EOF' -export function newFeatureB() { return 'new'; } -EOF - -cat >> src/component-c.ts << 'EOF' -export function newFeatureC() { return 'new'; } -EOF - -cat >> src/component-d.ts << 'EOF' -export function newFeatureD() { return 'new'; } -EOF - -# Stage all modified files together -git add src/component-a.ts src/component-b.ts src/component-c.ts src/component-d.ts - -# Add 4 new files (A - Added) and STAGE them -echo "# New service A" > src/service-a.ts -echo "export class ServiceA {}" >> src/service-a.ts -git add src/service-a.ts - -echo "# New service B" > src/service-b.ts -echo "export class ServiceB {}" >> src/service-b.ts -git add src/service-b.ts - -echo "# New service C" > src/service-c.ts -echo "export class ServiceC {}" >> src/service-c.ts -git add src/service-c.ts - -echo "# New service D" > docs/guide.md -echo "# User Guide" >> docs/guide.md -git add docs/guide.md - -# Delete 4 files (D - Deleted) -git rm utils/old-util-1.js -git rm utils/old-util-2.js -git rm utils/old-util-3.js -git rm utils/old-util-4.js - -# Rename 4 files (R - Renamed) -git mv lib/helpers.ts lib/helper-functions.ts -git mv lib/constants.ts lib/app-constants.ts -git mv lib/types.ts lib/type-definitions.ts -git mv lib/config.ts lib/configuration.ts - -# Copy 4 files (C - Copied) -# IMPORTANT: For Git to detect copies, source files must exist in previous commits -# and copies must have sufficient content (Git needs similarity threshold) -# We'll add more content to ensure detection works -cp src/model-1.ts src/model-1-backup.ts -# Add a comment to make it a proper copy (but still similar enough) -echo "" >> src/model-1-backup.ts -echo "// Backup copy" >> src/model-1-backup.ts -git add src/model-1-backup.ts - -cp src/model-2.ts src/model-2-backup.ts -echo "" >> src/model-2-backup.ts -echo "// Backup copy" >> src/model-2-backup.ts -git add src/model-2-backup.ts - -cp src/model-3.ts lib/model-3-copy.ts -echo "" >> lib/model-3-copy.ts -echo "// Copy in lib directory" >> lib/model-3-copy.ts -git add lib/model-3-copy.ts - -cp src/model-4.ts lib/model-4-copy.ts -echo "" >> lib/model-4-copy.ts -echo "// Copy in lib directory" >> lib/model-4-copy.ts -git add lib/model-4-copy.ts - -# Create one staged file for testing already-staged scenario -echo "# Pre-staged file" > pre-staged.ts -git add pre-staged.ts - -echo "" -echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" -echo -e "${GREEN}✓${NC} Sandbox ready: $SANDBOX_NAME" -echo "" -echo -e "${YELLOW}Sandbox location:${NC} $SANDBOX_DIR" -echo "" -echo -e "${YELLOW}Current repository state (all staged):${NC}" -echo " • Modified files (4): src/component-{a,b,c,d}.ts" -echo " • Added files (4): src/service-{a,b,c}.ts, docs/guide.md" -echo " • Deleted files (4): utils/old-util-{1,2,3,4}.js" -echo " • Renamed files (4): lib/{helpers→helper-functions, constants→app-constants, types→type-definitions, config→configuration}.ts" -echo " • Copied files (4): src/model-{1,2}-backup.ts, lib/model-{3,4}-copy.ts" -echo " • Pre-staged file: pre-staged.ts" -echo "" -echo -e "${YELLOW}To test:${NC}" -echo " cd $SANDBOX_DIR" -echo " node $PROJECT_ROOT/dist/index.js commit" -echo "" -echo -e "${YELLOW}To reset (from within sandbox):${NC}" -echo " bash reset-sandbox.sh # Reset, remove config" -echo " bash reset-sandbox.sh --preserve-config # Reset, keep config" -echo "" -echo -e "${YELLOW}To reset (from project root):${NC}" -echo " pnpm run test:sandbox:reset # Reset, remove config" -echo " bash $SCRIPT_DIR/labcommitr-sandbox.sh --reset --preserve-config # Reset, keep config" -echo "" -echo -e "${YELLOW}To reset (full recreation):${NC}" -echo " pnpm run test:sandbox" -echo "" -echo -e "${YELLOW}To clean up:${NC}" -echo " pnpm run test:sandbox:clean" -echo "" -echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" - diff --git a/scripts/reset-sandbox.sh b/scripts/reset-sandbox.sh deleted file mode 100755 index 56ab68d..0000000 --- a/scripts/reset-sandbox.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/bin/bash - -# Quick reset script for sandbox repositories -# Can be run from within a sandbox directory -# Usage: bash reset-sandbox.sh [--preserve-config] - -# Check if we're in a sandbox directory -CURRENT_DIR="$(pwd)" -if [[ "$CURRENT_DIR" != *"/.sandbox/"* ]]; then - echo "Error: This script must be run from within a sandbox directory" - echo "Current directory: $CURRENT_DIR" - echo "" - echo "To reset from project root, use:" - echo " pnpm run test:sandbox:reset" - exit 1 -fi - -# Find the project root by looking for .sandbox parent -# Current dir is something like: /path/to/project/.sandbox/atom -# We need to go up to find the project root -SANDBOX_DIR="$CURRENT_DIR" -PROJECT_ROOT="${SANDBOX_DIR%/.sandbox/*}" -SCRIPT_DIR="$PROJECT_ROOT/scripts" - -# Verify the script exists -if [ ! -f "$SCRIPT_DIR/labcommitr-sandbox.sh" ]; then - echo "Error: Could not find labcommitr-sandbox.sh script" - echo "Expected at: $SCRIPT_DIR/labcommitr-sandbox.sh" - exit 1 -fi - -# Call the main sandbox script with reset flag -if [ "$1" = "--preserve-config" ]; then - bash "$SCRIPT_DIR/labcommitr-sandbox.sh" --reset --preserve-config -else - bash "$SCRIPT_DIR/labcommitr-sandbox.sh" --reset -fi - diff --git a/src/cli/commands/init/index.ts b/src/cli/commands/init/index.ts index e228add..e07f433 100644 --- a/src/cli/commands/init/index.ts +++ b/src/cli/commands/init/index.ts @@ -62,6 +62,7 @@ function configExists(projectRoot: string): boolean { */ export const initCommand = new Command("init") .description("Initialize labcommitr configuration in your project") + .alias("i") .option("-f, --force", "Overwrite existing configuration") .option( "--preset ", @@ -78,10 +79,7 @@ async function initAction(options: { preset?: string; }): Promise { try { - // Intro: Clef introduces herself - await clef.intro(); - // Screen is now completely clear - + // Early validation: Check prerequisites before any UI // Detect project root const projectRoot = await detectProjectRoot(); if (!projectRoot) { @@ -89,13 +87,17 @@ async function initAction(options: { process.exit(1); } - // Check for existing config + // Check for existing config (must happen before any UI/animation) if (configExists(projectRoot) && !options.force) { Logger.error("Configuration already exists. Use --force to overwrite."); Logger.info(`File: ${path.join(projectRoot, ".labcommitr.config.yaml")}`); process.exit(1); } + // Intro: Clef introduces herself (only if we're proceeding) + await clef.intro(); + // Screen is now completely clear + // Prompts: Clean labels, no cat // Note: @clack/prompts clears each prompt after selection (their default behavior) const presetId = options.preset || (await promptPreset()); diff --git a/src/cli/commands/preview/index.ts b/src/cli/commands/preview/index.ts new file mode 100644 index 0000000..9c74ea7 --- /dev/null +++ b/src/cli/commands/preview/index.ts @@ -0,0 +1,269 @@ +/** + * Preview Command + * + * Browse and inspect commit history without modifying the repository + */ + +import { Command } from "commander"; +import { Logger } from "../../../lib/logger.js"; +import { + isGitRepository, + getCurrentBranch, + fetchCommits, + getCommitDetails, + getCommitDiff, +} from "../shared/git-operations.js"; +import type { CommitInfo } from "../shared/types.js"; +import { + displayCommitList, + displayCommitDetails, + displayHelp, + waitForDetailAction, + waitForListAction, +} from "./prompts.js"; +import { textColors } from "../init/colors.js"; + +/** + * Clear terminal screen + */ +function clearTerminal(): void { + if (process.stdout.isTTY) { + process.stdout.write("\x1B[2J"); + process.stdout.write("\x1B[H"); + } +} + +/** + * Preview action handler + */ +async function previewAction(options: { + limit?: number; + branch?: string; +}): Promise { + try { + // Check git repository + if (!isGitRepository()) { + Logger.error("Not a git repository"); + console.error("\n Initialize git first: git init\n"); + process.exit(1); + } + + const currentBranch = getCurrentBranch(); + if (!currentBranch) { + Logger.error("Could not determine current branch"); + process.exit(1); + } + + const branch = options.branch || currentBranch; + const maxCommits = Math.min(options.limit || 50, 100); + const pageSize = 10; + + // Initial fetch + let allCommits: CommitInfo[] = []; + let totalFetched = 0; + let hasMore = true; + let currentPage = 0; + + const loadMoreCommits = async (): Promise => { + if (totalFetched >= maxCommits) { + hasMore = false; + return; + } + + 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 newCommits = fetchCommits(toFetch, branch, lastHash); + allCommits = [...allCommits, ...newCommits]; + totalFetched = allCommits.length; + hasMore = newCommits.length === 50 && totalFetched < maxCommits; + }; + + // Load initial batch + await loadMoreCommits(); + + if (allCommits.length === 0) { + console.log("\n No commits found in current branch.\n"); + process.exit(0); + } + + // Main loop + let exit = false; + let viewingDetails = false; + let currentDetailCommit: CommitInfo | null = null; + let showBody = true; // Toggle state for body visibility + let showFiles = true; // Toggle state for files visibility + + while (!exit) { + clearTerminal(); + + if (viewingDetails && currentDetailCommit) { + // Detail view + displayCommitDetails(currentDetailCommit, showBody, showFiles); + 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")}`, + ); + + const action = await waitForDetailAction(); + + switch (action) { + case "back": + viewingDetails = false; + currentDetailCommit = null; + showBody = true; // Reset toggles when going back + showFiles = true; + break; + case "body": + // Toggle body visibility + showBody = !showBody; + break; + case "files": + // Toggle files visibility + showFiles = !showFiles; + break; + case "diff": + clearTerminal(); + console.log( + `\n${textColors.brightWhite("Diff for commit")} ${currentDetailCommit.shortHash}:\n`, + ); + const diff = getCommitDiff(currentDetailCommit.hash); + console.log(diff); + console.log( + `\n${textColors.white("Press any key to go back...")}`, + ); + await new Promise((resolve) => { + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.once("data", () => { + process.stdin.setRawMode(false); + process.stdin.pause(); + resolve(null); + }); + }); + break; + case "revert": + // Switch to revert command + console.log("\n Switching to revert command...\n"); + exit = true; + // Import and call revert with this commit + const revertModule = await import("../revert/index.js"); + await revertModule.revertCommit(currentDetailCommit.hash); + break; + case "help": + displayHelp(); + await new Promise((resolve) => { + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.once("data", () => { + process.stdin.setRawMode(false); + process.stdin.pause(); + resolve(null); + }); + }); + break; + case "exit": + exit = true; + break; + } + } else { + // List view + const startIndex = currentPage * pageSize; + const endIndex = Math.min(startIndex + pageSize, allCommits.length); + const pageCommits = allCommits.slice(startIndex, endIndex); + 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 hasPreviousPage = currentPage > 0; + + displayCommitList(pageCommits, startIndex, totalFetched, hasMore, hasPreviousPage, hasMorePages); + + const action = await waitForListAction(maxIndex, hasMorePages, hasPreviousPage); + + if (typeof action === "number") { + // View commit details + const commit = pageCommits[action]; + // Load full details if not already loaded + if (!commit.body || !commit.fileStats) { + try { + const fullDetails = getCommitDetails(commit.hash); + // Update the commit in our array + const index = allCommits.findIndex((c) => c.hash === commit.hash); + if (index >= 0) { + allCommits[index] = fullDetails; + } + currentDetailCommit = fullDetails; + } catch (error) { + Logger.error(`Failed to load commit details: ${error}`); + continue; + } + } else { + currentDetailCommit = commit; + } + viewingDetails = true; + showBody = true; // Reset toggles when viewing new commit + showFiles = true; + } else if (action === "previous") { + // Move to previous page + if (currentPage > 0) { + currentPage--; + } + } 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..."); + await loadMoreCommits(); + if (!hasMore && nextPageStart >= allCommits.length) { + console.log(" Maximum commits loaded (100)."); + await new Promise((resolve) => setTimeout(resolve, 1000)); + // Don't increment page if we can't show it + continue; + } + } + + // Increment page if we have commits to show + if (nextPageStart < allCommits.length) { + currentPage++; + } + } else if (action === "help") { + displayHelp(); + await new Promise((resolve) => { + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.once("data", () => { + process.stdin.setRawMode(false); + process.stdin.pause(); + resolve(null); + }); + }); + } else if (action === "exit") { + exit = true; + } + } + } + + console.log("\n Exiting preview.\n"); + } catch (error: unknown) { + Logger.error("Failed to preview commits"); + if (error instanceof Error) { + console.error(`\n ${error.message}\n`); + } + process.exit(1); + } +} + +/** + * Preview command + */ +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)") + .action(previewAction); + diff --git a/src/cli/commands/preview/prompts.ts b/src/cli/commands/preview/prompts.ts new file mode 100644 index 0000000..651a91d --- /dev/null +++ b/src/cli/commands/preview/prompts.ts @@ -0,0 +1,365 @@ +/** + * Preview Command Prompts + * + * Interactive prompts for browsing commit history + */ + +import { select, isCancel } from "@clack/prompts"; +import { labelColors, textColors } from "../init/colors.js"; +import type { CommitInfo } from "../shared/types.js"; +import { getCommitDetails, getCommitDiff } from "../shared/git-operations.js"; +import readline from "readline"; + +/** + * Create compact color-coded label + */ +function label( + text: string, + color: "magenta" | "cyan" | "blue" | "yellow" | "green", +): string { + const colorFn = { + magenta: labelColors.bgBrightMagenta, + cyan: labelColors.bgBrightCyan, + blue: labelColors.bgBrightBlue, + yellow: labelColors.bgBrightYellow, + green: labelColors.bgBrightGreen, + }[color]; + + const width = 7; + const textLength = Math.min(text.length, width); + const padding = width - textLength; + const leftPad = Math.ceil(padding / 2); + const rightPad = padding - leftPad; + const centeredText = + " ".repeat(leftPad) + text.substring(0, textLength) + " ".repeat(rightPad); + + return colorFn(` ${centeredText} `); +} + +/** + * Display commit list + */ +export function displayCommitList( + commits: CommitInfo[], + startIndex: number, + totalFetched: number, + hasMore: boolean, + hasPreviousPage: boolean = false, + hasMorePages: boolean = false, +): void { + console.log(); + console.log( + `${label("preview", "cyan")} ${textColors.pureWhite("Commit History")}`, + ); + console.log(); + + if (commits.length === 0) { + console.log(" No commits found."); + return; + } + + // Display commits with number shortcuts + const displayCount = Math.min(commits.length, 10); + for (let i = 0; i < displayCount; i++) { + const commit = commits[i]; + const number = i.toString(); + const mergeIndicator = commit.isMerge ? " [Merge]" : ""; + const truncatedSubject = + commit.subject.length > 50 + ? commit.subject.substring(0, 47) + "..." + : commit.subject; + + console.log( + ` ${textColors.brightCyan(`[${number}]`)} ${textColors.brightWhite(commit.shortHash)} ${truncatedSubject}${mergeIndicator}`, + ); + console.log( + ` ${textColors.white(commit.author.name)} • ${textColors.white(commit.date.relative)}`, + ); + } + + // Pagination info + const endIndex = startIndex + displayCount; + console.log(); + + if (hasMore) { + console.log( + ` Showing commits ${startIndex + 1}-${endIndex} of ${totalFetched}+`, + ); + } else { + console.log( + ` Showing commits ${startIndex + 1}-${endIndex} of ${totalFetched}`, + ); + } + console.log(); + + // Build navigation hints + const navHints: string[] = []; + navHints.push(`${textColors.brightCyan("0-9")} ${textColors.white("to view details")}`); + if (hasPreviousPage) { + 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("?")} ${textColors.white("for help")}`); + navHints.push(`${textColors.brightYellow("Esc")} ${textColors.white("to exit")}`); + + console.log( + ` ${textColors.white("Press")} ${navHints.join(`, `)}`, + ); +} + +/** + * Display commit details + */ +export function displayCommitDetails( + commit: CommitInfo, + showBody: boolean = true, + showFiles: boolean = true, +): void { + console.log(); + console.log( + `${label("detail", "green")} ${textColors.pureWhite("Commit Details")}`, + ); + console.log(); + console.log(` ${textColors.brightWhite("Hash:")} ${commit.hash}`); + console.log(` ${textColors.brightWhite("Subject:")} ${commit.subject}`); + console.log(); + 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(); + + if (commit.parents.length > 0) { + console.log(` ${textColors.brightWhite("Parents:")}`); + commit.parents.forEach((parent) => { + console.log(` ${parent.substring(0, 7)}`); + }); + console.log(); + } + + if (commit.isMerge) { + console.log(` ${textColors.brightYellow("⚠ This is a merge commit")}`); + console.log(); + } + + if (commit.fileStats) { + 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}`)}`); + } + if (commit.fileStats.deletions !== undefined) { + console.log(` Deletions: ${textColors.gitDeleted(`-${commit.fileStats.deletions}`)}`); + } + console.log(); + } + + if (showBody) { + if (commit.body) { + console.log(` ${textColors.brightWhite("Body:")}`); + const bodyLines = commit.body.split("\n"); + bodyLines.forEach((line) => { + console.log(` ${line}`); + }); + console.log(); + } else { + console.log(` ${textColors.white("Body:")} No body`); + console.log(); + } + } + + if (showFiles) { + if (commit.files && commit.files.length > 0) { + console.log(` ${textColors.brightWhite("Changed Files:")}`); + commit.files.slice(0, 20).forEach((file) => { + console.log(` ${file}`); + }); + if (commit.files.length > 20) { + console.log(` ... and ${commit.files.length - 20} more`); + } + console.log(); + } + } +} + +/** + * Display help + */ +export function displayHelp(): void { + console.log(); + console.log( + `${label("help", "yellow")} ${textColors.pureWhite("Keyboard Shortcuts")}`, + ); + console.log(); + console.log(` ${textColors.brightCyan("0-9")} View commit details`); + console.log(` ${textColors.brightYellow("p")} Jump to previous batch`); + console.log(` ${textColors.brightYellow("n")} Jump to next batch`); + console.log(` ${textColors.brightYellow("b")} View/toggle body`); + console.log(` ${textColors.brightYellow("f")} View/toggle files`); + console.log(` ${textColors.brightYellow("d")} View diff`); + console.log(` ${textColors.brightYellow("r")} Revert this commit`); + console.log(` ${textColors.brightYellow("←/Esc")} Back to list`); + console.log(` ${textColors.brightYellow("?")} Show this help`); + console.log(` ${textColors.brightYellow("q")} Exit`); + console.log(); +} + +/** + * Wait for user input in detail view + */ +export async function waitForDetailAction(): Promise< + "back" | "body" | "files" | "diff" | "revert" | "help" | "exit" +> { + return new Promise((resolve) => { + const stdin = process.stdin; + const wasRaw = stdin.isRaw; + + if (!wasRaw) { + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding("utf8"); + } + + readline.emitKeypressEvents(stdin); + + const onKeypress = (char: string, key: readline.Key) => { + if (key.name === "escape" || (key.ctrl && key.name === "c")) { + cleanup(); + resolve("exit"); + return; + } + + if (key.name === "left" || (key.name === "escape" && !key.ctrl)) { + cleanup(); + resolve("back"); + return; + } + + if (char === "b" || char === "B") { + cleanup(); + resolve("body"); + return; + } + + if (char === "f" || char === "F") { + cleanup(); + resolve("files"); + return; + } + + if (char === "d" || char === "D") { + cleanup(); + resolve("diff"); + return; + } + + if (char === "r" || char === "R") { + cleanup(); + resolve("revert"); + return; + } + + if (char === "?") { + cleanup(); + resolve("help"); + return; + } + + if (char === "q" || char === "Q") { + cleanup(); + resolve("exit"); + return; + } + }; + + const cleanup = () => { + stdin.removeListener("keypress", onKeypress); + if (!wasRaw) { + stdin.setRawMode(false); + stdin.pause(); + } + }; + + stdin.on("keypress", onKeypress); + }); +} + +/** + * Wait for user input in list view + */ +export async function waitForListAction( + maxIndex: number, + hasMorePages: boolean, + hasPreviousPage: boolean = false, +): Promise { + return new Promise((resolve) => { + const stdin = process.stdin; + const wasRaw = stdin.isRaw; + + if (!wasRaw) { + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding("utf8"); + } + + readline.emitKeypressEvents(stdin); + + const onKeypress = (char: string, key: readline.Key) => { + if (key.name === "escape" || (key.ctrl && key.name === "c")) { + cleanup(); + resolve("exit"); + return; + } + + // Number keys 0-9 + if (/^[0-9]$/.test(char)) { + const num = parseInt(char, 10); + if (num <= maxIndex) { + cleanup(); + resolve(num); + return; + } + } + + // Previous batch - allow if there's a previous page + if ((char === "p" || char === "P") && hasPreviousPage) { + cleanup(); + resolve("previous"); + return; + } + + // Next batch - allow if there are more pages to show + if ((char === "n" || char === "N") && hasMorePages) { + cleanup(); + resolve("next"); + return; + } + + // Help + if (char === "?") { + cleanup(); + resolve("help"); + return; + } + + // Exit + if (char === "q" || char === "Q") { + cleanup(); + resolve("exit"); + return; + } + }; + + const cleanup = () => { + stdin.removeListener("keypress", onKeypress); + if (!wasRaw) { + stdin.setRawMode(false); + stdin.pause(); + } + }; + + stdin.on("keypress", onKeypress); + }); +} + diff --git a/src/cli/commands/preview/types.ts b/src/cli/commands/preview/types.ts new file mode 100644 index 0000000..7104bed --- /dev/null +++ b/src/cli/commands/preview/types.ts @@ -0,0 +1,14 @@ +/** + * Preview Command Types + */ + +export interface PreviewState { + commits: import("../shared/types.js").CommitInfo[]; + currentPage: number; + pageSize: number; + totalFetched: number; + maxCommits: number; + hasMore: boolean; + currentIndex: number; +} + diff --git a/src/cli/commands/revert/index.ts b/src/cli/commands/revert/index.ts new file mode 100644 index 0000000..9a136f7 --- /dev/null +++ b/src/cli/commands/revert/index.ts @@ -0,0 +1,592 @@ +/** + * Revert Command + * + * Select and revert commits using the project's commit workflow + */ + +import { Command } from "commander"; +import { Logger } from "../../../lib/logger.js"; +import { loadConfig } from "../../../lib/config/index.js"; +import { + isGitRepository, + getCurrentBranch, + fetchCommits, + getCommitDetails, + isMergeCommit, + getMergeParents, + hasUncommittedChanges, +} from "../shared/git-operations.js"; +import { parseCommitMessage, generateRevertSubject } from "../shared/commit-parser.js"; +import type { CommitInfo } from "../shared/types.js"; +import { + displayRevertCommitList, + promptMergeParent, + displayRevertConfirmation, + promptRevertConfirmation, +} from "./prompts.js"; +import { + promptType, + promptScope, + promptSubject, + promptBody, + displayPreview, +} from "../commit/prompts.js"; +import { formatCommitMessage } from "../commit/formatter.js"; +import { createCommit } from "../commit/git.js"; +import { spawnSync } from "child_process"; +import readline from "readline"; +import { textColors, success, attention } from "../init/colors.js"; + +/** + * Clear terminal screen + */ +function clearTerminal(): void { + if (process.stdout.isTTY) { + process.stdout.write("\x1B[2J"); + process.stdout.write("\x1B[H"); + } +} + +/** + * Execute git revert command + */ +function execGitRevert(hash: string, parentNumber?: number): void { + const args = ["revert", "--no-edit"]; // We'll create our own commit message + + if (parentNumber !== undefined) { + args.push("-m", parentNumber.toString()); + } + + args.push(hash); + + const result = spawnSync("git", args, { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.status !== 0) { + const stderr = result.stderr?.toString() || "Unknown error"; + throw new Error(`Git revert failed: ${stderr}`); + } +} + +/** + * Check if revert is in progress + */ +function isRevertInProgress(): boolean { + try { + const result = spawnSync("git", ["status"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + return result.stdout?.toString().includes("revert") || false; + } catch { + return false; + } +} + +/** + * Continue revert after conflict resolution + */ +function continueRevert(): void { + const result = spawnSync("git", ["revert", "--continue"], { + encoding: "utf-8", + stdio: "inherit", + }); + + if (result.status !== 0) { + throw new Error("Failed to continue revert"); + } +} + +/** + * Abort revert + */ +function abortRevert(): void { + const result = spawnSync("git", ["revert", "--abort"], { + encoding: "utf-8", + stdio: "inherit", + }); + + if (result.status !== 0) { + throw new Error("Failed to abort revert"); + } +} + +/** + * Revert a specific commit (called from preview or directly) + */ +export async function revertCommit( + commitHash: string, + options?: { noEdit?: boolean; parentNumber?: number }, +): Promise { + try { + // Load config + const configResult = await loadConfig(); + if (configResult.source === "defaults") { + Logger.error("Configuration not found"); + console.error("\n Run 'lab init' to create configuration file.\n"); + process.exit(1); + } + + const config = configResult.config; + + // Get commit details + const commit = getCommitDetails(commitHash); + + // Check if merge commit + let parentNumber = options?.parentNumber; + if (commit.isMerge && parentNumber === undefined) { + const parents = getMergeParents(commitHash); + if (parents.length > 1) { + clearTerminal(); + parentNumber = await promptMergeParent(parents); + } else { + parentNumber = 1; // Default to first parent + } + } + + // Show confirmation + clearTerminal(); + displayRevertConfirmation(commit); + + let useWorkflow = !options?.noEdit; + if (useWorkflow) { + const confirmResult = await promptRevertConfirmation(); + if (confirmResult === "cancel") { + console.log("\n Revert cancelled.\n"); + process.exit(0); + } + useWorkflow = confirmResult === "edit"; + } + + // Execute revert using commit workflow + if (useWorkflow) { + // Parse original commit message + const parsed = parseCommitMessage(commit.subject); + const parsedBody = commit.body ? parseCommitMessage(commit.body) : null; + + // Determine type + let type: string; + let emoji: string | undefined; + const revertType = config.types.find((t) => t.id === "revert"); + if (revertType) { + type = "revert"; + emoji = revertType.emoji; + } else { + // Let user select type + clearTerminal(); + const typeResult = await promptType(config); + type = typeResult.type; + emoji = typeResult.emoji; + } + + // Determine scope + let scope: string | undefined; + if (parsed.scope) { + // Pre-fill with extracted scope + scope = await promptScope(config, type, undefined, parsed.scope); + } else { + scope = await promptScope(config, type); + } + + // Determine subject + const maxLength = config.format.subject_max_length; + const defaultSubject = parsed.parseSuccess + ? generateRevertSubject(parsed.subject, maxLength) + : generateRevertSubject(commit.subject, maxLength); + + clearTerminal(); + let subject = await promptSubject(config, undefined, defaultSubject); + + // Determine body + let body: string | undefined; + if (config.format.body.required) { + const defaultBody = `This reverts commit ${commit.hash}.`; + body = await promptBody(config, undefined, defaultBody); + } else { + // Optional body - pre-fill but allow skip + const defaultBody = `This reverts commit ${commit.hash}.`; + body = await promptBody(config, undefined, defaultBody); + } + + // Preview + clearTerminal(); + let formattedMessage = formatCommitMessage( + config, + type, + emoji, + scope, + subject, + ); + + let action: "commit" | "edit-type" | "edit-scope" | "edit-subject" | "edit-body" | "cancel"; + + do { + // Regenerate formatted message with current values + formattedMessage = formatCommitMessage( + config, + type, + emoji, + scope, + subject, + ); + + action = await displayPreview(formattedMessage, body, config); + + 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); + if (isScopeRequired && !scope) { + scope = await promptScope(config, type, undefined, scope); + } + } else if (action === "edit-scope") { + scope = await promptScope(config, type, undefined, scope); + } else if (action === "edit-subject") { + subject = await promptSubject(config, undefined, subject); + } else if (action === "edit-body") { + body = await promptBody(config, body); + } else if (action === "cancel") { + console.log("\n Revert cancelled.\n"); + process.exit(0); + } + + if (action !== "commit") { + clearTerminal(); + } + } while (action !== "commit"); + + // Execute revert + console.log(); + console.log("◐ Reverting commit..."); + + try { + // First, do the git revert (this stages the changes) + execGitRevert(commitHash, parentNumber); + + // Now amend the commit with our formatted message + const args = ["commit", "--amend", "-m", formattedMessage]; + if (body) { + args.push("-m", body); + } + if (config.advanced.git.sign_commits) { + args.push("-S"); + } + + const amendResult = spawnSync("git", args, { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (amendResult.status !== 0) { + throw new Error(`Failed to amend commit: ${amendResult.stderr?.toString() || "Unknown error"}`); + } + + // Get commit hash + const hashResult = spawnSync("git", ["rev-parse", "HEAD"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + const revertHash = hashResult.stdout?.toString().trim().substring(0, 7) || "unknown"; + + console.log(`${success("✓")} Revert commit created successfully!`); + console.log(` ${revertHash} ${formattedMessage}`); + } 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(); + 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`); + process.exit(1); + } + throw error; + } + } else { + // Use Git's default revert message + console.log(); + console.log("◐ Reverting commit..."); + + try { + execGitRevert(commitHash, parentNumber); + const hashResult = spawnSync("git", ["rev-parse", "HEAD"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + 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(); + 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`); + process.exit(1); + } + throw error; + } + } + } catch (error: unknown) { + Logger.error("Failed to revert commit"); + if (error instanceof Error) { + console.error(`\n ${error.message}\n`); + } + process.exit(1); + } +} + +/** + * Revert action handler + */ +async function revertAction(options: { + limit?: number; + branch?: string; + noEdit?: boolean; + continue?: boolean; + abort?: boolean; +}): Promise { + try { + // Handle continue/abort flags + if (options.continue) { + continueRevert(); + return; + } + + if (options.abort) { + abortRevert(); + return; + } + + // Check git repository + if (!isGitRepository()) { + Logger.error("Not a git repository"); + console.error("\n Initialize git first: git init\n"); + process.exit(1); + } + + // 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"); + process.exit(1); + } + } + + // Check for uncommitted changes + if (hasUncommittedChanges()) { + console.log(); + console.log( + `${attention("⚠ You have uncommitted changes.")}`, + ); + console.log(" Revert may cause conflicts."); + console.log(); + } + + const currentBranch = getCurrentBranch(); + if (!currentBranch) { + Logger.error("Could not determine current branch"); + process.exit(1); + } + + const branch = options.branch || currentBranch; + const maxCommits = Math.min(parseInt(options.limit?.toString() || "50", 10), 100); + const pageSize = 10; + + // Initial fetch + let allCommits: CommitInfo[] = []; + let totalFetched = 0; + let hasMore = true; + + const loadMoreCommits = async (): Promise => { + if (totalFetched >= maxCommits) { + hasMore = false; + return; + } + + 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 newCommits = fetchCommits(toFetch, branch, lastHash); + allCommits = [...allCommits, ...newCommits]; + totalFetched = allCommits.length; + hasMore = newCommits.length === 50 && totalFetched < maxCommits; + }; + + await loadMoreCommits(); + + if (allCommits.length === 0) { + console.log("\n No commits found in current branch.\n"); + process.exit(0); + } + + // Interactive selection + let selectedCommit: CommitInfo | null = null; + let currentPage = 0; + + while (!selectedCommit) { + clearTerminal(); + const startIndex = currentPage * pageSize; + const endIndex = Math.min(startIndex + pageSize, allCommits.length); + 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 hasPreviousPage = currentPage > 0; + + displayRevertCommitList(pageCommits, startIndex, totalFetched, hasMore, hasPreviousPage, hasMorePages); + + // Build navigation hints + const navHints: string[] = []; + navHints.push(`${textColors.brightCyan("0-9")} ${textColors.white("to select commit")}`); + if (hasPreviousPage) { + 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("Esc")} ${textColors.white("to cancel")}`); + + console.log( + ` ${textColors.white("Press")} ${navHints.join(`, `)}`, + ); + + // Wait for input + const stdin = process.stdin; + const wasRaw = stdin.isRaw; + + if (!wasRaw) { + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding("utf8"); + } + + readline.emitKeypressEvents(stdin); + + const selection = await new Promise((resolve) => { + const onKeypress = (char: string, key: readline.Key) => { + if (key.name === "escape" || (key.ctrl && key.name === "c")) { + cleanup(); + resolve("cancel"); + return; + } + + if (/^[0-9]$/.test(char)) { + const num = parseInt(char, 10); + if (num < pageCommits.length) { + cleanup(); + resolve(num); + return; + } + } + + // Previous batch - allow if there's a previous page + if ((char === "p" || char === "P") && hasPreviousPage) { + cleanup(); + resolve("previous"); + return; + } + + // Next batch - allow if there are more pages to show + if ((char === "n" || char === "N") && hasMorePages) { + cleanup(); + resolve("next"); + return; + } + }; + + const cleanup = () => { + stdin.removeListener("keypress", onKeypress); + if (!wasRaw) { + stdin.setRawMode(false); + stdin.pause(); + } + }; + + stdin.on("keypress", onKeypress); + }); + + if (selection === "cancel") { + console.log("\n Revert cancelled.\n"); + process.exit(0); + } else if (selection === "previous") { + // Move to previous page + if (currentPage > 0) { + currentPage--; + } + } 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..."); + await loadMoreCommits(); + if (!hasMore && nextPageStart >= allCommits.length) { + console.log(" Maximum commits loaded (100)."); + await new Promise((resolve) => setTimeout(resolve, 1000)); + // Don't increment page if we can't show it + continue; + } + } + + // Increment page if we have commits to show + if (nextPageStart < allCommits.length) { + currentPage++; + } + } else if (typeof selection === "number") { + selectedCommit = pageCommits[selection]; + // Load full details if needed + if (!selectedCommit.body || !selectedCommit.fileStats) { + try { + selectedCommit = getCommitDetails(selectedCommit.hash); + } catch (error) { + Logger.error(`Failed to load commit details: ${error}`); + selectedCommit = null; + continue; + } + } + } + } + + if (selectedCommit) { + await revertCommit(selectedCommit.hash, { noEdit: options.noEdit }); + } + } catch (error: unknown) { + Logger.error("Failed to revert commit"); + if (error instanceof Error) { + console.error(`\n ${error.message}\n`); + } + process.exit(1); + } +} + +/** + * Revert command + */ +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("--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 new file mode 100644 index 0000000..3c3165f --- /dev/null +++ b/src/cli/commands/revert/prompts.ts @@ -0,0 +1,166 @@ +/** + * Revert Command Prompts + * + * Interactive prompts for reverting commits + */ + +import { select, confirm, isCancel } from "@clack/prompts"; +import { labelColors, textColors, success, attention } from "../init/colors.js"; +import type { CommitInfo, MergeParent } from "../shared/types.js"; + +/** + * Create compact color-coded label + */ +function label( + text: string, + color: "magenta" | "cyan" | "blue" | "yellow" | "green", +): string { + const colorFn = { + magenta: labelColors.bgBrightMagenta, + cyan: labelColors.bgBrightCyan, + blue: labelColors.bgBrightBlue, + yellow: labelColors.bgBrightYellow, + green: labelColors.bgBrightGreen, + }[color]; + + const width = 7; + const textLength = Math.min(text.length, width); + const padding = width - textLength; + const leftPad = Math.ceil(padding / 2); + const rightPad = padding - leftPad; + const centeredText = + " ".repeat(leftPad) + text.substring(0, textLength) + " ".repeat(rightPad); + + return colorFn(` ${centeredText} `); +} + +/** + * Handle prompt cancellation + */ +function handleCancel(value: unknown): void { + if (isCancel(value)) { + console.log("\nRevert cancelled."); + process.exit(0); + } +} + +/** + * Display commit list for revert + */ +export function displayRevertCommitList( + commits: CommitInfo[], + startIndex: number, + totalFetched: number, + hasMore: boolean, + hasPreviousPage: boolean = false, + hasMorePages: boolean = false, +): void { + console.log(); + console.log( + `${label("revert", "yellow")} ${textColors.pureWhite("Select Commit to Revert")}`, + ); + console.log(); + + if (commits.length === 0) { + console.log(" No commits found."); + return; + } + + const displayCount = Math.min(commits.length, 10); + for (let i = 0; i < displayCount; i++) { + const commit = commits[i]; + const number = i.toString(); + const mergeIndicator = commit.isMerge ? " [Merge]" : ""; + const truncatedSubject = + commit.subject.length > 50 + ? commit.subject.substring(0, 47) + "..." + : commit.subject; + + console.log( + ` ${textColors.brightCyan(`[${number}]`)} ${textColors.brightWhite(commit.shortHash)} ${truncatedSubject}${mergeIndicator}`, + ); + console.log( + ` ${textColors.white(commit.author.name)} • ${textColors.white(commit.date.relative)}`, + ); + } + + // Pagination info + const endIndex = startIndex + displayCount; + console.log(); + + if (hasMore) { + console.log( + ` Showing commits ${startIndex + 1}-${endIndex} of ${totalFetched}+`, + ); + } else { + console.log( + ` Showing commits ${startIndex + 1}-${endIndex} of ${totalFetched}`, + ); + } + console.log(); +} + +/** + * Prompt for merge commit parent selection + */ +export async function promptMergeParent( + parents: MergeParent[], +): Promise { + const options = parents.map((parent) => ({ + value: parent.number.toString(), + label: `Parent ${parent.number}${parent.branch ? `: ${parent.branch}` : ""} (${parent.shortHash})${parent.number === 1 ? " [mainline, default]" : ""}`, + })); + + const selected = await select({ + message: `${label("parent", "blue")} ${textColors.pureWhite("Select parent to revert to:")}`, + options, + initialValue: "1", // Default to parent 1 + }); + + handleCancel(selected); + return parseInt(selected as string, 10); +} + +/** + * Display revert confirmation + */ +export function displayRevertConfirmation(commit: CommitInfo): 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(); + console.log( + ` ${attention("This will create a new commit that undoes these changes.")}`, + ); + console.log(); +} + +/** + * Prompt for revert confirmation + */ +export async function promptRevertConfirmation(): Promise<"confirm" | "edit" | "cancel"> { + const confirmed = await confirm({ + message: `${label("confirm", "green")} ${textColors.pureWhite("Proceed with revert?")}`, + initialValue: true, + }); + + handleCancel(confirmed); + + if (confirmed) { + // Ask if user wants to edit commit message + const edit = await confirm({ + message: `${label("edit", "yellow")} ${textColors.pureWhite("Edit commit message before reverting?")}`, + initialValue: false, + }); + + handleCancel(edit); + return edit ? "edit" : "confirm"; + } + + return "cancel"; +} + diff --git a/src/cli/commands/revert/types.ts b/src/cli/commands/revert/types.ts new file mode 100644 index 0000000..de4c42c --- /dev/null +++ b/src/cli/commands/revert/types.ts @@ -0,0 +1,13 @@ +/** + * Revert Command Types + */ + +import type { CommitInfo, MergeParent } from "../shared/types.js"; + +export interface RevertState { + selectedCommit: CommitInfo; + parentNumber?: number; // For merge commits (1, 2, etc.) + 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 new file mode 100644 index 0000000..d28b8fc --- /dev/null +++ b/src/cli/commands/shared/commit-parser.ts @@ -0,0 +1,99 @@ +/** + * Commit Message Parser + * + * Parses commit messages to extract type, scope, and subject + * for use in revert commit templates + */ + +import type { ParsedCommit } from "./types.js"; + +/** + * Parse commit message following conventional commits format + * Format: {emoji}{type}({scope}): {subject} + */ +export function parseCommitMessage(message: string): ParsedCommit { + if (!message || !message.trim()) { + return { + subject: message || "", + parseSuccess: false, + }; + } + + // Remove leading/trailing whitespace + const trimmed = message.trim(); + + // Try to match: {emoji}{type}({scope}): {subject} + // Emoji is optional, scope is optional + const pattern1 = /^(?:\p{Emoji}*\s*)?(\w+)(?:\(([^)]+)\))?:\s*(.+)$/u; + const match1 = trimmed.match(pattern1); + + if (match1) { + const [, type, scope, subject] = match1; + return { + type: type.toLowerCase(), + scope: scope || undefined, + subject: subject.trim(), + parseSuccess: true, + }; + } + + // Try to match: {type}({scope}): {subject} (no emoji) + const pattern2 = /^(\w+)(?:\(([^)]+)\))?:\s*(.+)$/; + const match2 = trimmed.match(pattern2); + + if (match2) { + const [, type, scope, subject] = match2; + return { + type: type.toLowerCase(), + scope: scope || undefined, + subject: subject.trim(), + parseSuccess: true, + }; + } + + // Try to match: {type}: {subject} (no scope) + const pattern3 = /^(\w+):\s*(.+)$/; + const match3 = trimmed.match(pattern3); + + if (match3) { + const [, type, subject] = match3; + return { + type: type.toLowerCase(), + subject: subject.trim(), + parseSuccess: true, + }; + } + + // If no pattern matches, return entire message as subject + return { + subject: trimmed, + parseSuccess: false, + }; +} + +/** + * Generate revert subject following industry standards + */ +export function generateRevertSubject( + originalSubject: string, + maxLength: number, +): string { + // Industry standard: Revert "original subject" + let base = `Revert "${originalSubject}"`; + + // Handle quotes: if double quotes in subject, use single quotes + if (originalSubject.includes('"')) { + base = `Revert '${originalSubject}'`; + } + + // Truncate if too long + if (base.length > maxLength) { + // Reserve space for "Revert \"...\"" + const availableLength = maxLength - 15; // "Revert \"...\"" + 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 new file mode 100644 index 0000000..676014f --- /dev/null +++ b/src/cli/commands/shared/git-operations.ts @@ -0,0 +1,308 @@ +/** + * Shared Git Operations + * + * Common Git operations used by preview and revert commands + */ + +import { spawnSync } from "child_process"; +import { Logger } from "../../../lib/logger.js"; +import type { CommitInfo, MergeParent } from "./types.js"; + +/** + * Execute git command and return stdout + */ +function execGit(args: string[]): string { + try { + const result = spawnSync("git", args, { + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + const stderr = result.stderr?.toString() || "Unknown error"; + const error = new Error(stderr); + (error as any).code = result.status; + throw error; + } + + return result.stdout?.toString().trim() || ""; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + Logger.error(`Git command failed: git ${args.join(" ")}`); + Logger.error(errorMessage); + throw error; + } +} + +/** + * Check if current directory is a git repository + */ +export function isGitRepository(): boolean { + try { + execGit(["rev-parse", "--git-dir"]); + return true; + } catch { + return false; + } +} + +/** + * Get current branch name + */ +export function getCurrentBranch(): string | null { + try { + return execGit(["rev-parse", "--abbrev-ref", "HEAD"]); + } catch { + return null; + } +} + +/** + * Format relative time (e.g., "2 hours ago") + */ +function formatRelativeTime(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + const diffWeeks = Math.floor(diffDays / 7); + 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 (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`; + return `${diffYears} year${diffYears !== 1 ? "s" : ""} ago`; +} + +/** + * Fetch commits from git log + */ +export function fetchCommits( + limit: number, + branch?: string, + excludeHash?: string, +): CommitInfo[] { + const args = [ + "log", + "--max-count", + limit.toString(), + "--format=%H|%s|%an|%ae|%ai|%P", + "--date=iso", + ]; + + // To get commits older than excludeHash, we fetch starting from excludeHash's parent + // excludeHash^ means "the parent of excludeHash" (which is older in the history) + // This gives us commits that are older than the last one we've seen + if (excludeHash) { + // Fetch from the parent of the last commit we've seen + // This will get commits older than excludeHash + const startPoint = `${excludeHash}^`; + args.push(startPoint); + // If branch is specified, we still want to limit to that branch + // But since we're starting from an older commit, we'll naturally get commits on the same branch + // (unless there are merges, but that's fine - we want all commits) + } else if (branch) { + args.push(branch); + } + + const output = execGit(args); + if (!output) return []; + + const commits: CommitInfo[] = []; + const lines = output.split("\n").filter((l) => l.trim()); + + for (const line of lines) { + const parts = line.split("|"); + if (parts.length < 6) continue; + + const [hash, subject, authorName, authorEmail, dateStr, parentsStr] = parts; + const shortHash = hash.substring(0, 7); + const parents = parentsStr ? parentsStr.trim().split(/\s+/) : []; + const isMerge = parents.length > 1; + const date = new Date(dateStr); + + commits.push({ + hash, + shortHash, + subject: subject || "(no subject)", + body: null, // Will be fetched lazily + author: { + name: authorName || "Unknown", + email: authorEmail || "", + }, + date: { + absolute: date.toISOString(), + relative: formatRelativeTime(date), + }, + parents, + isMerge, + }); + } + + return commits; +} + +/** + * Get detailed commit information + */ +export function getCommitDetails(hash: string): CommitInfo { + // Get basic info + const logOutput = execGit([ + "log", + "-1", + "--format=%H|%s|%an|%ae|%ai|%P", + "--date=iso", + hash, + ]); + + if (!logOutput) { + throw new Error(`Commit ${hash} not found`); + } + + const parts = logOutput.split("|"); + if (parts.length < 6) { + throw new Error(`Invalid commit format: ${hash}`); + } + + 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; + const date = new Date(dateStr); + + // Get body - need to exclude the subject line + const bodyOutput = execGit(["log", "-1", "--format=%B", hash]); + let body: string | null = null; + + if (bodyOutput) { + const trimmed = bodyOutput.trim(); + // Split by first blank line (subject is first line, body is after) + const parts = trimmed.split(/\n\n/, 2); + // If there's content after the first blank line, that's the body + body = parts.length > 1 && parts[1].trim() ? parts[1].trim() : null; + } + + // Get file stats + const statOutput = execGit(["show", "--stat", "--format=", hash]); + let fileStats: CommitInfo["fileStats"] | undefined; + 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)?)?/); + if (match) { + fileStats = { + filesChanged: parseInt(match[1], 10) || 0, + additions: match[2] ? parseInt(match[2], 10) : undefined, + deletions: match[3] ? parseInt(match[3], 10) : undefined, + }; + } + } + + // Get changed files + const filesOutput = execGit(["show", "--name-only", "--format=", hash]); + const files = filesOutput + ? filesOutput.split("\n").filter((l) => l.trim()) + : undefined; + + return { + hash: fullHash, + shortHash, + subject: subject || "(no subject)", + body, + author: { + name: authorName || "Unknown", + email: authorEmail || "", + }, + date: { + absolute: date.toISOString(), + relative: formatRelativeTime(date), + }, + parents, + isMerge, + fileStats, + files, + }; +} + +/** + * Check if commit is a merge commit + */ +export function isMergeCommit(hash: string): boolean { + try { + const parents = execGit(["log", "-1", "--format=%P", hash]); + return parents.trim().split(/\s+/).length > 1; + } catch { + return false; + } +} + +/** + * Get merge commit parents + */ +export function getMergeParents(hash: string): MergeParent[] { + try { + const parentsStr = execGit(["log", "-1", "--format=%P", hash]); + const parentHashes = parentsStr.trim().split(/\s+/).filter((h) => h); + + return parentHashes.map((parentHash, index) => { + const shortHash = parentHash.substring(0, 7); + // Try to get branch name + let branch: string | undefined; + try { + const branchOutput = execGit([ + "branch", + "--contains", + parentHash, + "--format=%(refname:short)", + ]); + const branches = branchOutput.split("\n").filter((b) => b.trim()); + branch = branches[0] || undefined; + } catch { + // Branch name not available + } + + return { + number: index + 1, + branch, + shortHash, + hash: parentHash, + }; + }); + } catch { + return []; + } +} + +/** + * Get commit diff + */ +export function getCommitDiff(hash: string): string { + try { + return execGit(["show", hash]); + } catch { + return ""; + } +} + +/** + * Check if there are uncommitted changes + */ +export function hasUncommittedChanges(): boolean { + try { + const status = execGit(["status", "--porcelain"]); + return status.trim().length > 0; + } catch { + return false; + } +} + diff --git a/src/cli/commands/shared/types.ts b/src/cli/commands/shared/types.ts new file mode 100644 index 0000000..7898745 --- /dev/null +++ b/src/cli/commands/shared/types.ts @@ -0,0 +1,86 @@ +/** + * Shared Types for Preview and Revert Commands + * + * Common type definitions used by both preview and revert commands + */ + +/** + * Commit information structure + */ +export interface CommitInfo { + /** Full commit hash */ + hash: string; + /** Short hash (7 characters) */ + shortHash: string; + /** Full subject line */ + subject: string; + /** Commit body or null if no body */ + body: string | null; + /** Author information */ + author: { + name: string; + email: string; + }; + /** Date information */ + date: { + absolute: string; // ISO format + relative: string; // "2 hours ago" + }; + /** Parent commit hashes */ + parents: string[]; + /** True if this is a merge commit */ + isMerge: boolean; + /** File statistics (optional) */ + fileStats?: { + filesChanged: number; + additions?: number; + deletions?: number; + }; + /** Changed file paths (optional, lazy-loaded) */ + files?: string[]; +} + +/** + * Commit batch for pagination + */ +export interface CommitBatch { + /** Commits in this batch */ + commits: CommitInfo[]; + /** 0-based index of first commit */ + startIndex: number; + /** Total commits fetched so far */ + totalFetched: number; + /** More commits available beyond max */ + hasMore: boolean; +} + +/** + * Parsed commit message components + */ +export interface ParsedCommit { + /** Extracted type from original commit */ + type?: string; + /** Extracted scope from original commit */ + scope?: string; + /** Original subject */ + subject: string; + /** Original body */ + body?: string; + /** Whether parsing succeeded */ + parseSuccess: boolean; +} + +/** + * Merge commit parent information + */ +export interface MergeParent { + /** Parent number (1, 2, etc.) */ + number: number; + /** Branch name if available */ + branch?: string; + /** Short hash */ + shortHash: string; + /** Full hash */ + hash: string; +} + diff --git a/src/cli/commands/test/index.ts b/src/cli/commands/test/index.ts new file mode 100644 index 0000000..c26de8c --- /dev/null +++ b/src/cli/commands/test/index.ts @@ -0,0 +1,289 @@ +/** + * Test Command + * + * Manages test environment for testing Labcommitr commands + */ + +import { Command } from "commander"; +import { Logger } from "../../../lib/logger.js"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; +import { existsSync } from "fs"; +import { spawnSync } from "child_process"; +import { generateScenario } from "./scenario-generator.js"; +import { + getSandboxPath, + loadState, + saveState, + clearState, + isSandboxValid, +} from "./state-manager.js"; +import { SCENARIOS, DEFAULT_SCENARIO, listScenarios, getScenario } from "./scenarios.js"; +import type { ScenarioName } from "./types.js"; +import { textColors, success, attention } from "../init/colors.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const PROJECT_ROOT = join(__dirname, "../../../../"); + +/** + * Build project if needed + */ +async function ensureBuilt(): Promise { + const distPath = join(PROJECT_ROOT, "dist", "index.js"); + if (!existsSync(distPath)) { + console.log("◐ Building project..."); + const result = spawnSync("pnpm", ["run", "build"], { + cwd: PROJECT_ROOT, + stdio: "inherit", + }); + if (result.status !== 0) { + throw new Error("Build failed"); + } + } +} + +/** + * Setup scenario + */ +async function setupAction(options: { scenario?: string }): Promise { + try { + await ensureBuilt(); + + const scenarioName = (options.scenario || DEFAULT_SCENARIO) as ScenarioName; + const scenario = getScenario(scenarioName); + + if (!scenario) { + Logger.error(`Invalid scenario: ${scenarioName}`); + console.error("\n Available scenarios:"); + listScenarios().forEach((s) => { + console.error(` • ${s.name} - ${s.description}`); + }); + console.error(); + process.exit(1); + } + + const sandboxPath = getSandboxPath(PROJECT_ROOT); + + console.log(); + console.log( + `${textColors.brightCyan("◐")} Setting up scenario: ${textColors.brightWhite(scenarioName)}`, + ); + console.log(` ${scenario.description}`); + console.log(); + + // Generate scenario + await generateScenario(sandboxPath, scenarioName); + + // Save state + saveState(sandboxPath, scenarioName); + + console.log(); + console.log(`${success("✓")} Test environment ready!`); + console.log(); + console.log(` ${textColors.brightWhite("Sandbox:")} ${sandboxPath}`); + console.log(` ${textColors.brightWhite("Scenario:")} ${scenarioName}`); + console.log(); + console.log( + ` ${textColors.white("Run commands with:")} ${textColors.brightCyan("pnpm run dev:cli test shell")}`, + ); + console.log(); + } catch (error: unknown) { + Logger.error("Failed to setup test environment"); + if (error instanceof Error) { + console.error(`\n ${error.message}\n`); + } + process.exit(1); + } +} + +/** + * Reset scenario + */ +async function resetAction(): Promise { + try { + const sandboxPath = getSandboxPath(PROJECT_ROOT); + const state = loadState(sandboxPath); + + if (!state || !state.scenario) { + Logger.error("No active test environment found"); + console.error("\n Run 'pnpm run dev:cli test setup' first.\n"); + process.exit(1); + } + + console.log(); + console.log( + `${textColors.brightCyan("◐")} Resetting scenario: ${textColors.brightWhite(state.scenario)}`, + ); + console.log(); + + // Regenerate scenario + await generateScenario(sandboxPath, state.scenario); + + console.log(); + console.log(`${success("✓")} Scenario reset complete!`); + console.log(); + } catch (error: unknown) { + Logger.error("Failed to reset test environment"); + if (error instanceof Error) { + console.error(`\n ${error.message}\n`); + } + process.exit(1); + } +} + +/** + * Clean sandbox + */ +async function cleanAction(): Promise { + const sandboxPath = getSandboxPath(PROJECT_ROOT); + + if (!existsSync(sandboxPath)) { + console.log("\n No test environment to clean.\n"); + return; + } + + const { rmSync } = await import("fs"); + rmSync(sandboxPath, { recursive: true, force: true }); + + console.log(); + console.log(`${success("✓")} Test environment removed`); + console.log(); +} + +/** + * Show status + */ +async function statusAction(): Promise { + const sandboxPath = getSandboxPath(PROJECT_ROOT); + const state = loadState(sandboxPath); + + console.log(); + + 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(); + return; + } + + const scenario = getScenario(state.scenario!); + + console.log(` ${textColors.brightWhite("Scenario:")} ${state.scenario}`); + if (scenario) { + console.log(` ${textColors.brightWhite("Description:")} ${scenario.description}`); + } + console.log(` ${textColors.brightWhite("Sandbox:")} ${sandboxPath}`); + console.log(); + + // Show git status + const { execSync } = await import("child_process"); + try { + const gitStatus = execSync("git status --porcelain", { + cwd: sandboxPath, + encoding: "utf-8", + }).trim(); + + if (gitStatus) { + const lines = gitStatus.split("\n").length; + console.log(` ${textColors.brightWhite("Uncommitted changes:")} ${lines} file(s)`); + } else { + console.log(` ${textColors.brightWhite("Working directory:")} clean`); + } + } catch { + // Git status failed + } + + console.log(); +} + +/** + * List scenarios + */ +function listScenariosAction(): void { + console.log(); + console.log(` ${textColors.brightWhite("Available scenarios:")}`); + console.log(); + + listScenarios().forEach((scenario) => { + console.log(` ${textColors.brightCyan("•")} ${textColors.brightWhite(scenario.name)}`); + console.log(` ${textColors.white(scenario.description)}`); + console.log(); + }); +} + +/** + * Open shell in test environment + */ +function shellAction(): void { + const sandboxPath = getSandboxPath(PROJECT_ROOT); + + if (!isSandboxValid(sandboxPath)) { + Logger.error("No active test environment found"); + console.error("\n Run 'pnpm run dev:cli test setup' first.\n"); + process.exit(1); + } + + console.log(); + console.log( + `${textColors.brightCyan("◐")} Opening shell in test environment...`, + ); + console.log(); + console.log( + ` ${textColors.white("Sandbox:")} ${sandboxPath}`, + ); + console.log( + ` ${textColors.white("Exit with:")} ${textColors.brightCyan("exit")} or ${textColors.brightCyan("Ctrl+D")}`, + ); + console.log(); + + // Spawn shell + const shell = process.env.SHELL || "/bin/bash"; + spawnSync(shell, [], { + cwd: sandboxPath, + stdio: "inherit", + env: { + ...process.env, + PS1: `[lab-test] ${process.env.PS1 || "$ "}`, + }, + }); +} + +/** + * Test command + */ +export const testCommand = new Command("test") + .description("Manage test environment for testing Labcommitr commands") + .addCommand( + new Command("setup") + .description("Set up test environment with specified scenario") + .option("-s, --scenario ", "Scenario name", DEFAULT_SCENARIO) + .action(setupAction), + ) + .addCommand( + new Command("reset") + .description("Reset current scenario to initial state") + .action(resetAction), + ) + .addCommand( + new Command("clean") + .description("Remove test environment") + .action(cleanAction), + ) + .addCommand( + new Command("status") + .description("Show current test environment status") + .action(statusAction), + ) + .addCommand( + new Command("list-scenarios") + .description("List all available scenarios") + .action(listScenariosAction), + ) + .addCommand( + new Command("shell") + .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 new file mode 100644 index 0000000..04d1b26 --- /dev/null +++ b/src/cli/commands/test/scenario-generator.ts @@ -0,0 +1,433 @@ +/** + * Scenario Generator + * + * Generates test scenarios with appropriate git repository states + */ + +import { spawnSync } from "child_process"; +import { mkdirSync, writeFileSync, existsSync, rmSync } from "fs"; +import { join, relative } from "path"; +import type { ScenarioName } from "./types.js"; +import { loadConfig } from "../../../lib/config/index.js"; + +/** + * Execute git command in sandbox + */ +function execGit(sandboxPath: string, args: string[]): void { + const result = spawnSync("git", args, { + cwd: sandboxPath, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.error) { + throw new Error( + `Failed to execute git command: git ${args.join(" ")}\n${result.error.message}`, + ); + } + + if (result.status !== 0) { + // 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}`; + throw new Error( + `Git command failed: git ${args.join(" ")}\n${errorMessage}`, + ); + } +} + +/** + * Initialize git repository + */ +function initGit(sandboxPath: string): void { + // Ensure directory exists and is accessible + if (!existsSync(sandboxPath)) { + throw new Error(`Sandbox directory does not exist: ${sandboxPath}`); + } + + execGit(sandboxPath, ["init", "--initial-branch=main"]); + execGit(sandboxPath, ["config", "user.name", "Test User"]); + execGit(sandboxPath, ["config", "user.email", "test@example.com"]); +} + +/** + * Create initial commit structure + */ +function createInitialStructure(sandboxPath: string): void { + // Create README + writeFileSync( + join(sandboxPath, "README.md"), + "# Test Repository\n\nThis is a test repository for Labcommitr.\n", + ); + + // Create package.json + writeFileSync( + join(sandboxPath, "package.json"), + JSON.stringify( + { + name: "test-project", + version: "1.0.0", + description: "Test project for Labcommitr", + }, + null, + 2, + ), + ); + + execGit(sandboxPath, ["add", "."]); + execGit(sandboxPath, ["commit", "-m", "Initial commit", "--no-verify"]); +} + +/** + * Generate commit history + */ +function generateCommitHistory( + sandboxPath: string, + count: number, + includeMerges: boolean = false, +): void { + const commitTypes = ["feat", "fix", "docs", "refactor", "test", "chore"]; + const scopes = ["api", "ui", "auth", "db", "config", null]; + const subjects = [ + "add new feature", + "fix bug", + "update documentation", + "refactor code", + "add tests", + "update dependencies", + "improve performance", + "fix typo", + "update config", + "add validation", + ]; + + // Create some base files first + mkdirSync(join(sandboxPath, "src"), { recursive: true }); + mkdirSync(join(sandboxPath, "lib"), { recursive: true }); + mkdirSync(join(sandboxPath, "docs"), { recursive: true }); + + for (let i = 0; i < count; i++) { + const type = commitTypes[i % commitTypes.length]; + const scope = scopes[i % scopes.length]; + const subject = subjects[i % subjects.length]; + + // Create or modify a file + const fileNum = (i % 10) + 1; + const fileName = `file-${fileNum}.ts`; + const filePath = join(sandboxPath, "src", fileName); + + writeFileSync( + filePath, + `// File ${fileNum}\n// Commit ${i + 1}\nexport const value${i} = ${i};\n`, + ); + + execGit(sandboxPath, ["add", filePath]); + + // Create commit message + let commitMessage = `${type}`; + if (scope) { + commitMessage += `(${scope})`; + } + commitMessage += `: ${subject} ${i + 1}`; + + // Add body occasionally + if (i % 5 === 0) { + commitMessage += `\n\nThis commit includes additional changes.\n- Change 1\n- Change 2`; + } + + execGit(sandboxPath, [ + "commit", + "-m", + commitMessage, + "--no-verify", + "--allow-empty", + ]); + + // Create merge commits occasionally + if (includeMerges && i > 0 && i % 10 === 0) { + const branchName = `feature-${i}`; + execGit(sandboxPath, ["checkout", "-b", branchName]); + + // Make a commit on branch + writeFileSync( + join(sandboxPath, "src", `branch-${i}.ts`), + `// Branch file ${i}\n`, + ); + execGit(sandboxPath, ["add", join("src", `branch-${i}.ts`)]); + execGit(sandboxPath, [ + "commit", + "-m", + `feat: add feature ${i}`, + "--no-verify", + ]); + + // Merge back to main + execGit(sandboxPath, ["checkout", "main"]); + execGit(sandboxPath, [ + "merge", + "--no-ff", + "-m", + `Merge branch '${branchName}'`, + branchName, + "--no-verify", + ]); + } + } +} + +/** + * Create uncommitted changes + */ +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`); + writeFileSync( + filePath, + `// Component ${String.fromCharCode(96 + i)}\nexport class Component${String.fromCharCode(96 + i).toUpperCase()} {}\n// Modified\n`, + ); + } + + // Added files + for (let i = 1; i <= 3; i++) { + 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`, + ); + } + + // Ensure lib directory exists + mkdirSync(join(sandboxPath, "lib"), { recursive: true }); + + // Deleted files (mark for deletion) + // Create both files first, then commit them together, then remove them + const filesToDelete = []; + for (let i = 1; i <= 2; i++) { + 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); + execGit(sandboxPath, ["add", ...relativePaths]); + execGit(sandboxPath, [ + "commit", + "-m", + "chore: add old util files for deletion test", + "--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) { + // Verify file still exists before git rm + if (existsSync(filePath)) { + // Stage the deletion + execGit(sandboxPath, ["rm", relativePath]); + // Unstage it so it becomes an unstaged change (natural git state) + execGit(sandboxPath, ["reset", "HEAD", "--", relativePath]); + } + } + + // Renamed files + const renames = [ + ["helpers.ts", "helper-functions.ts"], + ["constants.ts", "app-constants.ts"], + ]; + + // Ensure lib directory exists (in case it was removed) + const libDir = join(sandboxPath, "lib"); + if (!existsSync(libDir)) { + mkdirSync(libDir, { recursive: true }); + } + + // Create all files first, then commit them together + const filesToRename = []; + for (const [oldName, newName] of renames) { + 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); + execGit(sandboxPath, ["add", ...relativePaths]); + execGit(sandboxPath, [ + "commit", + "-m", + "chore: add files for rename test", + "--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 + const { readFileSync, unlinkSync, statSync } = await import("fs"); + for (const { oldPath, oldRelativePath, newRelativePath } of filesToRename) { + const newPath = join(sandboxPath, newRelativePath); + // Verify file exists and is a file (not a directory) before renaming + if (existsSync(oldPath)) { + try { + const stats = statSync(oldPath); + if (!stats.isFile()) { + continue; // Skip if it's not a file + } + // Manually move the file (not git mv) to create unstaged rename + const content = readFileSync(oldPath, "utf-8"); + // Ensure new file's directory exists + const newDir = join(sandboxPath, "lib"); + if (!existsSync(newDir)) { + mkdirSync(newDir, { recursive: true }); + } + writeFileSync(newPath, content); + // Delete the old file (this creates an unstaged deletion) + unlinkSync(oldPath); + } catch (error) { + // Skip this file if there's an error reading/writing + continue; + } + } + } +} + +/** + * Create conflict state + */ +function createConflictState(sandboxPath: string): void { + // Create a file and commit it + const conflictFile = join(sandboxPath, "conflict.ts"); + writeFileSync(conflictFile, "// Original content\n"); + execGit(sandboxPath, ["add", conflictFile]); + execGit(sandboxPath, [ + "commit", + "-m", + "feat: add conflict file", + "--no-verify", + ]); + + // Create a branch and modify + execGit(sandboxPath, ["checkout", "-b", "feature-branch"]); + writeFileSync(conflictFile, "// Modified on branch\n"); + execGit(sandboxPath, ["add", conflictFile]); + execGit(sandboxPath, [ + "commit", + "-m", + "feat: modify on branch", + "--no-verify", + ]); + + // Switch back and modify + execGit(sandboxPath, ["checkout", "main"]); + writeFileSync(conflictFile, "// Modified on main\n"); + execGit(sandboxPath, ["add", conflictFile]); + execGit(sandboxPath, [ + "commit", + "-m", + "feat: modify on main", + "--no-verify", + ]); + + // Attempt merge to create conflict + try { + execGit(sandboxPath, ["merge", "feature-branch", "--no-commit"]); + } catch { + // Conflict expected + } +} + +/** + * Copy config file to sandbox + */ +async function copyConfig(sandboxPath: string): Promise { + const configResult = await loadConfig(); + if (configResult.source !== "defaults" && configResult.config) { + // Get config from project root + const { readFileSync } = await import("fs"); + const { fileURLToPath } = await import("url"); + const { dirname, join } = await import("path"); + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + const projectRoot = join(__dirname, "../../../../"); + const configPath = join(projectRoot, ".labcommitr.config.yaml"); + + if (existsSync(configPath)) { + const configContent = readFileSync(configPath, "utf-8"); + writeFileSync(join(sandboxPath, ".labcommitr.config.yaml"), configContent); + } + } +} + +/** + * Generate scenario + */ +export async function generateScenario( + sandboxPath: string, + scenario: ScenarioName, +): Promise { + // Ensure sandbox directory exists + mkdirSync(sandboxPath, { recursive: true }); + + // Clean and initialize + const gitDir = join(sandboxPath, ".git"); + if (existsSync(gitDir)) { + rmSync(gitDir, { recursive: true, force: true }); + } + + initGit(sandboxPath); + createInitialStructure(sandboxPath); + + // Generate based on scenario + switch (scenario) { + case "existing-project": + // History + changes, no config + generateCommitHistory(sandboxPath, 25); + await createUncommittedChanges(sandboxPath); + // No config file + break; + + case "with-changes": + // History + changes + config + generateCommitHistory(sandboxPath, 25); + await createUncommittedChanges(sandboxPath); + await copyConfig(sandboxPath); + break; + + case "with-history": + // Extensive history + config + generateCommitHistory(sandboxPath, 100); + await copyConfig(sandboxPath); + break; + + case "with-merge": + // History with merges + config + generateCommitHistory(sandboxPath, 50, true); + await copyConfig(sandboxPath); + break; + + case "with-conflicts": + // History + conflict state + config + generateCommitHistory(sandboxPath, 20); + createConflictState(sandboxPath); + await copyConfig(sandboxPath); + break; + } +} + diff --git a/src/cli/commands/test/scenarios.ts b/src/cli/commands/test/scenarios.ts new file mode 100644 index 0000000..29b00ee --- /dev/null +++ b/src/cli/commands/test/scenarios.ts @@ -0,0 +1,71 @@ +/** + * Test Scenario Definitions + * + * Defines all available test scenarios and their metadata + */ + +import type { ScenarioMetadata, ScenarioName } from "./types.js"; + +export const SCENARIOS: Record = { + "existing-project": { + name: "existing-project", + description: + "Existing project with history and uncommitted changes, no config file. Use for testing adding Labcommitr to an existing project.", + hasHistory: true, + hasChanges: true, + hasConfig: false, + hasConflicts: false, + hasMerges: false, + }, + "with-changes": { + name: "with-changes", + description: + "Project with history, uncommitted changes, and config file. Use for testing commit command with various file states.", + hasHistory: true, + hasChanges: true, + hasConfig: true, + hasConflicts: false, + hasMerges: false, + }, + "with-history": { + name: "with-history", + description: + "Project with extensive commit history (100+ commits) and config file. Use for testing preview and revert commands.", + hasHistory: true, + hasChanges: false, + hasConfig: true, + hasConflicts: false, + hasMerges: false, + }, + "with-merge": { + name: "with-merge", + description: + "Project with merge commits and config file. Use for testing revert command with merge commit handling.", + hasHistory: true, + hasChanges: false, + hasConfig: true, + hasConflicts: false, + hasMerges: true, + }, + "with-conflicts": { + name: "with-conflicts", + description: + "Project in conflict state with config file. Use for testing conflict resolution workflows.", + hasHistory: true, + hasChanges: false, + hasConfig: true, + hasConflicts: true, + hasMerges: false, + }, +}; + +export const DEFAULT_SCENARIO: ScenarioName = "with-changes"; + +export function getScenario(name: string): ScenarioMetadata | null { + return SCENARIOS[name as ScenarioName] || 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 new file mode 100644 index 0000000..6ae0b9c --- /dev/null +++ b/src/cli/commands/test/state-manager.ts @@ -0,0 +1,84 @@ +/** + * Test State Manager + * + * Manages test environment state and metadata + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs"; +import { join } from "path"; +import type { TestState, ScenarioName } from "./types.js"; + +const STATE_FILE = ".test-state.json"; + +/** + * Get sandbox path + */ +export function getSandboxPath(projectRoot: string): string { + return join(projectRoot, ".sandbox", "test"); +} + +/** + * Get state file path + */ +function getStateFilePath(sandboxPath: string): string { + return join(sandboxPath, STATE_FILE); +} + +/** + * Load test state + */ +export function loadState(sandboxPath: string): TestState | null { + const statePath = getStateFilePath(sandboxPath); + if (!existsSync(statePath)) { + return null; + } + + try { + const content = readFileSync(statePath, "utf-8"); + const state = JSON.parse(content) as TestState; + return state; + } catch { + return null; + } +} + +/** + * Save test state + */ +export function saveState( + sandboxPath: string, + scenario: ScenarioName, +): void { + mkdirSync(sandboxPath, { recursive: true }); + + const state: TestState = { + scenario, + sandboxPath, + isActive: true, + }; + + const statePath = getStateFilePath(sandboxPath); + writeFileSync(statePath, JSON.stringify(state, null, 2)); +} + +/** + * Clear test state + */ +export function clearState(sandboxPath: string): void { + const statePath = getStateFilePath(sandboxPath); + if (existsSync(statePath)) { + unlinkSync(statePath); + } +} + +/** + * Check if sandbox exists and is valid + */ +export function isSandboxValid(sandboxPath: string): boolean { + return ( + existsSync(sandboxPath) && + existsSync(join(sandboxPath, ".git")) && + existsSync(getStateFilePath(sandboxPath)) + ); +} + diff --git a/src/cli/commands/test/types.ts b/src/cli/commands/test/types.ts new file mode 100644 index 0000000..6fda8b0 --- /dev/null +++ b/src/cli/commands/test/types.ts @@ -0,0 +1,27 @@ +/** + * Test Command Types + */ + +export type ScenarioName = + | "existing-project" + | "with-changes" + | "with-history" + | "with-merge" + | "with-conflicts"; + +export interface ScenarioMetadata { + name: ScenarioName; + description: string; + hasHistory: boolean; + hasChanges: boolean; + hasConfig: boolean; + hasConflicts: boolean; + hasMerges: boolean; +} + +export interface TestState { + scenario: ScenarioName | null; + sandboxPath: string; + isActive: boolean; +} + diff --git a/src/cli/program-dev.ts b/src/cli/program-dev.ts new file mode 100644 index 0000000..cad45f5 --- /dev/null +++ b/src/cli/program-dev.ts @@ -0,0 +1,76 @@ +/** + * Commander.js program configuration (Development) + * + * This module sets up the CLI program structure for development use, + * including test commands that are not available in production builds. + * + * This file is only used during development and is not included in + * the published package. + */ + +import { Command } from "commander"; +import { readFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; + +// Commands +import { configCommand } from "./commands/config.js"; +import { initCommand } from "./commands/init/index.js"; +import { commitCommand } from "./commands/commit.js"; +import { previewCommand } from "./commands/preview/index.js"; +import { revertCommand } from "./commands/revert/index.js"; +import { testCommand } from "./commands/test/index.js"; + +// Get package.json for version info +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const packageJsonPath = join(__dirname, "../../package.json"); +const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")); + +/** + * Development CLI program instance (includes test commands) + */ +export const program = new Command(); + +// Program metadata +program + .name("labcommitr") + .description( + "A CLI tool for standardized git commits with customizable workflows", + ) + .version(packageJson.version, "-v, --version", "Display version number") + .helpOption("-h, --help", "Display help information"); + +// Global options (future: --verbose, --no-emoji, etc.) +// program.option('--verbose', 'Enable verbose logging'); + +// Register commands (including test command for development) +program.addCommand(configCommand); +program.addCommand(initCommand); +program.addCommand(commitCommand); +program.addCommand(previewCommand); +program.addCommand(revertCommand); +program.addCommand(testCommand); + +// Customize help text +program.addHelpText( + "after", + ` +Examples: + $ lab init Initialize config in current project + $ lab commit Create a standardized commit (interactive) + $ lab i (alias for init) + $ lab c (alias for commit) + $ lab test setup Set up test environment + $ lab test shell Open shell in test environment + +Note: You can use either 'lab' or 'labcommitr' to run commands. + +Documentation: + https://github.com/labcatr/labcommitr#readme +`, +); + +// Error on unknown commands +program.showSuggestionAfterError(true); + diff --git a/src/cli/program.ts b/src/cli/program.ts index 23ae0ee..b10f0bc 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -17,6 +17,9 @@ import { dirname, join } from "path"; import { configCommand } from "./commands/config.js"; import { initCommand } from "./commands/init/index.js"; import { commitCommand } from "./commands/commit.js"; +import { previewCommand } from "./commands/preview/index.js"; +import { revertCommand } from "./commands/revert/index.js"; +// Note: testCommand is only available in dev entrypoint (program-dev.ts) // Get package.json for version info const __filename = fileURLToPath(import.meta.url); @@ -45,15 +48,21 @@ program program.addCommand(configCommand); program.addCommand(initCommand); program.addCommand(commitCommand); +program.addCommand(previewCommand); +program.addCommand(revertCommand); +// Note: testCommand is only registered in dev entrypoint (program-dev.ts) // Customize help text program.addHelpText( "after", ` Examples: - $ labcommitr init Initialize config in current project - $ lab commit Create a standardized commit (interactive) - $ lab config show Display current configuration + $ lab init Initialize config in current project + $ lab commit Create a standardized commit (interactive) + $ lab i (alias for init) + $ lab c (alias for commit) + +Note: You can use either 'lab' or 'labcommitr' to run commands. Documentation: https://github.com/labcatr/labcommitr#readme diff --git a/src/index-dev.ts b/src/index-dev.ts new file mode 100644 index 0000000..5715b5c --- /dev/null +++ b/src/index-dev.ts @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +/** + * Labcommitr CLI Entry Point (Development) + * + * This file serves as the development entry point for the labcommitr CLI tool. + * It includes test commands that are not available in production builds. + * + * This file is only used during development and is not included in + * the published package. + */ + +import { program } from "./cli/program-dev.js"; +import { handleCliError } from "./cli/utils/error-handler.js"; + +/** + * Main CLI execution (Development) + * Parses process arguments and executes the appropriate command + */ +async function main(): Promise { + try { + await program.parseAsync(process.argv); + } catch (error: unknown) { + // Check if error is about too many arguments (likely unquoted message/body) + if ( + error instanceof Error && + error.message.includes("too many arguments") + ) { + console.error("\n✗ Error: Too many arguments"); + console.error("\n Your message or body contains spaces and needs to be quoted."); + console.error("\n Fix: Use quotes around values with spaces:"); + console.error(` • Message: -m "your message here"`); + console.error(` • Body: -b "your body here"`); + console.error( + ` • Example: lab commit -t feat -m "add feature" -b "detailed description"\n`, + ); + process.exit(1); + } + handleCliError(error); + process.exit(1); + } +} + +// Execute CLI +main(); + diff --git a/src/lib/config/validator.ts b/src/lib/config/validator.ts index a58052a..f7ded4b 100644 --- a/src/lib/config/validator.ts +++ b/src/lib/config/validator.ts @@ -1,7 +1,7 @@ /** * Configuration validation system for labcommitr * - * Implements incremental validation following the CONFIG_SCHEMA.md specification. + * Implements incremental validation following the docs/CONFIG_SCHEMA.md specification. * Phase 1: Basic schema validation (required fields, types, structure) * Phase 2: Business logic validation (uniqueness, cross-references) * Phase 3: Advanced validation (templates, industry standards)