diff --git a/.github/BRANCH_PROTECTION.md b/.github/BRANCH_PROTECTION.md new file mode 100644 index 0000000..d628ec1 --- /dev/null +++ b/.github/BRANCH_PROTECTION.md @@ -0,0 +1,112 @@ +# Branch Protection Setup + +This document describes how to set up branch protection for the `main` branch using GitHub's API or web interface. + +## Option 1: Using GitHub Web Interface (Recommended) + +1. Go to your repository on GitHub +2. Navigate to **Settings** → **Branches** +3. Click **Add rule** or edit the existing rule for `main` +4. Configure the following settings: + - **Branch name pattern**: `main` + - ✅ **Require a pull request before merging** + - ✅ Require approvals: `1` (or more) + - ✅ Dismiss stale pull request approvals when new commits are pushed + - ✅ **Require status checks to pass before merging** + - ✅ Require branches to be up to date before merging + - Select required status checks: + - `Test (Python 3.11) / ubuntu-latest` + - `Test (Python 3.11) / macos-latest` + - `Test (Python 3.12) / ubuntu-latest` + - `Test (Python 3.12) / macos-latest` + - `Test (Python 3.13) / ubuntu-latest` + - `Test (Python 3.13) / macos-latest` + - `Lint` + - ✅ **Require conversation resolution before merging** + - ✅ **Do not allow bypassing the above settings** + - ✅ **Restrict who can push to matching branches** (optional, but recommended) + - ✅ **Allow force pushes** (unchecked - disable force pushes) + - ✅ **Allow deletions** (unchecked - prevent branch deletion) + +## Option 2: Using GitHub CLI + +```bash +# Install GitHub CLI if not already installed +# brew install gh # macOS +# apt install gh # Linux + +# Authenticate +gh auth login + +# Set branch protection rules +gh api repos/:owner/:repo/branches/main/protection \ + --method PUT \ + --field required_status_checks='{"strict":true,"contexts":["Test (Python 3.11) / ubuntu-latest","Test (Python 3.11) / macos-latest","Test (Python 3.12) / ubuntu-latest","Test (Python 3.12) / macos-latest","Test (Python 3.13) / ubuntu-latest","Test (Python 3.13) / macos-latest","Lint"]}' \ + --field enforce_admins=true \ + --field required_pull_request_reviews='{"required_approving_review_count":1,"dismiss_stale_reviews":true}' \ + --field restrictions=null \ + --field allow_force_pushes=false \ + --field allow_deletions=false +``` + +Replace `:owner` and `:repo` with your GitHub username and repository name. + +## Option 3: Using GitHub API with curl + +```bash +# Set your GitHub token +export GITHUB_TOKEN=your_token_here + +# Set branch protection +curl -X PUT \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/vadimvolk/git-worktree-wrapper/branches/main/protection \ + -d '{ + "required_status_checks": { + "strict": true, + "contexts": [ + "Test (Python 3.11) / ubuntu-latest", + "Test (Python 3.11) / macos-latest", + "Test (Python 3.12) / ubuntu-latest", + "Test (Python 3.12) / macos-latest", + "Test (Python 3.13) / ubuntu-latest", + "Test (Python 3.13) / macos-latest", + "Lint" + ] + }, + "enforce_admins": true, + "required_pull_request_reviews": { + "required_approving_review_count": 1, + "dismiss_stale_reviews": true + }, + "restrictions": null, + "allow_force_pushes": false, + "allow_deletions": false + }' +``` + +## Verification + +After setting up branch protection, verify it works: + +1. Create a test branch: `git checkout -b test-branch-protection` +2. Make a change and push: `git push origin test-branch-protection` +3. Create a pull request to `main` +4. Verify that: + - The PR cannot be merged until CI tests pass + - The PR requires at least one approval + - Force push to `main` is blocked + +## Notes + +- The CI workflow runs on all branches (push events) and on pull requests to `main` +- After merging to `main`, the CI will run again to verify the merge +- Status checks must pass before merging pull requests +- **Important**: After the first CI run completes, check the actual status check names in GitHub: + 1. Go to any pull request or commit + 2. View the "Checks" tab + 3. Note the exact names of the status checks (e.g., "Test (Python 3.11)", "Lint") + 4. Update the branch protection settings with the exact check names +- Alternatively, you can require only the job names (`test` and `lint`) if GitHub supports that, or require "any status check" to pass diff --git a/.github/scripts/setup-branch-protection.sh b/.github/scripts/setup-branch-protection.sh new file mode 100755 index 0000000..a686f4c --- /dev/null +++ b/.github/scripts/setup-branch-protection.sh @@ -0,0 +1,85 @@ +#!/bin/bash +# Setup branch protection for main branch using GitHub CLI + +set -e + +REPO_OWNER="${GITHUB_REPOSITORY_OWNER:-vadimvolk}" +REPO_NAME="${GITHUB_REPOSITORY_NAME:-git-worktree-wrapper}" +BRANCH="main" + +echo "Setting up branch protection for ${REPO_OWNER}/${REPO_NAME}:${BRANCH}" + +# Check if gh CLI is installed +if ! command -v gh &> /dev/null; then + echo "Error: GitHub CLI (gh) is not installed." + echo "Install it with: brew install gh # macOS" + echo " apt install gh # Linux" + exit 1 +fi + +# Check if authenticated +if ! gh auth status &> /dev/null; then + echo "Error: Not authenticated with GitHub CLI." + echo "Run: gh auth login" + exit 1 +fi + +# Set branch protection +echo "Configuring branch protection rules..." + +# Create temporary JSON file for the protection payload +# After first CI run, update the "contexts" array with actual check names +# Example: "contexts": ["Test (Python 3.11)", "Test (Python 3.12)", "Lint"] +TMP_FILE=$(mktemp) +cat > "$TMP_FILE" <__` +- Mock external dependencies (git commands, file system) when appropriate + +**Key Test Files**: +- `test_cli_commands.py` - CLI command integration tests +- `test_worktree_management.py` - Worktree operations +- `test_config_loader.py` - Configuration loading +- `test_template_functions.py` - Template evaluation + +--- + +### 📚 Documentation Agent + +**Role**: Maintain documentation, README files, and specifications. + +**Context**: +- Main docs: `README.md` (English) and `README.ru.md` (Russian) +- Specifications: `specs/001-git-worktree-wrapper/` +- Architecture: `architecture.md` + +**Guidelines**: +- Keep README.md up-to-date with CLI changes +- Document all template functions with examples +- Include usage examples for common workflows +- Maintain both English and Russian versions +- Update quickstart guides when adding features + +**Documentation Sections**: +- Installation instructions (uv, pipx, pip) +- Quick start guide +- Configuration examples +- Template function reference +- Command reference +- Development setup + +**Common Tasks**: +- Updating README after feature additions +- Adding examples for new template functions +- Documenting breaking changes +- Syncing English and Russian docs + +--- + +### ⚙️ Configuration Agent + +**Role**: Work with configuration files, templates, and path resolution. + +**Context**: +- Config file: `~/.config/gww/config.yml` (Linux) or `~/Library/Application Support/gww/config.yml` (macOS) +- Config structure: `default_sources`, `default_worktrees`, `sources`, `actions` +- Template evaluation uses `simpleeval` with custom functions + +**Template Functions**: +- URI: `host()`, `port()`, `protocol()`, `uri()`, `path(n)` +- Branch: `branch()`, `norm_branch(replacement)` +- Utility: `time_id(fmt)` +- Actions: `source_path()`, `dest_path()`, `file_exists(path)`, `dir_exists(path)`, `path_exists(path)` +- Tags: `tag(name)`, `tag_exist(name)` + +**Guidelines**: +- Validate config syntax and semantics +- Provide clear error messages for invalid configs +- Support XDG config directory standards +- Handle template evaluation errors gracefully +- Document template function behavior + +**Common Tasks**: +- Adding new template functions +- Improving config validation +- Enhancing path resolution logic +- Supporting new condition operators + +--- + +### 🖥️ CLI Agent + +**Role**: Work with CLI commands, user interface, and shell integration. + +**Context**: +- Main entry: `src/gww/cli/main.py` +- Commands: `clone`, `add`, `remove`, `pull`, `migrate`, `init` +- Shell aliases: `gwc` (clone), `gwa` (add), `gwr` (remove) +- Shell completion: bash, zsh, fish + +**CLI Commands**: +- `gww clone [--tag key=value]...` - Clone repository +- `gww add [-c] [--tag key=value]...` - Add worktree +- `gww remove [-f]` - Remove worktree +- `gww pull` - Update source repository +- `gww migrate [--dry-run] [--move]` - Migrate repositories +- `gww init config` - Create default config +- `gww init shell ` - Install shell completion + +**Guidelines**: +- Use argparse for argument parsing +- Provide helpful error messages +- Support `--verbose` and `--quiet` flags +- Handle user prompts for navigation confirmation +- Generate shell completion scripts correctly + +**Common Tasks**: +- Adding new CLI options +- Improving error messages +- Enhancing shell completion +- Adding user prompts/confirmations + +--- + +### 🔍 Code Review Agent + +**Role**: Review code changes for quality, consistency, and correctness. + +**Guidelines**: +- Check type hints are present and correct +- Verify error handling is appropriate +- Ensure tests cover new functionality +- Check code follows project patterns +- Verify documentation is updated +- Check for security issues (path traversal, command injection) +- Ensure mypy type checking passes: `uv run mypy src/gww` + +**Review Checklist**: +- [ ] Type hints on all functions +- [ ] Error handling with appropriate exit codes +- [ ] Tests added/updated +- [ ] Documentation updated +- [ ] No hardcoded paths or secrets +- [ ] Follows existing code style +- [ ] Mypy passes without errors + +--- + +## General Guidelines for All Agents + +### Code Quality +- Use type hints everywhere +- Run `uv run mypy src/gww` before committing +- Follow Python 3.11+ best practices +- Keep functions focused and testable + +### Git Workflow +- Use the speckit workflow commands (`.cursor/commands/speckit.*`) +- Create feature branches: `NN-feature-name` +- Write specs before implementation +- Update tasks.md as work progresses + +### Testing +- Write tests for new features +- Run `uv run pytest` before committing +- Maintain test coverage +- Test on both Linux and macOS when possible + +### Documentation +- Update README.md for user-facing changes +- Document new template functions +- Add examples for new features +- Keep architecture.md current + +### Dependencies +- Use `uv` for dependency management +- Add new dependencies to `pyproject.toml` +- Run `uv sync` after dependency changes + +--- + +## Quick Reference + +**Run tests**: `uv run pytest` +**Type check**: `uv run mypy src/gww` +**Install deps**: `uv sync` +**Run CLI**: `uv run gww --help` +**Create spec**: Use `/speckit.specify` command +**Create plan**: Use `/speckit.plan` command diff --git a/README.md b/README.md index dcfd255..3a8c76d 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ # 🚀 GWW - Git Worktree Wrapper +[![CI](https://github.com/vadimvolk/git-worktree-wrapper/actions/workflows/ci.yml/badge.svg)](https://github.com/vadimvolk/git-worktree-wrapper/actions/workflows/ci.yml) + A CLI tool that wraps git worktree functionality with configurable path templates, condition-based routing, and project-specific actions. ## ✨ Features @@ -70,76 +72,126 @@ gww --help gww init config ``` -This creates a default configuration file at `~/.config/gww/config.yml` (Linux) or `~/Library/Application Support/gww/config.yml` (macOS). +This creates a default configuration file at `~/.config/gww/config.yml` (Linux) or `~/Library/Application Support/gww/config.yml` (macOS). Edit these 2 values: `default_sources` and `default_worktrees`. Check the [tutorial section](#tutorial) for routing details. + +### 2. 🐚 Initialize Shell Integration + +```bash +gww init shell zsh # or bash, or fish +``` + +This installs shell completion and aliases (`gwc`, `gwa`, `gwr`) for easier workflow. Follow the instructions printed by the command to enable them in your shell. -### 2. 📥 Clone a Repository +### 3. 📥 Clone a Repository ```bash -gww clone https://github.com/user/repo.git -# Output: ~/Developer/sources/github/user/repo +gwc https://github.com/user/repo.git +# Prompts: "Navigate to ~/Developer/sources/github/user/repo? [Y/n]" +# Navigates if you confirm (default: yes) ``` -### 3. ➕ Add a Worktree +### 4. ➕ Add a Worktree ```bash cd ~/Developer/sources/github/user/repo -gww add feature-branch -# Output: ~/Developer/worktrees/github/user/repo/feature-branch +gwa feature-branch +# Prompts: "Navigate to ~/Developer/worktrees/github/user/repo/feature-branch? [Y/n]" +# Navigates if you confirm (default: yes) ``` -### 4. ➖ Remove a Worktree +### 5. ➖ Remove a Worktree ```bash -gww remove feature-branch +gwr feature-branch +# If worktree has uncommitted changes or untracked files: +# Prompts: "Force removal? [y/N]" +# Removes with --force if you confirm +# Otherwise: Removes worktree immediately # Output: Removed worktree: ~/Developer/worktrees/github/user/repo/feature-branch ``` -### 5. 🔄 Update Source Repository +### 6. 🔄 Update Source Repository ```bash gww pull # Output: Updated source repository: ~/Developer/sources/github/user/repo ``` -**Note**: `gww pull` will pull the source repository even if it's called from a worktree, as long as the source repository is clean and has `main` or `master` branch checked out. This is useful for merge/rebase scenarios where you want to update the source repository while working in a worktree. +**Note**: `gww pull` updates the source repository even from a worktree, as long as the source is clean and has `main` or `master` checked out. Useful for merge/rebase workflows. +```bash +gww pull # from any repository worktree +git rebase main # rebase your current changes to updated main branch +``` + +### 7. 🚚 Migrate Repositories +Create a backup first! -## ⚙️ Configuration +```bash +gww migrate ~/old-repos --dry-run +# Output: +# Would migrate 5 repositories: +# ~/old-repos/repo1 -> ~/Developer/sources/github/user/repo1 +# ~/old-repos/repo2 -> ~/Developer/sources/github/user/repo2 +# ... + +gww migrate ~/old-repos +# Output: +# Migrated 5 repositories +# Repaired 2 worktrees +``` -Example `config.yml`: +The `migrate` command scans a directory for git repositories and migrates them to locations based on your current configuration. It's useful when: +- You've updated your configuration and want to reorganize existing repositories +- You're moving from manual repository management to GWW +- You need to consolidate repositories from different locations -```yaml -default_sources: ~/Developer/sources/default/path(-2)/path(-1) -default_worktrees: ~/Developer/worktrees/default/path(-2)/path(-1)/norm_branch() +**Options**: +- `--dry-run`, `-n`: Show what would be migrated without making changes +- `--move`: Move repositories instead of copying (default is copy) -sources: - github: - when: '"github" in host()' - sources: ~/Developer/sources/github/path(-2)/path(-1) - worktrees: ~/Developer/worktrees/github/path(-2)/path(-1)/norm_branch() +**Behavior**: +- Recursively scans the specified directory for git repositories +- Extracts the remote URI from each repository +- Calculates the expected location using your current config +- Migrates repositories that are in different locations than expected +- Automatically repairs worktree paths if migrating worktrees +- Skips repositories without remotes or that are already in the correct location - gitlab: - when: '"gitlab" in host()' - sources: ~/Developer/sources/gitlab/path(-3)/path(-2)/path(-1) - worktrees: ~/Developer/worktrees/gitlab/path(-3)/path(-2)/path(-1)/norm_branch() +## Tutorial -actions: - - when: file_exists("local.properties") - after_clone: - - abs_copy: ["~/sources/default-local.properties", "local.properties"] - after_add: - - rel_copy: ["local.properties"] +A minimal config file looks like: +```yaml +# Folder where all sources are checked out with gwc. path(-2)/path(-1) generates 2-level subfolders based on repository URI. Like https://github.com/user/repo.git -> ~/Developer/sources/user/repo +default_sources: ~/Developer/other/sources/path(-2)/path(-1) +# Folder where all worktrees are checked out with gwa. norm_branch() works better with remote branches, e.g. origin/remote-branch -> origin-remote-branch +default_worktrees: ~/Developer/other/worktrees/path(-2)/path(-1)/norm_branch() ``` +The generated file will have more options commented out, including the functions reference. -### 📝 Template Functions +### Checkout based on where repository is hosted +Useful to separate e.g. open source projects (where you learn or get inspired) from your work projects. +```yaml +# Still needed in case the config fails to find a section. You may prefer a non-nested sources structure, but make sure the result folder is unique +default_sources: ~/Developer/sources/host()-path(-2)-path(-1) +default_worktrees: ~/Developer/worktrees/host()-path(-2)-path(-1)-norm_branch() +sources: + # ... other rules + work: + when: "your.org.host" in host() + sources: ~/Developer/work/sources/path(-2)-path(-1) + worktrees: ~/Developer/work/sources/path(-2)-path(-1)-norm_branch() + +``` +That's enough to separate work sources from all others, but you can create more sections with various rules. The library uses [simpleeval](https://github.com/danthedeckie/simpleeval) to evaluate templates, so you can use its [operators](https://github.com/danthedeckie/simpleeval?tab=readme-ov-file#operators) and functions below to get necessary routing. #### 🌐 URI Functions (available in templates and `when` conditions) | Function | Description | Example | |----------|-------------|---------| -| `host()` | Get URI hostname | `host()` → `"github.com"` | -| `port()` | Get URI port (empty string if not specified) | `port()` → `""` or `"22"` | -| `protocol()` | Get URI protocol/scheme | `protocol()` → `"https"` or `"ssh"` | -| `uri()` | Get full URI string | `uri()` → `"https://github.com/user/repo.git"` | +| `uri()` | Get full URI string | `uri()` → `"https://loca-repo-manager.com:8081/user/repo.git"` | +| `host()` | Get URI hostname | `host()` → `"loca-repo-manager.com"` | +| `port()` | Get URI port (empty string if not specified) | `port()` → `"8081"` or `""` usually | +| `protocol()` | Get URI protocol/scheme | `protocol()` → `"https"` / `"ssh"` / `git` | | `path(n)` | Get URI path segment by index (0-based, negative for reverse) | `path(-1)` → `"repo"`, `path(0)` → `"user"` | #### 🌿 Branch Functions (available in templates) @@ -149,14 +201,34 @@ actions: | `branch()` | Get current branch name | `branch()` → `"feature/new/ui"` | | `norm_branch(replacement)` | Branch name with `/` replaced (default: `"-"`) | `norm_branch()` → `"feature-new-ui"`, `norm_branch("_")` → `"feature_new_ui"` | -#### ⏰ Utility Functions (available in templates) +Need to checkout temporary projects separately? Add this to your config: +```yml +sources: + # ... other rules + temp: + when: tag_exist("temp") # See [tags section](#-tags) for details about tags + sources: ~/Downloads/temp/sources/time_id()-host()-path(-2)-path(-1) + worktrees: ~/Downloads/temp/worktrees/time_id()-host()-path(-2)-path(-1)-norm-branch() +``` +`time_id(fmt)` generates a datetime-based identifier (cached per template evaluation). Default format is `"20260120-2134.03"` (short, seconds accuracy unique). Use [format codes](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes) for more detailed/nested results. Works properly if used multiple times. +```yml +worktrees: ~/Downloads/temp/worktrees/time_id("%Y")/time_id("%m")/time_id("%H-%M$.%S")/host()-path(-2)-path(-1)-norm-branch() +``` +Generates nested structure: `YYYY/HH-MM.ss/host()-path(-2)-path(-1)-norm-branch()` -| Function | Description | Example | -|----------|-------------|---------| -| `time_id(fmt)` | Generate datetime-based identifier (cached per template evaluation). See [format codes](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes). | `time_id()` → `"20260120-2134.03"`, `time_id("%Y-%m-%d")` → `"2026-01-20"` | #### ⚙️ Actions (available in `actions` section) - +Run actions after checking out a repository or adding a worktree. Common example: copying `local.properties` for Gradle projects. +```yml +actions: + - when: file_exists("settings.gradle") # Check if it's actually a Gradle project + after_clone: + - abs_copy: ["~/sources/default-local.properties", "local.properties"] # Copies your default file right after cloning the repo + after_add: + - rel_copy: ["local.properties"] # Inherit existing repository file to worktree +``` +You can have multiple `when` subsections in actions. After clone/add, the library goes top-to-bottom and executes all actions with matching `when` conditions. +Other functions available in the actions section: | Action | Description | Example | |--------|-------------|---------| | `abs_copy` | Copy file from absolute path to relative destination in target directory | `abs_copy: ["~/sources/default-local.properties", "local.properties"]` | @@ -173,7 +245,9 @@ actions: | `dir_exists(path)` | Check if directory exists relative to source repository | `dir_exists("config")` → `True` | | `path_exists(path)` | Check if path exists (file or directory) relative to source repository | `path_exists("local.properties")` → `True` | -#### 🏷️ Tag Functions (available in templates and `when` conditions) +#### 🏷️ Tags + +Still not flexible enough? Here comes tags. Tags specified using command line param `-t [=optional value]` (or `--tag`) for clone / add commands. Tags available in configuration with: | Function | Description | Example | |----------|-------------|---------| @@ -184,28 +258,29 @@ actions: ```yaml sources: # Temporary checkout: Clone repositories to ~/Downloads/temp for quick access - # Usage: gww clone --tag temp + # Usage: gwc -t temp temp: when: 'tag_exist("temp")' - sources: ~/Downloads/temp/path(-1) - worktrees: ~/Downloads/temp/path(-1)/norm_branch() + sources: ~/Downloads/temp/time_id()-host()-path(-1) + worktrees: ~/Downloads/temp/time_id()-host()-path(-1)/norm_branch() # Code review worktrees: Add worktrees to ~/Developer/worktree/code-review for review tasks - # Usage: gww add --tag review + # Usage: gwa --tag review review: when: 'tag_exist("review")' - sources: ~/Developer/sources/path(-2)/path(-1) - worktrees: ~/Developer/worktree/code-review/path(-1)/norm_branch() + worktrees: ~/Developer/review/worktree/path(-1)/norm_branch() + # If used during clone, default source path is used +``` ``` ```bash # Clone to temporary location -gww clone https://github.com/user/repo.git --tag temp +gwc https://github.com/user/repo.git -t temp # Output: ~/Downloads/temp/repo # Add worktree for code review cd ~/Developer/sources/github/user/repo -gww add feature-branch --tag review +gwa feature-branch --tag review # Output: ~/Developer/worktree/code-review/repo/feature-branch ``` @@ -213,16 +288,18 @@ gww add feature-branch --tag review | Command | Description | |---------|-------------| -| `gww clone [--tag key=value]...` | 📥 Clone repository to configured location (tags available in templates/conditions) | -| `gww add [-c] [--tag key=value]...` | ➕ Add worktree for branch (optionally create branch, tags available in templates/conditions) | -| `gww remove [-f]` | ➖ Remove worktree | +| `gwc [--tag key=value]...` | 📥 Clone repository to configured location (tags available in templates/conditions) | +| `gwa [-c] [--tag key=value]...` | ➕ Add worktree for branch (optionally create branch, tags available in templates/conditions) | +| `gwr [-f]` | ➖ Remove worktree | | `gww pull` | 🔄 Update source repository (works from worktrees if source is clean and on main/master) | | `gww migrate [--dry-run] [--move]` | 🚚 Migrate repositories to new locations | | `gww init config` | ⚙️ Create default configuration file | | `gww init shell ` | 🐚 Install shell completion (bash/zsh/fish) | +**Note**: `gwc`, `gwa`, and `gwr` are convenient shell aliases for `gww clone`, `gww add`, and `gww remove` respectively. They provide the same functionality with automatic navigation prompts. Install them with `gww init shell `. + **Common Options**: -- `--tag`, `-t`: Tag in format `key=value` or just `key` (can be specified multiple times). +- `--tag`, `-t`: Tag in the format `key=value` or just `key` (can be specified multiple times). ## 🔄 Update diff --git a/README.ru.md b/README.ru.md index 03d6340..6e3bb91 100644 --- a/README.ru.md +++ b/README.ru.md @@ -5,6 +5,8 @@ # 🚀 GWW — Git Worktree Wrapper +![Tests](https://github.com/vadimvolk/git-worktree-wrapper/actions/workflows/ci.yml/badge.svg) + CLI-инструмент, который оборачивает функциональность `git worktree`, добавляя настраиваемые шаблоны путей, маршрутизацию на основе условий и действия, специфичные для проекта. ## ✨ Возможности @@ -70,76 +72,126 @@ gww --help gww init config ``` -Это создаст конфигурационный файл по умолчанию в `~/.config/gww/config.yml` (Linux) или `~/Library/Application Support/gww/config.yml` (macOS). +Это создаст конфигурационный файл по умолчанию в `~/.config/gww/config.yml` (Linux) или `~/Library/Application Support/gww/config.yml` (macOS). Отредактируйте эти 2 значения: `default_sources` и `default_worktrees`. Проверьте [раздел с руководством](#tutorial) для деталей маршрутизации. + +### 2. 🐚 Инициализировать интеграцию с shell + +```bash +gww init shell zsh # или bash, или fish +``` + +Это установит автодополнение и алиасы (`gwc`, `gwa`, `gwr`) для более удобной работы. Следуйте инструкциям, выведенным командой, чтобы включить их в вашем shell. -### 2. 📥 Клонировать репозиторий +### 3. 📥 Клонировать репозиторий ```bash -gww clone https://github.com/user/repo.git -# Output: ~/Developer/sources/github/user/repo +gwc https://github.com/user/repo.git +# Запрашивает: "Navigate to ~/Developer/sources/github/user/repo? [Y/n]" +# Переходит, если вы подтверждаете (по умолчанию: да) ``` -### 3. ➕ Добавить worktree +### 4. ➕ Добавить worktree ```bash cd ~/Developer/sources/github/user/repo -gww add feature-branch -# Output: ~/Developer/worktrees/github/user/repo/feature-branch +gwa feature-branch +# Запрашивает: "Navigate to ~/Developer/worktrees/github/user/repo/feature-branch? [Y/n]" +# Переходит, если вы подтверждаете (по умолчанию: да) ``` -### 4. ➖ Удалить worktree +### 5. ➖ Удалить worktree ```bash -gww remove feature-branch -# Output: Removed worktree: ~/Developer/worktrees/github/user/repo/feature-branch +gwr feature-branch +# Если в worktree есть незакоммиченные изменения или неотслеживаемые файлы: +# Запрашивает: "Force removal? [y/N]" +# Удаляет с --force, если вы подтверждаете +# Иначе: Удаляет worktree немедленно +# Вывод: Removed worktree: ~/Developer/worktrees/github/user/repo/feature-branch ``` -### 5. 🔄 Обновить исходный репозиторий +### 6. 🔄 Обновить исходный репозиторий ```bash gww pull -# Output: Updated source repository: ~/Developer/sources/github/user/repo +# Вывод: Updated source repository: ~/Developer/sources/github/user/repo +``` + +**Примечание**: `gww pull` обновляет исходный репозиторий даже из worktree, при условии что исходный репозиторий чист и находится на ветке `main` или `master`. Полезно для сценариев merge/rebase. +```bash +gww pull # из любого worktree репозитория +git rebase main # перебазировать ваши текущие изменения на обновленную ветку main ``` -**Примечание**: `gww pull` обновит исходный репозиторий даже если команда вызвана из worktree, при условии что исходный репозиторий чист и находится на ветке `main` или `master`. Это полезно для сценариев merge/rebase, когда нужно обновить исходный репозиторий во время работы в worktree. +### 7. 🚚 Мигрировать репозитории +Сначала создайте резервную копию! -## ⚙️ Конфигурация +```bash +gww migrate ~/old-repos --dry-run +# Вывод: +# Would migrate 5 repositories: +# ~/old-repos/repo1 -> ~/Developer/sources/github/user/repo1 +# ~/old-repos/repo2 -> ~/Developer/sources/github/user/repo2 +# ... + +gww migrate ~/old-repos +# Вывод: +# Migrated 5 repositories +# Repaired 2 worktrees +``` -Пример `config.yml`: +Команда `migrate` сканирует директорию на наличие git-репозиториев и мигрирует их в локации на основе вашей текущей конфигурации. Полезна когда: +- Вы обновили конфигурацию и хотите реорганизовать существующие репозитории +- Вы переходите с ручного управления репозиториями на GWW +- Вам нужно объединить репозитории из разных локаций -```yaml -default_sources: ~/Developer/sources/default/path(-2)/path(-1) -default_worktrees: ~/Developer/worktrees/default/path(-2)/path(-1)/norm_branch() +**Опции**: +- `--dry-run`, `-n`: Показать что будет мигрировано без внесения изменений +- `--move`: Переместить репозитории вместо копирования (по умолчанию — копирование) -sources: - github: - when: '"github" in host()' - sources: ~/Developer/sources/github/path(-2)/path(-1) - worktrees: ~/Developer/worktrees/github/path(-2)/path(-1)/norm_branch() +**Поведение**: +- Рекурсивно сканирует указанную директорию на наличие git-репозиториев +- Извлекает URI удаленного репозитория из каждого репозитория +- Вычисляет ожидаемую локацию используя вашу текущую конфигурацию +- Мигрирует репозитории, которые находятся в других локациях, чем ожидается +- Автоматически исправляет пути worktree при миграции worktree +- Пропускает репозитории без удаленных репозиториев или уже находящиеся в правильной локации - gitlab: - when: '"gitlab" in host()' - sources: ~/Developer/sources/gitlab/path(-3)/path(-2)/path(-1) - worktrees: ~/Developer/worktrees/gitlab/path(-3)/path(-2)/path(-1)/norm_branch() +## Tutorial -actions: - - when: file_exists("local.properties") - after_clone: - - abs_copy: ["~/sources/default-local.properties", "local.properties"] - after_add: - - rel_copy: ["local.properties"] +Минимальный конфигурационный файл выглядит так: +```yaml +# Папка, куда все исходники клонируются с помощью gwc. path(-2)/path(-1) генерирует 2-уровневые подпапки на основе URI репозитория. Например https://github.com/user/repo.git -> ~/Developer/sources/user/repo +default_sources: ~/Developer/other/sources/path(-2)/path(-1) +# Папка, куда все worktree клонируются с помощью gwa. norm_branch() лучше работает с удаленными ветками, например origin/remote-branch -> origin-remote-branch +default_worktrees: ~/Developer/other/worktrees/path(-2)/path(-1)/norm_branch() ``` +Сгенерированный файл будет иметь больше опций в комментариях, включая справочник функций. -### 📝 Функции шаблонов +### Checkout на основе того, где размещен репозиторий +Полезно для разделения, например, open source проектов (где вы учитесь или черпаете вдохновение) от ваших рабочих проектов. +```yaml +# Все еще нужен на случай, если конфигурация не найдет секцию. Вы можете предпочесть невложенную структуру sources, но убедитесь, что результирующая папка уникальна +default_sources: ~/Developer/sources/host()-path(-2)-path(-1) +default_worktrees: ~/Developer/worktrees/host()-path(-2)-path(-1)-norm_branch() +sources: + # ... другие правила + work: + when: "your.org.host" in host() + sources: ~/Developer/work/sources/path(-2)-path(-1) + worktrees: ~/Developer/work/sources/path(-2)-path(-1)-norm_branch() + +``` +Этого достаточно, чтобы разделить рабочие исходники от всех остальных, но вы можете создать больше секций с различными правилами. Библиотека использует [simpleeval](https://github.com/danthedeckie/simpleeval) для оценки шаблонов, поэтому вы можете использовать его [операторы](https://github.com/danthedeckie/simpleeval?tab=readme-ov-file#operators) и функции ниже для получения необходимой маршрутизации. #### 🌐 Функции URI (доступны в шаблонах и условиях `when`) | Function | Description | Example | |----------|-------------|---------| -| `host()` | Получить hostname из URI | `host()` → `"github.com"` | -| `port()` | Получить порт из URI (пустая строка, если не указан) | `port()` → `""` или `"22"` | -| `protocol()` | Получить протокол/схему URI | `protocol()` → `"https"` или `"ssh"` | -| `uri()` | Получить URI целиком | `uri()` → `"https://github.com/user/repo.git"` | +| `uri()` | Получить полную строку URI | `uri()` → `"https://loca-repo-manager.com:8081/user/repo.git"` | +| `host()` | Получить hostname URI | `host()` → `"loca-repo-manager.com"` | +| `port()` | Получить порт URI (пустая строка, если не указан) | `port()` → `"8081"` или `""` обычно | +| `protocol()` | Получить протокол/схему URI | `protocol()` → `"https"` / `"ssh"` / `git` | | `path(n)` | Получить сегмент пути URI по индексу (0-based, отрицательные — с конца) | `path(-1)` → `"repo"`, `path(0)` → `"user"` | #### 🌿 Функции веток (доступны в шаблонах) @@ -149,14 +201,34 @@ actions: | `branch()` | Получить имя текущей ветки | `branch()` → `"feature/new/ui"` | | `norm_branch(replacement)` | Имя ветки с заменой `/` (по умолчанию: `"-"`) | `norm_branch()` → `"feature-new-ui"`, `norm_branch("_")` → `"feature_new_ui"` | -#### ⏰ Утилитарные функции (доступны в шаблонах) +Нужно клонировать временные проекты отдельно? Добавьте это в вашу конфигурацию: +```yml +sources: + # ... другие правила + temp: + when: tag_exist("temp") # См. [раздел тегов](#-tags) для деталей о тегах + sources: ~/Downloads/temp/sources/time_id()-host()-path(-2)-path(-1) + worktrees: ~/Downloads/temp/worktrees/time_id()-host()-path(-2)-path(-1)-norm-branch() +``` +`time_id(fmt)` генерирует идентификатор на основе даты/времени (кэшируется в рамках одной оценки шаблона). Формат по умолчанию — `"20260120-2134.03"` (короткий, с точностью до секунд уникальный). Используйте [коды формата](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes) для более детальных/вложенных результатов. Работает правильно при многократном использовании. +```yml +worktrees: ~/Downloads/temp/worktrees/time_id("%Y")/time_id("%m")/time_id("%H-%M$.%S")/host()-path(-2)-path(-1)-norm-branch() +``` +Генерирует вложенную структуру: `YYYY/HH-MM.ss/host()-path(-2)-path(-1)-norm-branch()` -| Function | Description | Example | -|----------|-------------|---------| -| `time_id(fmt)` | Генерирует идентификатор на основе даты/времени (кэшируется в рамках одного шаблона). См. [коды формата](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes). | `time_id()` → `"20260120-2134.03"`, `time_id("%Y-%m-%d")` → `"2026-01-20"` | #### ⚙️ Действия (доступны в секции `actions`) - +Запускать действия после клонирования репозитория или добавления worktree. Распространенный пример: копирование `local.properties` для проектов Gradle. +```yml +actions: + - when: file_exists("settings.gradle") # Проверить, что это действительно проект Gradle + after_clone: + - abs_copy: ["~/sources/default-local.properties", "local.properties"] # Копирует ваш файл по умолчанию сразу после клонирования репозитория + after_add: + - rel_copy: ["local.properties"] # Наследовать существующий файл репозитория в worktree +``` +Вы можете иметь несколько подсекций `when` в действиях. После clone/add библиотека проходит сверху вниз и выполняет все действия с соответствующими условиями `when`. +Другие функции, доступные в секции действий: | Action | Description | Example | |--------|-------------|---------| | `abs_copy` | Копировать файл из абсолютного пути в относительное место назначения в целевой директории | `abs_copy: ["~/sources/default-local.properties", "local.properties"]` | @@ -173,39 +245,42 @@ actions: | `dir_exists(path)` | Проверить наличие директории относительно исходного репозитория | `dir_exists("config")` → `True` | | `path_exists(path)` | Проверить наличие пути (файл или директория) относительно исходного репозитория | `path_exists("local.properties")` → `True` | -#### 🏷️ Функции тегов (доступны в шаблонах и условиях `when`) +#### 🏷️ Теги + +Все еще недостаточно гибко? Вот теги. Теги указываются с помощью параметра командной строки `-t [=optional value]` (или `--tag`) для команд clone / add. Теги доступны в конфигурации с помощью: | Function | Description | Example | |----------|-------------|---------| -| `tag(name)` | Получить значение тега по имени (пустая строка, если не задан) | `tag("env")` → `"prod"` | +| `tag(name)` | Получить значение тега по имени (возвращает пустую строку, если не задан) | `tag("env")` → `"prod"` | | `tag_exist(name)` | Проверить, существует ли тег (возвращает boolean) | `tag_exist("env")` → `True` | **🏷️ Пример использования тегов**: ```yaml sources: # Временная копия: Клонировать репозитории в ~/Downloads/temp для быстрого доступа - # Использование: gww clone --tag temp + # Использование: gwc -t temp temp: when: 'tag_exist("temp")' - sources: ~/Downloads/temp/path(-1) - worktrees: ~/Downloads/temp/path(-1)/norm_branch() + sources: ~/Downloads/temp/time_id()-host()-path(-1) + worktrees: ~/Downloads/temp/time_id()-host()-path(-1)/norm_branch() # Worktree для code review: Добавить worktree в ~/Developer/worktree/code-review для задач ревью - # Использование: gww add --tag review + # Использование: gwa --tag review review: when: 'tag_exist("review")' - sources: ~/Developer/sources/path(-2)/path(-1) - worktrees: ~/Developer/worktree/code-review/path(-1)/norm_branch() + worktrees: ~/Developer/review/worktree/path(-1)/norm_branch() + # Если используется во время clone, используется путь источника по умолчанию +``` ``` ```bash # Клонировать во временную локацию -gww clone https://github.com/user/repo.git --tag temp +gwc https://github.com/user/repo.git -t temp # Output: ~/Downloads/temp/repo # Добавить worktree для code review cd ~/Developer/sources/github/user/repo -gww add feature-branch --tag review +gwa feature-branch --tag review # Output: ~/Developer/worktree/code-review/repo/feature-branch ``` @@ -213,14 +288,16 @@ gww add feature-branch --tag review | Command | Description | |---------|-------------| -| `gww clone [--tag key=value]...` | 📥 Клонировать репозиторий в настроенную локацию (теги доступны в шаблонах/условиях) | -| `gww add [-c] [--tag key=value]...` | ➕ Добавить worktree для ветки (опционально создать ветку, теги доступны в шаблонах/условиях) | -| `gww remove [-f]` | ➖ Удалить worktree | +| `gwc [--tag key=value]...` | 📥 Клонировать репозиторий в настроенную локацию (теги доступны в шаблонах/условиях) | +| `gwa [-c] [--tag key=value]...` | ➕ Добавить worktree для ветки (опционально создать ветку, теги доступны в шаблонах/условиях) | +| `gwr [-f]` | ➖ Удалить worktree | | `gww pull` | 🔄 Обновить исходный репозиторий (работает из worktree, если исходный репозиторий чист и на main/master) | | `gww migrate [--dry-run] [--move]` | 🚚 Мигрировать репозитории в новые локации | | `gww init config` | ⚙️ Создать конфиг по умолчанию | | `gww init shell ` | 🐚 Установить автодополнение (bash/zsh/fish) | +**Примечание**: `gwc`, `gwa`, и `gwr` — это удобные алиасы shell для `gww clone`, `gww add`, и `gww remove` соответственно. Они предоставляют ту же функциональность с автоматическими запросами на навигацию. Установите их с помощью `gww init shell `. + **Часто используемые опции**: - `--tag`, `-t`: Тег в формате `key=value` или просто `key` (можно указывать несколько раз). diff --git a/docs/architecture-diagram.html b/docs/architecture-diagram.html new file mode 100644 index 0000000..2d258f9 --- /dev/null +++ b/docs/architecture-diagram.html @@ -0,0 +1,111 @@ + + + + + + GWW Architecture Diagram + + + + +
+

Git Worktree Wrapper (GWW) - High-Level Architecture

+

Component relationships and data flow

+
+graph TB + subgraph "User Interface Layer" + CLI[CLI Entry Point
gww main.py] + Commands[Command Handlers
clone, add, remove, pull, migrate, init] + end + + subgraph "Core Services Layer" + ConfigMgr[Config Manager
loader, validator, resolver] + TemplateEngine[Template Engine
evaluator, functions] + GitOps[Git Operations
repository, worktree, branch] + ActionSys[Action System
matcher, executor] + end + + subgraph "Utilities Layer" + Utils[Utilities
shell, URI parsing, XDG paths] + end + + subgraph "External Dependencies" + Git[Git CLI] + ConfigFile[YAML Config File
~/.config/gww/config.yml] + FileSystem[File System] + end + + CLI --> Commands + Commands --> ConfigMgr + Commands --> GitOps + Commands --> ActionSys + + ConfigMgr --> TemplateEngine + ConfigMgr --> ConfigFile + ConfigMgr --> Utils + + TemplateEngine --> Utils + + GitOps --> Git + GitOps --> FileSystem + GitOps --> Utils + + ActionSys --> TemplateEngine + ActionSys --> FileSystem + ActionSys --> Utils + + Commands --> TemplateEngine + + style CLI fill:#e1f5ff + style Commands fill:#e1f5ff + style ConfigMgr fill:#fff4e1 + style TemplateEngine fill:#fff4e1 + style GitOps fill:#fff4e1 + style ActionSys fill:#fff4e1 + style Utils fill:#e8f5e9 + style Git fill:#fce4ec + style ConfigFile fill:#fce4ec + style FileSystem fill:#fce4ec +
+
+ + + \ No newline at end of file diff --git a/architecture.md b/docs/architecture.md similarity index 59% rename from architecture.md rename to docs/architecture.md index 1899288..ca7b1e0 100644 --- a/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,102 @@ # Git worktree wrapper with additional fuctions Name of console command is gww +## High-Level Architecture + +```mermaid +graph TB + subgraph "User Interface Layer" + CLI[CLI Entry Point
gww main.py] + Commands[Command Handlers
clone, add, remove, pull, migrate, init] + end + + subgraph "Core Services Layer" + ConfigMgr[Config Manager
loader, validator, resolver] + TemplateEngine[Template Engine
evaluator, functions] + GitOps[Git Operations
repository, worktree, branch] + ActionSys[Action System
matcher, executor] + end + + subgraph "Utilities Layer" + Utils[Utilities
shell, URI parsing, XDG paths] + end + + subgraph "External Dependencies" + Git[Git CLI] + ConfigFile[YAML Config File
~/.config/gww/config.yml] + FileSystem[File System] + end + + CLI --> Commands + Commands --> ConfigMgr + Commands --> GitOps + Commands --> ActionSys + + ConfigMgr --> TemplateEngine + ConfigMgr --> ConfigFile + ConfigMgr --> Utils + + TemplateEngine --> Utils + + GitOps --> Git + GitOps --> FileSystem + GitOps --> Utils + + ActionSys --> TemplateEngine + ActionSys --> FileSystem + ActionSys --> Utils + + Commands --> TemplateEngine + + style CLI fill:#e1f5ff + style Commands fill:#e1f5ff + style ConfigMgr fill:#fff4e1 + style TemplateEngine fill:#fff4e1 + style GitOps fill:#fff4e1 + style ActionSys fill:#fff4e1 + style Utils fill:#e8f5e9 + style Git fill:#fce4ec + style ConfigFile fill:#fce4ec + style FileSystem fill:#fce4ec +``` + +### Component Descriptions + +**CLI Layer** (`src/gww/cli/`) +- **main.py**: Entry point, argument parsing, command routing +- **commands/**: Individual command implementations (clone, add, remove, pull, migrate, init) + +**Config Layer** (`src/gww/config/`) +- **loader.py**: YAML config file loading/saving using ruamel.yaml +- **validator.py**: Config structure validation +- **resolver.py**: Path resolution based on URI conditions and templates + +**Template Layer** (`src/gww/template/`) +- **evaluator.py**: Template evaluation engine using simpleeval with strict type checking +- **functions.py**: Template function registry (URI, branch, tag, utility, project-specific functions) + +**Git Layer** (`src/gww/git/`) +- **repository.py**: Git repository operations (clone, pull, status checks) +- **worktree.py**: Git worktree management (add, remove, list) +- **branch.py**: Branch operations and normalization + +**Actions Layer** (`src/gww/actions/`) +- **matcher.py**: Match project rules based on `when` conditions +- **executor.py**: Execute actions (abs_copy, rel_copy, command) + +**Utils Layer** (`src/gww/utils/`) +- **shell.py**: Shell completion generation +- **uri.py**: URI parsing and manipulation +- **xdg.py**: XDG config directory resolution + +### Data Flow + +1. **Clone Flow**: `CLI` → `clone` command → `ConfigMgr` resolves path → `TemplateEngine` evaluates template → `GitOps` clones repo → `ActionSys` matches and executes `after_clone` actions + +2. **Add Worktree Flow**: `CLI` → `add` command → `ConfigMgr` resolves worktree path → `TemplateEngine` evaluates template → `GitOps` creates worktree → `ActionSys` matches and executes `after_add` actions + +3. **Config Resolution**: `ConfigMgr` loads YAML → evaluates `when` conditions using `TemplateEngine` → selects matching source rule → evaluates path templates → returns resolved paths + # Configuration Works with configuration file gww.yml located in $XDG_CONFIG_HOME compliant manner diff --git a/src/gww/actions/matcher.py b/src/gww/actions/matcher.py index 5df3c6b..71bc0a0 100644 --- a/src/gww/actions/matcher.py +++ b/src/gww/actions/matcher.py @@ -29,7 +29,7 @@ def _create_predicate_context( source_path: Path, tags: dict[str, str] = {}, dest_path: Optional[Path] = None, -) -> dict[str, object]: +) -> dict[str, Any]: """Create evaluation context for project predicates. Uses the unified FunctionRegistry for shared functions and adds @@ -48,7 +48,7 @@ def _create_predicate_context( """ # Create shared functions from unified registry (tags only, no URI/branch) context = TemplateContext(source_path=source_path, tags=tags) - functions: dict[str, object] = create_function_registry(context) + functions: dict[str, Any] = create_function_registry(context) # Add project-specific functions project_functions = create_project_functions(source_path, dest_path) diff --git a/src/gww/cli/commands/init.py b/src/gww/cli/commands/init.py index d813c2f..253894d 100644 --- a/src/gww/cli/commands/init.py +++ b/src/gww/cli/commands/init.py @@ -10,6 +10,7 @@ generate_completion, get_completion_path, get_installation_instructions, + install_aliases, install_completion, ) from gww.utils.xdg import get_config_path @@ -79,7 +80,7 @@ def run_init_shell(args: argparse.Namespace) -> int: # Install completion try: - path = install_completion(shell) + completion_path = install_completion(shell) except ValueError as e: print(f"Error: {e}", file=sys.stderr) return 1 @@ -87,9 +88,19 @@ def run_init_shell(args: argparse.Namespace) -> int: print(f"Error installing completion: {e}", file=sys.stderr) return 1 + # Install aliases + try: + aliases_path = install_aliases(shell) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + except OSError as e: + print(f"Error installing aliases: {e}", file=sys.stderr) + return 1 + # Print instructions if not quiet: - instructions = get_installation_instructions(shell, path) + instructions = get_installation_instructions(shell, completion_path, aliases_path) print(instructions) return 0 diff --git a/src/gww/config/resolver.py b/src/gww/config/resolver.py index 0ef29f5..20fe767 100644 --- a/src/gww/config/resolver.py +++ b/src/gww/config/resolver.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import Optional +from typing import Any, Optional from gww.config.validator import Config, SourceRule from gww.template.evaluator import TemplateError, evaluate_predicate, evaluate_template @@ -31,7 +31,7 @@ def _expand_home(path: str) -> str: return path -def _build_uri_context(uri: ParsedURI, tags: dict[str, str] = {}) -> dict[str, object]: +def _build_uri_context(uri: ParsedURI, tags: dict[str, str] = {}) -> dict[str, Any]: """Build evaluation context for URI predicates. Uses the unified FunctionRegistry to provide shared functions: diff --git a/src/gww/utils/shell.py b/src/gww/utils/shell.py index 60c6791..17a6cb6 100644 --- a/src/gww/utils/shell.py +++ b/src/gww/utils/shell.py @@ -32,6 +32,36 @@ def get_completion_path(shell: str) -> Path: raise ValueError(f"Unsupported shell: {shell}. Must be one of: bash, zsh, fish") +def get_aliases_path(shell: str) -> Path | dict[str, Path]: + """Get the default installation path for alias functions. + + Args: + shell: Shell name (bash, zsh, fish). + + Returns: + For bash/zsh: Path where aliases script should be installed. + For fish: Dictionary mapping function name to its file path. + + Raises: + ValueError: If shell is not supported. + """ + home = Path.home() + + if shell == "bash": + return home / ".bash_completion.d" / f"{APP_NAME}-aliases" + elif shell == "zsh": + return home / ".zsh" / "functions" / f"{APP_NAME}-aliases" + elif shell == "fish": + functions_dir = home / ".config" / "fish" / "functions" + return { + "gwc": functions_dir / "gwc.fish", + "gwa": functions_dir / "gwa.fish", + "gwr": functions_dir / "gwr.fish", + } + else: + raise ValueError(f"Unsupported shell: {shell}. Must be one of: bash, zsh, fish") + + def generate_bash_completion() -> str: """Generate bash completion script. @@ -216,6 +246,12 @@ def generate_fish_completion() -> str: return f'''# Fish completion for {APP_NAME} # Generated by {APP_NAME} init shell fish +# Source git.fish to import __fish_git_branches and other git completion functions +# This uses $__fish_data_dir which is set by fish at runtime +if test -f $__fish_data_dir/completions/git.fish + source $__fish_data_dir/completions/git.fish +end + # Disable file completions for all commands complete -c {APP_NAME} -f @@ -257,6 +293,286 @@ def generate_fish_completion() -> str: ''' +def generate_bash_aliases() -> str: + """Generate bash alias functions for gwc, gwa, and gwr. + + Returns: + Bash function definitions for gwc (clone), gwa (add), and gwr (remove). + """ + return f'''# Alias functions for {APP_NAME} +# Generated by {APP_NAME} init shell bash + +# gwc - Clone a repository and navigate to it +gwc() {{ + local output + local exit_code + output=$(command {APP_NAME} clone "$@" 2>&1) + exit_code=$? + if [ $exit_code -eq 0 ]; then + local target_path + target_path=$(echo "$output" | tail -n 1) + if [ -n "$target_path" ] && [ -d "$target_path" ]; then + printf "Navigate to %s? [Y/n] " "$target_path" + read -r reply + if [ -z "$reply" ] || [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then + cd "$target_path" || return 1 + fi + fi + else + echo "$output" >&2 + return $exit_code + fi +}} + +# gwa - Add a worktree and navigate to it +gwa() {{ + local output + local exit_code + output=$(command {APP_NAME} add "$@" 2>&1) + exit_code=$? + if [ $exit_code -eq 0 ]; then + local target_path + target_path=$(echo "$output" | tail -n 1) + if [ -n "$target_path" ] && [ -d "$target_path" ]; then + printf "Navigate to %s? [Y/n] " "$target_path" + read -r reply + if [ -z "$reply" ] || [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then + cd "$target_path" || return 1 + fi + fi + else + echo "$output" >&2 + return $exit_code + fi +}} + +# gwr - Remove a worktree (prompts for force if dirty) +gwr() {{ + local output + local exit_code + output=$(command {APP_NAME} remove "$@" 2>&1) + exit_code=$? + if [ $exit_code -eq 0 ]; then + echo "$output" + elif echo "$output" | grep -q "uncommitted changes\\|untracked files"; then + echo "$output" >&2 + printf "Force removal? [y/N] " + read -r reply + if [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then + command {APP_NAME} remove --force "$@" + else + return 1 + fi + else + echo "$output" >&2 + return $exit_code + fi +}} + +# Completion wrappers - reuse gww completions for aliases +_gwc_completions() {{ + COMP_WORDS=({APP_NAME} clone "${{COMP_WORDS[@]:1}}") + COMP_CWORD=$((COMP_CWORD + 1)) + _{APP_NAME}_completions +}} + +_gwa_completions() {{ + COMP_WORDS=({APP_NAME} add "${{COMP_WORDS[@]:1}}") + COMP_CWORD=$((COMP_CWORD + 1)) + _{APP_NAME}_completions +}} + +_gwr_completions() {{ + COMP_WORDS=({APP_NAME} remove "${{COMP_WORDS[@]:1}}") + COMP_CWORD=$((COMP_CWORD + 1)) + _{APP_NAME}_completions +}} + +complete -F _gwc_completions gwc +complete -F _gwa_completions gwa +complete -F _gwr_completions gwr +''' + + +def generate_zsh_aliases() -> str: + """Generate zsh alias functions for gwc, gwa, and gwr. + + Returns: + Zsh function definitions for gwc (clone), gwa (add), and gwr (remove). + """ + return f'''# Alias functions for {APP_NAME} +# Generated by {APP_NAME} init shell zsh + +# gwc - Clone a repository and navigate to it +gwc() {{ + local output + local exit_code + output=$(command {APP_NAME} clone "$@" 2>&1) + exit_code=$? + if [[ $exit_code -eq 0 ]]; then + local target_path + target_path=$(echo "$output" | tail -n 1) + if [[ -n "$target_path" ]] && [[ -d "$target_path" ]]; then + printf "Navigate to %s? [Y/n] " "$target_path" + read -r reply + if [[ -z "$reply" ]] || [[ "$reply" == "y" ]] || [[ "$reply" == "Y" ]]; then + cd "$target_path" || return 1 + fi + fi + else + echo "$output" >&2 + return $exit_code + fi +}} + +# gwa - Add a worktree and navigate to it +gwa() {{ + local output + local exit_code + output=$(command {APP_NAME} add "$@" 2>&1) + exit_code=$? + if [[ $exit_code -eq 0 ]]; then + local target_path + target_path=$(echo "$output" | tail -n 1) + if [[ -n "$target_path" ]] && [[ -d "$target_path" ]]; then + printf "Navigate to %s? [Y/n] " "$target_path" + read -r reply + if [[ -z "$reply" ]] || [[ "$reply" == "y" ]] || [[ "$reply" == "Y" ]]; then + cd "$target_path" || return 1 + fi + fi + else + echo "$output" >&2 + return $exit_code + fi +}} + +# gwr - Remove a worktree (prompts for force if dirty) +gwr() {{ + local output + local exit_code + output=$(command {APP_NAME} remove "$@" 2>&1) + exit_code=$? + if [[ $exit_code -eq 0 ]]; then + echo "$output" + elif echo "$output" | grep -q "uncommitted changes\\|untracked files"; then + echo "$output" >&2 + printf "Force removal? [y/N] " + read -r reply + if [[ "$reply" == "y" ]] || [[ "$reply" == "Y" ]]; then + command {APP_NAME} remove --force "$@" + else + return 1 + fi + else + echo "$output" >&2 + return $exit_code + fi +}} + +# Completion wrappers - reuse gww completions for aliases +_gwc() {{ + words[1]={APP_NAME} + words=(clone "${{words[@]:1}}") + ((CURRENT++)) + _{APP_NAME} +}} + +_gwa() {{ + words[1]={APP_NAME} + words=(add "${{words[@]:1}}") + ((CURRENT++)) + _{APP_NAME} +}} + +_gwr() {{ + words[1]={APP_NAME} + words=(remove "${{words[@]:1}}") + ((CURRENT++)) + _{APP_NAME} +}} + +compdef _gwc gwc +compdef _gwa gwa +compdef _gwr gwr +''' + + +def generate_fish_aliases() -> dict[str, str]: + """Generate fish alias functions for gwc, gwa, and gwr. + + Returns: + Dictionary mapping function name to fish function content. + Fish convention: one function per file. + """ + gwc_content = f'''# gwc - Clone a repository and navigate to it +# Generated by {APP_NAME} init shell fish + +function gwc --wraps="{APP_NAME} clone" --description "Clone a repository and navigate to it" + set -l output (command {APP_NAME} clone $argv 2>&1) + set -l exit_code $status + if test $exit_code -eq 0 + set -l target_path (echo $output | tail -n 1) + if test -n "$target_path" -a -d "$target_path" + read -P "Navigate to $target_path? [Y/n] " reply + if test -z "$reply" -o "$reply" = "y" -o "$reply" = "Y" + cd "$target_path" + end + end + else + echo $output >&2 + return $exit_code + end +end +''' + + gwa_content = f'''# gwa - Add a worktree and navigate to it +# Generated by {APP_NAME} init shell fish + +function gwa --wraps="{APP_NAME} add" --description "Add a worktree and navigate to it" + set -l output (command {APP_NAME} add $argv 2>&1) + set -l exit_code $status + if test $exit_code -eq 0 + set -l target_path (echo $output | tail -n 1) + if test -n "$target_path" -a -d "$target_path" + read -P "Navigate to $target_path? [Y/n] " reply + if test -z "$reply" -o "$reply" = "y" -o "$reply" = "Y" + cd "$target_path" + end + end + else + echo $output >&2 + return $exit_code + end +end +''' + + gwr_content = f'''# gwr - Remove a worktree (prompts for force if dirty) +# Generated by {APP_NAME} init shell fish + +function gwr --wraps="{APP_NAME} remove" --description "Remove a worktree (prompts for force if dirty)" + set -l output (command {APP_NAME} remove $argv 2>&1) + set -l exit_code $status + if test $exit_code -eq 0 + echo $output + else if echo $output | grep -q "uncommitted changes\\|untracked files" + echo $output >&2 + read -P "Force removal? [y/N] " reply + if test "$reply" = "y" -o "$reply" = "Y" + command {APP_NAME} remove --force $argv + else + return 1 + end + else + echo $output >&2 + return $exit_code + end +end +''' + + return {"gwc": gwc_content, "gwa": gwa_content, "gwr": gwr_content} + + def generate_completion(shell: str) -> str: """Generate completion script for specified shell. @@ -307,34 +623,129 @@ def install_completion(shell: str, path: Optional[Path] = None) -> Path: return path -def get_installation_instructions(shell: str, path: Path) -> str: - """Get instructions for activating completion after installation. +def install_aliases(shell: str) -> Path | list[Path]: + """Install alias functions for specified shell. + + Args: + shell: Shell name (bash, zsh, fish). + + Returns: + For bash/zsh: Path where aliases script was installed. + For fish: List of paths where function files were installed. + + Raises: + ValueError: If shell is not supported. + OSError: If installation fails. + """ + if shell == "bash": + path = get_aliases_path(shell) + assert isinstance(path, Path) + script = generate_bash_aliases() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(script) + return path + elif shell == "zsh": + path = get_aliases_path(shell) + assert isinstance(path, Path) + script = generate_zsh_aliases() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(script) + return path + elif shell == "fish": + paths_dict = get_aliases_path(shell) + assert isinstance(paths_dict, dict) + functions = generate_fish_aliases() + installed_paths = [] + for name, content in functions.items(): + path = paths_dict[name] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + installed_paths.append(path) + return installed_paths + else: + raise ValueError(f"Unsupported shell: {shell}. Must be one of: bash, zsh, fish") + + +def get_installation_instructions( + shell: str, + completion_path: Path, + aliases_path: Path | list[Path] | None = None, +) -> str: + """Get instructions for activating completion and aliases after installation. Args: shell: Shell name. - path: Path where completion was installed. + completion_path: Path where completion was installed. + aliases_path: Path(s) where aliases were installed (optional). Returns: Human-readable activation instructions. """ if shell == "bash": - return ( - f"Installed bash completion script: {path}\n" - f"To activate, run: source {path}\n" - f"Or add to ~/.bashrc: source {path}" + instructions = ( + f"Installed bash completion script: {completion_path}\n" ) + if aliases_path: + assert isinstance(aliases_path, Path) + instructions += f"Installed bash alias functions: {aliases_path}\n" + instructions += ( + f"\nTo activate, add to ~/.bashrc:\n" + f" source {completion_path}\n" + f" source {aliases_path}\n" + f"\nAlias functions installed:\n" + f" gwc - Clone a repository and navigate to it\n" + f" gwa - Add a worktree and navigate to it\n" + f" gwr - Remove a worktree (prompts for force if dirty)" + ) + else: + instructions += ( + f"To activate, run: source {completion_path}\n" + f"Or add to ~/.bashrc: source {completion_path}" + ) + return instructions elif shell == "zsh": - return ( - f"Installed zsh completion script: {path}\n" - f"To activate, ensure your ~/.zshrc contains:\n" - f" fpath=(~/.zsh/completions $fpath)\n" - f" autoload -Uz compinit && compinit\n" - f"Then restart your shell." + instructions = ( + f"Installed zsh completion script: {completion_path}\n" ) + if aliases_path: + assert isinstance(aliases_path, Path) + instructions += f"Installed zsh alias functions: {aliases_path}\n" + instructions += ( + f"\nTo activate, add to ~/.zshrc:\n" + f" fpath=(~/.zsh/completions $fpath)\n" + f" autoload -Uz compinit && compinit\n" + f" source {aliases_path}\n" + f"\nAlias functions installed:\n" + f" gwc - Clone a repository and navigate to it\n" + f" gwa - Add a worktree and navigate to it\n" + f" gwr - Remove a worktree (prompts for force if dirty)\n" + f"\nThen restart your shell." + ) + else: + instructions += ( + f"To activate, ensure your ~/.zshrc contains:\n" + f" fpath=(~/.zsh/completions $fpath)\n" + f" autoload -Uz compinit && compinit\n" + f"Then restart your shell." + ) + return instructions elif shell == "fish": - return ( - f"Installed fish completion script: {path}\n" - f"Restart fish shell or run: source {path}" + instructions = ( + f"Installed fish completion script: {completion_path}\n" ) + if aliases_path: + assert isinstance(aliases_path, list) + for p in aliases_path: + instructions += f"Installed fish function: {p}\n" + instructions += ( + f"\nAlias functions installed:\n" + f" gwc - Clone a repository and navigate to it\n" + f" gwa - Add a worktree and navigate to it\n" + f" gwr - Remove a worktree (prompts for force if dirty)\n" + f"\nRestart fish shell to activate." + ) + else: + instructions += f"Restart fish shell or run: source {completion_path}" + return instructions else: - return f"Installed completion script: {path}" + return f"Installed completion script: {completion_path}" diff --git a/tests/unit/test_shell_completion.py b/tests/unit/test_shell_completion.py index 916ba12..4881e17 100644 --- a/tests/unit/test_shell_completion.py +++ b/tests/unit/test_shell_completion.py @@ -174,6 +174,16 @@ def test_uses_seen_subcommand_from(self) -> None: script = generate_fish_completion() assert "__fish_seen_subcommand_from" in script + def test_sources_git_completion(self) -> None: + """Test that script sources git.fish to import __fish_git_branches.""" + script = generate_fish_completion() + # Should source git.fish to make __fish_git_branches available + assert "source" in script + assert "git.fish" in script + assert "__fish_data_dir" in script + # Should use __fish_git_branches for branch completion + assert "__fish_git_branches" in script + class TestGenerateCompletion: """Tests for generate_completion function."""