diff --git a/README.md b/README.md index 8a150aa..dbb8f43 100644 --- a/README.md +++ b/README.md @@ -6,157 +6,38 @@ **Manage a workspace of git repositories** -wcheck compares different workspaces of git repositories and reports their differences. It supports: +wcheck compares different workspaces of git repositories and reports their differences. It supports local workspaces and YAML configuration files ([vcstool](https://github.com/dirk-thomas/vcstool) format). -- **Local workspaces** - directories containing multiple git repositories -- **Configuration files** - YAML files defining expected repository versions (vcstool format) +## Quick Install -## Features - -- ๐Ÿ” Check status of all repositories in a workspace -- ๐Ÿ“Š Compare workspace against configuration files -- ๐Ÿ”„ Compare multiple configuration files -- ๐Ÿ“ˆ Track version changes across git branches -- ๐Ÿ–ฅ๏ธ Optional GUI for branch management - -## Installation - -**Using uv (recommended):** ```bash -uv tool install wcheck +uv tool install wcheck # Basic +uv tool install wcheck[gui] # With GUI (PySide6) +uv tool install wcheck[tui] # With TUI (Textual) ``` -**Using pip:** -```bash -pip install wcheck -``` - -**With GUI support:** -```bash -pip install wcheck[gui] -``` - -## Quick Start - -### Check Workspace Status +## Quick Example ```bash +# Check status of all repositories wcheck status -``` -Output: -``` -โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”“ -โ”ƒ Repo Name โ”ƒ Current Workspace โ”ƒ -โ”กโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ•‡โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ฉ -โ”‚ my-project (2M 1U) โ”‚ feature/new-feature โ”‚ -โ”‚ another-repo (1โ†‘) โ”‚ main โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -``` - -**Status indicators:** -- `U` - Untracked files -- `M` - Modified files -- `S` - Staged files -- `โ†‘` - Commits to push -- `โ†“` - Commits to pull - -### Compare with Configuration - -```bash +# Compare with configuration file wcheck wconfig -c workspace.yaml -``` - -### Compare Multiple Configs -```bash -wcheck config-list -c robot_a.yaml -c robot_b.yaml +# Interactive terminal interface +wcheck status --tui ``` -### Compare Across Branches - -```bash -wcheck config-versions -c workspace.yaml -``` - -## Commands - -| Command | Description | -|---------|-------------| -| `status` | Check status of repositories in workspace | -| `wconfig` | Compare workspace to configuration file | -| `config-list` | Compare multiple configuration files | -| `config-versions` | Compare config across git branches | - -## Common Options - -| Option | Description | -|--------|-------------| -| `-w, --workspace-directory` | Workspace path (default: current dir) | -| `-c, --config` | Configuration file path | -| `-f, --full` | Show all repos, not just differences | -| `-v, --verbose` | Verbose output | -| `--show-time` | Show time since last commit | -| `--fetch` | Fetch remotes before checking | -| `--gui` | Launch graphical interface | - -## Configuration File Format - -wcheck uses [vcstool](https://github.com/dirk-thomas/vcstool)-compatible YAML files: - -```yaml -repositories: - my-project: - type: git - url: git@github.com:user/my-project.git - version: main - - another-repo: - type: git - url: https://github.com/user/another-repo.git - version: v1.0.0 -``` - -## Similar Projects - -- [vcstool](https://github.com/dirk-thomas/vcstool) - Version control system tool for managing multiple repositories -- [myrepos](https://myrepos.branchable.com/) - Tool to manage all your version control repos -- [repo](https://gerrit.googlesource.com/git-repo/) - Google's tool for managing multiple Git repositories -- [gita](https://github.com/nosarthur/gita) - Manage multiple Git repos with ease -- [mu-repo](https://github.com/fabioz/mu-repo) - Tool to work with multiple Git repositories - ## Documentation -๐Ÿ“– **Full documentation:** [https://dpastorm.github.io/wcheck/](https://dpastorm.github.io/wcheck/) - -- [Installation Guide](https://dpastorm.github.io/wcheck/installation/) -- [Quick Start](https://dpastorm.github.io/wcheck/quickstart/) -- [CLI Reference](https://dpastorm.github.io/wcheck/cli/) -- [Configuration Files](https://dpastorm.github.io/wcheck/configuration/) -- [API Reference](https://dpastorm.github.io/wcheck/api/) - -## Development - -```bash -# Clone repository -git clone https://github.com/dpastorm/wcheck.git -cd wcheck - -# Install with dev dependencies -uv sync --extra dev - -# Run tests -uv run pytest +๐Ÿ“– **Full documentation:** [https://pastord.github.io/wcheck/](https://pastord.github.io/wcheck/) -# Build documentation locally -uv sync --extra docs -uv run mkdocs serve -``` +- [Installation Guide](https://pastord.github.io/wcheck/installation/) - Detailed installation instructions +- [Quick Start](https://pastord.github.io/wcheck/quickstart/) - Get started in minutes +- [CLI Reference](https://pastord.github.io/wcheck/cli/) - Complete command reference +- [Configuration Files](https://pastord.github.io/wcheck/configuration/) - YAML file format ## License MIT License - see [LICENSE](LICENSE) for details. - -## Author - -Daniel Pastor (danpasmor@gmail.com) diff --git a/docs/cli.md b/docs/cli.md index f5bb692..a111fa2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -33,6 +33,7 @@ wcheck status [OPTIONS] | `--show-time` | flag | Show time since last commit | | `--fetch` | flag | Fetch from remotes before checking status | | `--gui` | flag | Launch GUI interface | +| `--tui` | flag | Launch TUI interface | ### Examples @@ -66,6 +67,24 @@ wcheck status --fetch --show-time wcheck status --gui ``` +**Compare multiple workspaces side by side:** + +```bash +wcheck status -w /path/to/workspace1 -w /path/to/workspace2 +``` + +This displays a table with one column per workspace, showing branch differences: + +``` +Comparing 2 workspaces +โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”“ +โ”ƒ Repo Name โ”ƒ workspace1 โ”ƒ workspace2 โ”ƒ +โ”กโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ•‡โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ•‡โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ฉ +โ”‚ project-a โ”‚ main โ”‚ develop โ”‚ +โ”‚ project-b โ”‚ v1.0.0 (2M) โ”‚ v1.1.0 โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + ### Output ``` @@ -107,6 +126,7 @@ wcheck wconfig [OPTIONS] | `-v, --verbose` | flag | Show more detailed information | | `--show-time` | flag | Show time since last commit | | `--gui` | flag | Launch GUI interface | +| `--tui` | flag | Launch TUI interface | ### Examples @@ -261,18 +281,47 @@ Comparing config versions in workspace.yaml ## GUI Mode -When using `--gui` with `status` or `wconfig`, a graphical interface opens. +The `--gui` flag (with `status` or `wconfig`) opens a graphical interface. + +**Features:** + +- Branch selector dropdown +- Checkout button +- Open in editor button +- Visual status indicators +- Config version comparison + +!!! note "Requirements" + Install with: `pip install wcheck[gui]` + +--- + +## TUI Mode + +The `--tui` flag (with `status` or `wconfig`) opens an interactive terminal interface. + +```bash +wcheck status --tui +wcheck wconfig -c config.yaml --tui +``` + +**Key bindings:** + +| Key | Action | +|-----|--------| +| `โ†‘/โ†“` | Navigate repositories | +| `Enter` | Open branch selection | +| `b` | Select branch (alternative) | +| `e` | Open in editor (`$EDITOR`) | +| `r` | Refresh | +| `q` | Quit | -The GUI provides: +**Features:** -- **Branch selector** - Dropdown to select branches/tags -- **Checkout button** - Switch to selected branch -- **Editor button** - Open repository in your editor (uses `$EDITOR` or VS Code) -- **Status indicators** - Visual highlighting for dirty repositories -- **Config comparison** - Shows expected vs actual versions (in wconfig mode) +- Repository table with status indicators +- Branch selection modal +- Config version comparison (in wconfig mode) +- Keyboard-driven navigation -!!! note "GUI Requirements" - The GUI requires the optional `gui` dependencies. Install with: - ```bash - pip install wcheck[gui] - ``` +!!! note "Requirements" + Install with: `pip install wcheck[tui]` diff --git a/docs/index.md b/docs/index.md index c8a0609..573d9d2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,46 +2,38 @@ **Manage a workspace of git repositories** -wcheck is a command-line tool for managing and comparing workspaces containing multiple git repositories. It helps you track the status of multiple repositories, compare them against configuration files, and manage versions across different environments. +wcheck is a command-line tool for managing and comparing workspaces containing multiple git repositories. It helps you track the status of repositories, compare them against configuration files, and manage versions across different environments. ## Features -- ๐Ÿ” **Clear Status checking** - See the status of all repositories in a workspace at a glance. -- ๐Ÿ“Š **Configuration comparison** - Compare workspaces against YAML configuration files to use with vcs-tools +- ๐Ÿ” **Status checking** - See the status of all repositories at a glance +- ๐Ÿ“Š **Configuration comparison** - Compare workspaces against YAML configuration files (vcstool format) - ๐Ÿ”„ **Version tracking** - Compare repository versions across git branches and tags -- ๐Ÿ–ฅ๏ธ **GUI support** - Optional graphical interface for branch management -- โšก **Fast and efficient** - Built with modern Python for quick execution +- ๐Ÿ–ฅ๏ธ **GUI support** - Optional graphical interface for branch management (PySide6) +- ๐Ÿ’ป **TUI support** - Optional terminal interface for branch management (Textual) ## Quick Example ```bash -# Check status of all repositories in current directory +# Check status of all repositories wcheck status # Compare workspace to a configuration file wcheck wconfig -c config.yaml -# Compare multiple configuration files -wcheck config-list -c robot_a.yaml -c robot_b.yaml +# Interactive terminal interface +wcheck status --tui ``` -## Installation +## Getting Started -```bash -# Using uv (recommended) -uv tool install wcheck - -# Using pip -pip install wcheck -``` - -## Documentation - -- [Installation Guide](installation.md) - Detailed installation instructions -- [Quick Start](quickstart.md) - Get started in minutes -- [CLI Reference](cli.md) - Complete command reference -- [API Reference](api.md) - Python API documentation +| | | +|---|---| +| ๐Ÿ“ฅ **[Installation](installation.md)** | Install wcheck using uv or pip, with optional GUI/TUI support | +| ๐Ÿš€ **[Quick Start](quickstart.md)** | Get started with basic commands in minutes | +| ๐Ÿ’ป **[CLI Reference](cli.md)** | Complete documentation for all commands and options | +| ๐Ÿ“„ **[Configuration](configuration.md)** | Learn about YAML configuration file format | ## License -MIT License - see [LICENSE](https://github.com/dpastorm/wcheck/blob/main/LICENSE) for details. +MIT License - see [LICENSE](https://github.com/PastorD/wcheck/blob/main/LICENSE) for details. diff --git a/docs/installation.md b/docs/installation.md index 7fc3d6a..1ef1c9e 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -9,108 +9,74 @@ ### Using uv (Recommended) -The easiest way to install wcheck is using [uv](https://docs.astral.sh/uv/), which installs the package in an isolated environment: +The easiest way to install wcheck is using [uv](https://docs.astral.sh/uv/): ```bash uv tool install wcheck ``` -To use the GUI, install with the `gui` extra: - -```bash -uv tool install 'wcheck[gui]' -``` -then you can use the flag `--gui` with commands like `status` and `wconfig` to display the graphical interface. ### Using pip -You can also install using pip: - ```bash pip install wcheck ``` -It is recommended to use a virtual environment, like venv or conda, to avoid conflicts with other packages. For venv, you can do: -```bash -python -m venv wcheck-env -source wcheck-env/bin/activate -pip install wcheck -``` -To use conda, install it first from a suitable distribution like Anaconda or Miniconda, then create a new environment: - -```bash -conda create -n wcheck-env python=3.9 -conda activate wcheck-env -pip install wcheck -``` +!!! tip "Virtual Environment" + It's recommended to use a virtual environment: + ```bash + python -m venv wcheck-env + source wcheck-env/bin/activate + pip install wcheck + ``` ### From Source -To install from source for development: - -```bash -git clone https://github.com/dpastorm/wcheck.git -cd wcheck -pip install -e . -``` - -Or using uv: - ```bash -git clone https://github.com/dpastorm/wcheck.git +git clone https://github.com/PastorD/wcheck.git cd wcheck -uv sync +uv sync # or: pip install -e . ``` ## Optional Dependencies -### GUI Support +### GUI Support (PySide6) -To enable the graphical user interface, install with the `gui` extra: +For the graphical interface with branch selection dialogs: ```bash -pip install wcheck[gui] +uv tool install 'wcheck[gui]' +# or: pip install wcheck[gui] ``` -Or with uv: +Then use `--gui` flag with `status` or `wconfig` commands. -```bash -uv sync --extra gui -``` +### TUI Support (Textual) -This installs PySide6 for the Qt-based GUI. - -### Development Dependencies - -For development and testing: +For the interactive terminal interface: ```bash -pip install wcheck[dev] +uv tool install 'wcheck[tui]' +# or: pip install wcheck[tui] ``` -Or with uv: +Then use `--tui` flag with `status` or `wconfig` commands. + +### Development Dependencies ```bash uv sync --extra dev +# or: pip install wcheck[dev] ``` ## Verifying Installation -After installation, verify wcheck is working: - ```bash wcheck --version ``` -You should see the version number displayed. - ## Updating -To update to the latest version: - ```bash -# Using uv uv tool upgrade wcheck - -# Using pip -pip install --upgrade wcheck +# or: pip install --upgrade wcheck ``` diff --git a/docs/quickstart.md b/docs/quickstart.md index 40ed540..1363675 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,34 +1,32 @@ # Quick Start -This guide will help you get started with wcheck in just a few minutes. +Get started with wcheck in just a few minutes. -## Basic Workflow +## Running wcheck -The simpliest way to use wcheck is with [`uvx`](https://docs.astral.sh/uv/): +With [uvx](https://docs.astral.sh/uv/): ```bash uvx wcheck [options] ``` -To use with wcheck with the graphical interface, add the `--gui` flag. If you use `uvx`, make sure use the `gui` extra: + +Or after [installing](installation.md): ```bash -uvx --from 'wcheck[gui]' wcheck --gui [options] +wcheck [options] ``` - - -To install wcheck, see the [Installation Guide](installation.md). +## Basic Commands ### 1. Check Repository Status -Navigate to a directory containing multiple git repositories and run: +Navigate to a directory with git repositories and run: ```bash wcheck status ``` -This will display a table showing the status of each repository: - +Output: ``` โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”“ โ”ƒ Repo Name โ”ƒ Current Workspace โ”ƒ @@ -38,82 +36,62 @@ This will display a table showing the status of each repository: โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` -#### Status Legend - -| Symbol | Meaning | -|--------|---------| -| `U` | Untracked files | -| `M` | Modified files | -| `S` | Staged files | -| `โ†‘` | Commits ahead of remote (to push) | -| `โ†“` | Commits behind remote (to pull) | - ### 2. Compare with Configuration File -If you have a YAML configuration file defining expected repository versions: - ```bash wcheck wconfig -c workspace.yaml ``` -This shows which repositories match or differ from the configuration: +### 3. Compare Multiple Configs -``` -โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ณโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”“ -โ”ƒ Repo Name โ”ƒ Workspace version โ”ƒ Config version โ”ƒ -โ”กโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ•‡โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ•‡โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”ฉ -โ”‚ my-project โ”‚ feature/branch โ”‚ main โ”‚ -โ”‚ another-repo โ”‚ main โ”‚ main โ”‚ -โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +```bash +wcheck config-list -c robot_a.yaml -c robot_b.yaml ``` -### 3. Compare Configuration Files +### 4. Compare Multiple Workspaces -Compare two or more configuration files to see differences: +Compare the same repositories across different directories: ```bash -wcheck config-list -c robot_a.yaml -c robot_b.yaml +wcheck status -w /path/to/workspace1 -w /path/to/workspace2 ``` -### 4. Compare Versions Across Branches +### 5. Interactive Interface -See how a configuration file differs across git branches: +Use the TUI for interactive branch management: ```bash -wcheck config-versions -c workspace.yaml +wcheck status --tui ``` -## Common Options +| Key | Action | +|-----|--------| +| `โ†‘/โ†“` | Navigate | +| `Enter` | Select branch | +| `e` | Open in editor | +| `q` | Quit | -These options work with most commands: +## Status Indicators + +| Symbol | Meaning | +|--------|---------| +| `U` | Untracked files | +| `M` | Modified files | +| `S` | Staged files | +| `โ†‘` | Commits to push | +| `โ†“` | Commits to pull | + +## Common Options | Option | Description | |--------|-------------| -| `-f, --full` | Show all repositories, not just differences | -| `-v, --verbose` | Show detailed output | -| `--show-time` | Show time since last commit | -| `--gui` | Launch graphical interface | -| `-w, --workspace-directory` | Specify workspace path | - -## Example Configuration File - -Create a `workspace.yaml` file: - -```yaml -repositories: - my-project: - type: git - url: git@github.com:user/my-project.git - version: main - - another-repo: - type: git - url: git@github.com:user/another-repo.git - version: v1.0.0 -``` +| `-f, --full` | Show all repositories | +| `-v, --verbose` | Detailed output | +| `--show-time` | Time since last commit | +| `--gui` | Graphical interface | +| `--tui` | Terminal interface | ## Next Steps -- Read the [CLI Reference](cli.md) for complete command documentation -- Learn about [Configuration Files](configuration.md) in detail -- Check the [API Reference](api.md) if you want to use wcheck programmatically +- [CLI Reference](cli.md) - Complete command documentation +- [Configuration](configuration.md) - YAML file format diff --git a/mkdocs.yml b/mkdocs.yml index 16145c8..e70d722 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -68,4 +68,4 @@ nav: extra: social: - icon: fontawesome/brands/github - link: https://github.com/dpastorm/wcheck + link: https://github.com/PastorD/wcheck diff --git a/pyproject.toml b/pyproject.toml index ec82e89..1143166 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,9 @@ dev = [ gui = [ "pyside6>=6.4.0", ] +tui = [ + "textual>=0.50.0", +] docs = [ "mkdocs>=1.6.0", "mkdocs-material>=9.5.0", diff --git a/src/wcheck/tui.py b/src/wcheck/tui.py new file mode 100644 index 0000000..6b19453 --- /dev/null +++ b/src/wcheck/tui.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +"""TUI module for wcheck - provides a Textual-based terminal interface for managing repositories.""" + +import os +import subprocess +from typing import NoReturn + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import ( + DataTable, + Footer, + Header, + Label, + OptionList, + Static, +) +from textual.widgets.option_list import Option + +from git import Repo + + +def get_repo_head_ref(repo: Repo) -> str: + """Get the current HEAD reference for a repository. + + Returns the branch name, tag name, or commit SHA depending on the state: + - If on a branch: returns branch name + - If detached at a tag: returns tag name + - If detached at a commit: returns commit SHA + + Args: + repo: Git repository object. + + Returns: + String representing the current HEAD reference. + """ + try: + _ = repo.head.commit + except ValueError: + # No commits yet + try: + return repo.active_branch.name + " (no commits)" + except TypeError: + return "(no commits)" + + if repo.head.is_detached: + repo_commit = repo.head.commit.hexsha + for tag in repo.tags: + if tag.commit.hexsha == repo_commit: + return tag.name + return repo_commit[:8] # Short SHA for display + else: + return repo.active_branch.name + + +def get_repo_status_indicator(repo: Repo) -> str: + """Get a status indicator string for a repository. + + Args: + repo: Git repository object. + + Returns: + Status indicator string (e.g., 'โ—' for dirty, 'โ—‹' for clean). + """ + try: + _ = repo.head.commit + has_commits = True + except ValueError: + has_commits = False + + if repo.is_dirty() or len(repo.untracked_files) > 0: + return "[yellow]โ—[/yellow]" # Dirty + elif not has_commits: + return "[dim]โ—‹[/dim]" # No commits + else: + return "[green]โ—‹[/green]" # Clean + + +class BranchSelectScreen(ModalScreen[str | None]): + """Modal screen for selecting a branch to checkout. + + Attributes: + repo_name: Name of the repository. + repo: Git repository object. + current_branch: Currently active branch name. + """ + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("enter", "select", "Select"), + ] + + def __init__( + self, + repo_name: str, + repo: Repo, + current_branch: str, + ) -> None: + """Initialize the branch selection screen. + + Args: + repo_name: Name of the repository. + repo: Git repository object. + current_branch: Currently active branch name. + """ + super().__init__() + self.repo_name = repo_name + self.repo = repo + self.current_branch = current_branch + + def compose(self) -> ComposeResult: + """Compose the branch selection UI.""" + yield Vertical( + Label(f"Select branch for [bold]{self.repo_name}[/bold]", id="title"), + OptionList(id="branch-list"), + Static("Press [bold]Enter[/bold] to checkout, [bold]Escape[/bold] to cancel", id="help"), + id="branch-dialog", + ) + + def on_mount(self) -> None: + """Populate the branch list when the screen is mounted.""" + option_list = self.query_one("#branch-list", OptionList) + + # Add current branch first (marked) + option_list.add_option(Option(f"โ— {self.current_branch} (current)", id=self.current_branch)) + + # Add other branches and tags + added_refs = {self.current_branch} + for ref in self.repo.references: + ref_name = ref.name + # Strip origin/ prefix for display but keep for identification + display_name = ref_name + if ref_name.startswith("origin/"): + display_name = ref_name.replace("origin/", "", 1) + + if display_name not in added_refs: + option_list.add_option(Option(f" {display_name}", id=ref_name)) + added_refs.add(display_name) + + def action_cancel(self) -> None: + """Cancel branch selection.""" + self.dismiss(None) + + def action_select(self) -> None: + """Select the highlighted branch.""" + option_list = self.query_one("#branch-list", OptionList) + if option_list.highlighted is not None: + selected = option_list.get_option_at_index(option_list.highlighted) + if selected.id != self.current_branch: + self.dismiss(selected.id) + else: + self.dismiss(None) + else: + self.dismiss(None) + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + """Handle option selection via click or enter.""" + if event.option.id != self.current_branch: + self.dismiss(event.option.id) + else: + self.dismiss(None) + + +class WCheckTUI(App[None]): + """Main TUI application for wcheck. + + Displays a table of repositories with their current branches and status, + providing controls for switching branches and opening in editor. + + Attributes: + repos: Dictionary mapping repository names to Repo objects. + config_file_path: Path to the configuration file. + config_repo: Dictionary mapping repository names to their configured versions. + """ + + CSS = """ + #branch-dialog { + align: center middle; + width: 60; + height: auto; + max-height: 80%; + background: $surface; + border: thick $primary; + padding: 1 2; + } + + #title { + text-align: center; + width: 100%; + padding-bottom: 1; + } + + #help { + text-align: center; + width: 100%; + padding-top: 1; + color: $text-muted; + } + + #branch-list { + height: auto; + max-height: 20; + } + + DataTable { + height: 1fr; + } + + #status-bar { + dock: bottom; + height: 1; + background: $primary; + color: $text; + padding: 0 1; + } + """ + + BINDINGS = [ + Binding("q", "quit", "Quit"), + Binding("b", "select_branch", "Select Branch"), + Binding("e", "open_editor", "Open Editor"), + Binding("r", "refresh", "Refresh"), + ] + + def __init__( + self, + repos: dict[str, Repo], + config_file_path: str = "", + config_repo: dict[str, str] | None = None, + ) -> None: + """Initialize the WCheckTUI application. + + Args: + repos: Dictionary mapping repository names to Repo objects. + config_file_path: Path to the configuration file. + config_repo: Dictionary mapping repository names to their configured versions. + """ + super().__init__() + self.repos = repos + self.config_file_path = config_file_path + self.config_repo = config_repo + + def compose(self) -> ComposeResult: + """Compose the main UI layout.""" + yield Header() + if self.config_file_path: + yield Static(f"Config: {self.config_file_path}", id="config-label") + yield DataTable(id="repo-table") + yield Footer() + + def on_mount(self) -> None: + """Set up the data table when the app is mounted.""" + table = self.query_one("#repo-table", DataTable) + table.cursor_type = "row" + + # Add columns + table.add_column("Status", key="status", width=6) + table.add_column("Repository", key="repo", width=30) + table.add_column("Branch", key="branch", width=30) + if self.config_repo is not None: + table.add_column("Config Version", key="config", width=20) + + self._populate_table() + + def _populate_table(self) -> None: + """Populate or refresh the repository table.""" + table = self.query_one("#repo-table", DataTable) + table.clear() + + for repo_name in sorted(self.repos.keys()): + repo = self.repos[repo_name] + status = get_repo_status_indicator(repo) + branch = get_repo_head_ref(repo) + + row_data = [status, repo_name, branch] + + if self.config_repo is not None: + if repo_name in self.config_repo: + config_version = self.config_repo[repo_name] + if config_version != branch: + config_version = f"[red]{config_version}[/red]" + else: + config_version = "[dim]N/A[/dim]" + row_data.append(config_version) + + table.add_row(*row_data, key=repo_name) + + def _get_selected_repo(self) -> tuple[str, Repo] | None: + """Get the currently selected repository. + + Returns: + Tuple of (repo_name, Repo) or None if no selection. + """ + table = self.query_one("#repo-table", DataTable) + if table.cursor_row is not None: + row_data = table.get_row_at(table.cursor_row) + # The repo name is in the second column (index 1) + repo_name = row_data[1] + if repo_name in self.repos: + return repo_name, self.repos[repo_name] + return None + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """Handle row selection (Enter key) on the DataTable.""" + self.action_select_branch() + + def action_select_branch(self) -> None: + """Open the branch selection dialog for the selected repository.""" + selected = self._get_selected_repo() + if selected is None: + self.notify("No repository selected", severity="warning") + return + + repo_name, repo = selected + current_branch = get_repo_head_ref(repo) + + def handle_branch_selection(branch: str | None) -> None: + """Handle the result of branch selection.""" + if branch is not None: + # Strip origin/ prefix if present + checkout_branch = branch + if checkout_branch.startswith("origin/"): + checkout_branch = checkout_branch.replace("origin/", "", 1) + try: + repo.git.checkout(checkout_branch) + self._populate_table() + self.notify(f"Checked out {checkout_branch} in {repo_name}") + except Exception as e: + self.notify(f"Checkout failed: {e}", severity="error") + + self.push_screen( + BranchSelectScreen(repo_name, repo, current_branch), + handle_branch_selection, + ) + + def action_open_editor(self) -> None: + """Open the selected repository in an external editor.""" + selected = self._get_selected_repo() + if selected is None: + self.notify("No repository selected", severity="warning") + return + + repo_name, repo = selected + editor_command = os.getenv("EDITOR", "code") + repo_path = repo.working_tree_dir + + try: + subprocess.Popen([editor_command, repo_path]) + self.notify(f"Opened {repo_name} in {editor_command}") + except Exception as e: + self.notify(f"Failed to open editor: {e}", severity="error") + + def action_refresh(self) -> None: + """Refresh the repository table.""" + self._populate_table() + self.notify("Refreshed") + + +def show_tui( + repos: dict[str, Repo], + config_file_path: str = "", + config_repo: dict[str, str] | None = None, +) -> NoReturn: + """Launch the TUI application for managing repositories. + + Creates and runs the WCheckTUI application. This function does not + return as it enters the Textual event loop. + + Args: + repos: Dictionary mapping repository names to Repo objects. + config_file_path: Path to the configuration file (displayed in UI). + config_repo: Dictionary mapping repository names to their configured versions. + """ + app = WCheckTUI(repos, config_file_path, config_repo) + app.run() diff --git a/src/wcheck/wcheck.py b/src/wcheck/wcheck.py index 7e13263..e5a3220 100644 --- a/src/wcheck/wcheck.py +++ b/src/wcheck/wcheck.py @@ -35,6 +35,23 @@ def _show_gui( show_gui(repos, config_file_path, config_repo) +def _show_tui( + repos: dict[str, Repo], + config_file_path: str = "", + config_repo: dict[str, str] | None = None, +) -> None: + """Lazy import and call show_tui to avoid importing Textual unless needed. + + Args: + repos: Dictionary mapping repository names to Repo objects. + config_file_path: Path to the configuration file. + config_repo: Dictionary mapping repository names to their configured versions. + """ + from wcheck.tui import show_tui + + show_tui(repos, config_file_path, config_repo) + + ##################################### UTILITLY FUNCTIONS ################### @@ -556,6 +573,7 @@ def check_workspace_status( show_time: bool = False, fetch: bool = False, gui: bool = False, + tui: bool = False, ) -> None: """Check and display the status of all repositories in a workspace. @@ -569,12 +587,18 @@ def check_workspace_status( show_time: If True, include time since last commit in the output. fetch: If True, fetch from remotes before checking status. gui: If True, launch the GUI interface instead of console output. + tui: If True, launch the TUI interface instead of console output. """ # Load workspace source_repos = get_workspace_repos(workspace_directory) if gui: _show_gui(source_repos) + return + + if tui: + _show_tui(source_repos) + return if fetch: for repo_name in source_repos: @@ -671,6 +695,7 @@ def compare_workspace_to_config( verbose: bool = False, show_time: bool = False, gui: bool = False, + tui: bool = False, ) -> None: """Compare workspace repository versions with a configuration file. @@ -684,6 +709,7 @@ def compare_workspace_to_config( verbose: If True, print additional information during processing. show_time: If True, include time since last commit in the output. gui: If True, launch the GUI interface instead of console output. + tui: If True, launch the TUI interface instead of console output. """ # Load workspace @@ -714,6 +740,11 @@ def compare_workspace_to_config( if gui: _show_gui(source_repos, config_filename, config_file_version) + return + + if tui: + _show_tui(source_repos, config_filename, config_file_version) + return repos_workspace_config_versions = {} repos_workspace_config_versions["Workspace version"] = ( @@ -750,7 +781,8 @@ def cli(): @click.option("--show-time", is_flag=True, help="Show last modified time") @click.option("--fetch", is_flag=True, help="Fetch remote branches") @click.option("--gui", is_flag=True, help="Use GUI to change branches") -def status(workspace_directories, full, verbose, show_time, fetch, gui): +@click.option("--tui", is_flag=True, help="Use TUI to change branches") +def status(workspace_directories, full, verbose, show_time, fetch, gui, tui): """Check the status of all repositories in a workspace. When a single workspace is specified (or none, using current directory), @@ -773,11 +805,12 @@ def status(workspace_directories, full, verbose, show_time, fetch, gui): show_time, fetch=fetch, gui=gui, + tui=tui, ) else: # Multiple workspaces: compare side by side - if gui: - click.echo("GUI mode is not supported for multi-workspace comparison") + if gui or tui: + click.echo("GUI/TUI mode is not supported for multi-workspace comparison") return click.echo(f"Comparing {len(workspace_directories)} workspaces") compare_workspaces( @@ -813,7 +846,8 @@ def status(workspace_directories, full, verbose, show_time, fetch, gui): @click.option("-v", "--verbose", is_flag=True, help="Show more information") @click.option("--show-time", is_flag=True, help="Show last modified time") @click.option("--gui", is_flag=True, help="Use GUI to change branches") -def wconfig(workspace_directory, config, full, verbose, show_time, gui): +@click.option("--tui", is_flag=True, help="Use TUI to change branches") +def wconfig(workspace_directory, config, full, verbose, show_time, gui, tui): """Compare the workspace with a configuration file.""" if not workspace_directory: click.echo("Source directory is not specified, using current directory") @@ -825,6 +859,7 @@ def wconfig(workspace_directory, config, full, verbose, show_time, gui): verbose, show_time, gui, + tui, ) diff --git a/tests/test_status.py b/tests/test_status.py index 8bb908e..e19f0b8 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -181,7 +181,7 @@ def test_status_multiple_workspaces_shows_differences(self, tmp_path): assert "shared_repo" in result.output def test_status_multiple_workspaces_gui_not_supported(self, tmp_path): - """Test that GUI mode is not supported for multi-workspace comparison.""" + """Test that GUI/TUI mode is not supported for multi-workspace comparison.""" workspace1 = tmp_path / "ws1" workspace2 = tmp_path / "ws2" workspace1.mkdir() @@ -192,7 +192,14 @@ def test_status_multiple_workspaces_gui_not_supported(self, tmp_path): cli, ["status", "-w", str(workspace1), "-w", str(workspace2), "--gui"], ) - assert "GUI mode is not supported for multi-workspace comparison" in result.output + assert "GUI/TUI mode is not supported for multi-workspace comparison" in result.output + + # Also test with --tui flag + result = runner.invoke( + cli, + ["status", "-w", str(workspace1), "-w", str(workspace2), "--tui"], + ) + assert "GUI/TUI mode is not supported for multi-workspace comparison" in result.output def test_status_single_workspace_unchanged_behavior(self, temp_workspace): """Test that single workspace behavior is unchanged.""" diff --git a/uv.lock b/uv.lock index 6d34597..55db981 100644 --- a/uv.lock +++ b/uv.lock @@ -334,6 +334,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/b3/85aef151a052a40521f5b54005908a22c437dd4c952800d5e5efce99a47d/librt-0.7.7-cp39-cp39-win_amd64.whl", hash = "sha256:264720fc288c86039c091a4ad63419a5d7cabbf1c1c9933336a957ed2483e570", size = 48957, upload-time = "2026-01-01T23:52:21.43Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, +] + [[package]] name = "markdown" version = "3.9" @@ -376,6 +388,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py", marker = "python_full_version < '3.10'" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -391,6 +408,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py", marker = "python_full_version >= '3.10'" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -443,6 +465,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/d3/fe08482b5cd995033556d45041a4f4e76e7f0521112a9c9991d40d39825f/markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8", size = 13928, upload-time = "2025-09-27T18:37:39.037Z" }, ] +[[package]] +name = "mdit-py-plugins" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload-time = "2024-09-09T20:27:49.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload-time = "2024-09-09T20:27:48.397Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1079,6 +1131,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, ] +[[package]] +name = "textual" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["linkify"], marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["linkify"], marker = "python_full_version >= '3.10'" }, + { name = "mdit-py-plugins", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "mdit-py-plugins", version = "0.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "platformdirs", version = "4.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/4b/24ae02d857ec0fa6661d27a989994e65c6a3b3d56c7177b2d8e022d29ccc/textual-7.2.0.tar.gz", hash = "sha256:5355f2dc16fbdc452a714dee2e440125e33b82373b3032cb53bea96e7019fa0b", size = 1582530, upload-time = "2026-01-11T17:40:50.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/b8/cc8ed2548ff1bf0fd719dd399bb56869d82a57143c2772cfd57f68efc1d3/textual-7.2.0-py3-none-any.whl", hash = "sha256:2624077f02dbd504beea9a24a943770f954f500a5f29a0bfa83465c52fa3ea1c", size = 715809, upload-time = "2026-01-11T17:40:48.679Z" }, +] + [[package]] name = "tomli" version = "2.3.0" @@ -1122,6 +1194,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] +[[package]] +name = "uc-micro-py" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" @@ -1167,7 +1248,7 @@ wheels = [ [[package]] name = "wcheck" -version = "0.2.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -1194,6 +1275,9 @@ docs = [ gui = [ { name = "pyside6" }, ] +tui = [ + { name = "textual" }, +] [package.metadata] requires-dist = [ @@ -1209,8 +1293,9 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0" }, { name = "rich", specifier = ">=12.6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.9" }, + { name = "textual", marker = "extra == 'tui'", specifier = ">=0.50.0" }, ] -provides-extras = ["dev", "gui", "docs"] +provides-extras = ["dev", "gui", "tui", "docs"] [[package]] name = "zipp"