From 74a3b88806686e9eeb5814be4455e6085d2d9493 Mon Sep 17 00:00:00 2001 From: Beda Schmid Date: Sat, 14 Feb 2026 19:27:08 -0300 Subject: [PATCH] First Release --- CHANGELOG.md | 12 + doc/README.md | 13 + doc/architecture.md | 38 +++ doc/usage.md | 53 ++++ git-tui | 611 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 727 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 doc/README.md create mode 100644 doc/architecture.md create mode 100644 doc/usage.md create mode 100755 git-tui diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..953a625 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. + +## [0.1.0] - 2026-02-14 + +### Added + +- Added a single-file `git-tui` curses application for common Git flows: stage all, stage selected files, unstage selected files, commit, push, pull, fetch, and status. +- Added `doc/` documentation index and component documentation for architecture and usage. diff --git a/doc/README.md b/doc/README.md new file mode 100644 index 0000000..fa023c4 --- /dev/null +++ b/doc/README.md @@ -0,0 +1,13 @@ +# Documentation Index + +This directory contains project documentation for `git-tui`. + +## Modules + +- [`doc/architecture.md`](architecture.md) - architecture and module responsibilities. +- [`doc/usage.md`](usage.md) - installation, runtime requirements, and user workflow. + +## Conventions + +- Keep public behavior changes documented in both module docs and `CHANGELOG.md`. +- Keep this index updated when adding documentation modules. diff --git a/doc/architecture.md b/doc/architecture.md new file mode 100644 index 0000000..086ba29 --- /dev/null +++ b/doc/architecture.md @@ -0,0 +1,38 @@ +# Architecture + +## Overview + +`git-tui` is a single executable Python script that provides a terminal UI for common Git commands. + +## Module Responsibilities + +- `git-tui`: + - Initializes and runs the curses application loop. + - Renders a menu-driven UI with keyboard navigation. + - Executes Git subprocess commands and displays command output. + - Implements file selection for staged and unstaged operations. + +## Public API and Contracts + +- Command entry point: `git-tui` executable script. +- Runtime contract: + - Must be executed in a terminal that supports curses. + - Must be run from a directory inside a Git working tree. + - Requires `git` to be installed and available in `PATH`. + +## Data Model + +- `ChangedFile`: + - `index_status`: index status marker from `git status --short`. + - `worktree_status`: worktree status marker from `git status --short`. + - `path`: repository-relative file path. + +## Build and Deployment Rules + +- No build step is required. +- Deployment is a direct executable file install (copy or symlink). + +## Cross References + +- Usage and install details: [`doc/usage.md`](usage.md) +- Change tracking: [`CHANGELOG.md`](../CHANGELOG.md) diff --git a/doc/usage.md b/doc/usage.md new file mode 100644 index 0000000..e799bd8 --- /dev/null +++ b/doc/usage.md @@ -0,0 +1,53 @@ +# Usage + +## Environment Requirements + +- Linux or Unix-like terminal environment with curses support. +- Python 3 interpreter available as `python3`. +- Git installed and available in `PATH`. + +## Installation + +1. Make the script executable: + ```bash + chmod +x git-tui + ``` +2. Install as a memorable command name (example: `gtui`): + ```bash + sudo install -m 755 git-tui /usr/local/bin/gtui + ``` + +## Running + +- Enter any Git repository and run: + ```bash + gtui + ``` + +## Key Commands in UI + +- Main menu: + - `Up/Down` or `j/k` to move. + - `Enter` to execute selected action. + - `q` or `Esc` to quit. +- File selector: + - `Space` to toggle file selection. + - `a` to toggle all files. + - `Enter` to confirm. + - `q` or `Esc` to cancel. + +## Git Operations Covered + +- Stage all changes (`git add -A`). +- Stage selected files (`git add -- `). +- Unstage selected files (`git restore --staged -- `). +- Commit staged changes (`git commit -m ""`). +- Push current branch (`git push`). +- Pull with rebase (`git pull --rebase`). +- Fetch all remotes (`git fetch --all --prune`). +- Show full status (`git status`). + +## Cross References + +- Architecture and contracts: [`doc/architecture.md`](architecture.md) +- Change history: [`CHANGELOG.md`](../CHANGELOG.md) diff --git a/git-tui b/git-tui new file mode 100755 index 0000000..82e69b8 --- /dev/null +++ b/git-tui @@ -0,0 +1,611 @@ +#!/usr/bin/env python3 +"""Terminal UI for common Git workflows. + +This module provides a single-file curses application that helps users run +common Git commands from a keyboard-driven terminal interface. +""" + +from __future__ import annotations + +import curses +import shlex +import subprocess +import sys +from dataclasses import dataclass +from typing import List, Optional, Sequence + + +@dataclass(frozen=True) +class ChangedFile: + """Represents one file entry from `git status --short`. + + Attributes: + index_status: The index status character from porcelain output. + worktree_status: The worktree status character from porcelain output. + path: The repository-relative path for the change. + """ + + index_status: str + worktree_status: str + path: str + + def short_status(self) -> str: + """Returns the two-character status code for UI display. + + Returns: + A two-character status string in the same shape as porcelain output. + """ + + return f"{self.index_status}{self.worktree_status}" + + +def run_git(args: Sequence[str]) -> tuple[int, str]: + """Executes a Git command and captures combined output. + + Args: + args: Command arguments to pass after `git`. + + Returns: + A tuple of `(exit_code, output_text)` where output includes both + standard output and standard error. + """ + + command = ["git", *args] + completed = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + ) + output = (completed.stdout or "") + (completed.stderr or "") + return completed.returncode, output.rstrip() + + +def parse_changed_files(short_status_output: str) -> List[ChangedFile]: + """Parses `git status --short` output into structured file entries. + + Args: + short_status_output: Raw output from `git status --short`. + + Returns: + A list of parsed changed files in displayed order. + """ + + parsed_files: List[ChangedFile] = [] + for line in short_status_output.splitlines(): + if len(line) < 4: + continue + index_status = line[0] + worktree_status = line[1] + raw_path = line[3:] + if " -> " in raw_path: + raw_path = raw_path.split(" -> ", maxsplit=1)[1] + parsed_files.append( + ChangedFile( + index_status=index_status, + worktree_status=worktree_status, + path=raw_path, + ) + ) + return parsed_files + + +class GitTuiApp: + """Implements the interactive curses application for Git operations.""" + + MENU_ITEMS = [ + ("Stage all changes", "stage_all"), + ("Stage selected files", "stage_selected"), + ("Unstage selected files", "unstage_selected"), + ("Commit staged changes", "commit"), + ("Push current branch", "push"), + ("Pull current branch (rebase)", "pull"), + ("Fetch all remotes", "fetch"), + ("Show git status", "status"), + ("Quit", "quit"), + ] + + def __init__(self, stdscr: "curses._CursesWindow") -> None: + """Initializes UI state for the curses app. + + Args: + stdscr: The root curses window provided by `curses.wrapper`. + """ + + self.stdscr = stdscr + self.status_message = "Ready." + + def run(self) -> None: + """Runs the main menu event loop until the user quits.""" + + if not self.ensure_inside_git_repo(): + self.show_output( + "Not a Git repository", + "Run git-tui inside a Git repository working directory.", + ) + return + + self.set_cursor_visibility(0) + self.stdscr.keypad(True) + + selected_index = 0 + menu_size = len(self.MENU_ITEMS) + + while True: + self.draw_main_screen(selected_index) + key = self.stdscr.getch() + + if key in (ord("q"), 27): + return + if key in (curses.KEY_UP, ord("k")): + selected_index = (selected_index - 1) % menu_size + continue + if key in (curses.KEY_DOWN, ord("j")): + selected_index = (selected_index + 1) % menu_size + continue + if key in (10, 13, curses.KEY_ENTER): + action_name = self.MENU_ITEMS[selected_index][1] + if action_name == "quit": + return + self.run_action(action_name) + + def ensure_inside_git_repo(self) -> bool: + """Checks whether the current working directory is in a Git repository. + + Returns: + True when inside a Git work tree, otherwise False. + """ + + exit_code, output = run_git(["rev-parse", "--is-inside-work-tree"]) + return exit_code == 0 and output.strip() == "true" + + def current_branch(self) -> str: + """Determines the current branch name for header display. + + Returns: + The current branch name, or `detached` if HEAD is detached. + """ + + exit_code, output = run_git(["branch", "--show-current"]) + if exit_code != 0 or not output.strip(): + return "detached" + return output.strip() + + def changed_files(self) -> List[ChangedFile]: + """Reads the working tree status and returns changed files. + + Returns: + A list of changed files represented in short porcelain format. + """ + + exit_code, output = run_git( + ["-c", "core.quotePath=false", "status", "--short"] + ) + if exit_code != 0: + return [] + return parse_changed_files(output) + + def draw_main_screen(self, selected_index: int) -> None: + """Renders the main menu with repository summary information. + + Args: + selected_index: Index of the currently highlighted menu item. + """ + + self.stdscr.erase() + height, width = self.stdscr.getmaxyx() + branch_name = self.current_branch() + changed_count = len(self.changed_files()) + + self.safe_addstr( + 0, + 0, + f"git-tui | branch: {branch_name} | changed files: {changed_count}", + curses.A_BOLD, + ) + + for idx, (label, _) in enumerate(self.MENU_ITEMS): + prefix = ">" if idx == selected_index else " " + row_text = f"{prefix} {label}" + attr = curses.A_REVERSE if idx == selected_index else curses.A_NORMAL + self.safe_addstr(2 + idx, 0, row_text, attr) + + self.safe_addstr(height - 2, 0, self.status_message[: max(width - 1, 1)]) + self.safe_addstr( + height - 1, + 0, + "Keys: Up/Down or j/k, Enter run, q or Esc quit", + ) + self.stdscr.refresh() + + def safe_addstr( + self, row: int, col: int, text: str, attr: int = curses.A_NORMAL + ) -> None: + """Draws text safely inside current terminal bounds. + + Args: + row: Target row position in the active window. + col: Target column position in the active window. + text: The text content to draw. + attr: Optional curses text attribute. + """ + + height, width = self.stdscr.getmaxyx() + if row < 0 or row >= height or col >= width: + return + max_chars = max(width - col - 1, 0) + if max_chars == 0: + return + clipped_text = text[:max_chars] + try: + self.stdscr.addstr(row, col, clipped_text, attr) + except curses.error: + return + + def set_cursor_visibility(self, visibility: int) -> None: + """Sets cursor visibility without failing on unsupported terminals. + + Args: + visibility: Curses cursor mode where 0 hides and 1 shows. + """ + + try: + curses.curs_set(visibility) + except curses.error: + return + + def run_action(self, action_name: str) -> None: + """Dispatches a selected menu action. + + Args: + action_name: Identifier for the action selected from the menu. + """ + + if action_name == "stage_all": + self.action_stage_all() + return + if action_name == "stage_selected": + self.action_stage_selected() + return + if action_name == "unstage_selected": + self.action_unstage_selected() + return + if action_name == "commit": + self.action_commit() + return + if action_name == "push": + self.action_push() + return + if action_name == "pull": + self.action_pull() + return + if action_name == "fetch": + self.action_fetch() + return + if action_name == "status": + self.action_status() + return + + def action_stage_all(self) -> None: + """Stages all tracked and untracked changes.""" + + exit_code, output = run_git(["add", "-A"]) + self.show_command_result(["git", "add", "-A"], exit_code, output) + + def action_stage_selected(self) -> None: + """Prompts for changed files and stages selected paths.""" + + files = self.changed_files() + if not files: + self.status_message = "No changed files to stage." + return + + selected_paths = self.select_files(files, "Select files to stage") + if not selected_paths: + self.status_message = "Stage selected canceled." + return + + exit_code, output = run_git(["add", "--", *selected_paths]) + self.show_command_result( + ["git", "add", "--", *selected_paths], exit_code, output + ) + + def action_unstage_selected(self) -> None: + """Prompts for staged files and unstages selected paths.""" + + staged_files = [ + file_entry + for file_entry in self.changed_files() + if file_entry.index_status not in (" ", "?") + ] + if not staged_files: + self.status_message = "No staged files to unstage." + return + + selected_paths = self.select_files(staged_files, "Select files to unstage") + if not selected_paths: + self.status_message = "Unstage selected canceled." + return + + exit_code, output = run_git(["restore", "--staged", "--", *selected_paths]) + self.show_command_result( + ["git", "restore", "--staged", "--", *selected_paths], + exit_code, + output, + ) + + def action_commit(self) -> None: + """Prompts for a commit message and runs `git commit`.""" + + if not self.has_staged_changes(): + self.status_message = "No staged changes to commit." + return + + commit_message = self.prompt_input("Commit message") + if commit_message is None: + self.status_message = "Commit canceled." + return + + exit_code, output = run_git(["commit", "-m", commit_message]) + self.show_command_result( + ["git", "commit", "-m", commit_message], + exit_code, + output, + ) + + def action_push(self) -> None: + """Pushes current branch changes to default remote.""" + + exit_code, output = run_git(["push"]) + self.show_command_result(["git", "push"], exit_code, output) + + def action_pull(self) -> None: + """Pulls remote changes using rebase mode.""" + + exit_code, output = run_git(["pull", "--rebase"]) + self.show_command_result(["git", "pull", "--rebase"], exit_code, output) + + def action_fetch(self) -> None: + """Fetches updates from all remotes and prunes deleted refs.""" + + exit_code, output = run_git(["fetch", "--all", "--prune"]) + self.show_command_result( + ["git", "fetch", "--all", "--prune"], exit_code, output + ) + + def action_status(self) -> None: + """Shows detailed repository status output.""" + + exit_code, output = run_git(["-c", "core.quotePath=false", "status"]) + if exit_code != 0: + self.show_command_result( + ["git", "-c", "core.quotePath=false", "status"], + exit_code, + output, + ) + return + self.show_output("git status", output or "(no output)") + self.status_message = "Displayed repository status." + + def has_staged_changes(self) -> bool: + """Checks whether there are staged changes ready to commit. + + Returns: + True when `git diff --cached --quiet` reports staged differences. + """ + + exit_code, _ = run_git(["diff", "--cached", "--quiet"]) + if exit_code == 0: + return False + if exit_code == 1: + return True + return False + + def prompt_input(self, label: str) -> Optional[str]: + """Prompts for a single line of text input near the screen footer. + + Args: + label: Prompt label displayed before the input field. + + Returns: + The entered text, or None when canceled or empty. + """ + + height, width = self.stdscr.getmaxyx() + prompt = f"{label}: " + max_input_len = max(width - len(prompt) - 1, 1) + + self.safe_addstr(height - 2, 0, " " * max(width - 1, 1)) + self.safe_addstr(height - 2, 0, prompt) + self.stdscr.refresh() + + curses.echo() + self.set_cursor_visibility(1) + try: + raw_input = self.stdscr.getstr(height - 2, len(prompt), max_input_len) + except curses.error: + raw_input = b"" + finally: + curses.noecho() + self.set_cursor_visibility(0) + + value = raw_input.decode("utf-8", errors="ignore").strip() + if not value: + return None + return value + + def select_files( + self, files: Sequence[ChangedFile], title: str + ) -> List[str]: + """Shows a multiselect list of files and returns chosen paths. + + Args: + files: Candidate file entries that can be selected. + title: Header title displayed in the selector. + + Returns: + A list of selected file paths, or an empty list when canceled. + """ + + selected = [False] * len(files) + cursor_index = 0 + top_index = 0 + + while True: + self.stdscr.erase() + height, _ = self.stdscr.getmaxyx() + + self.safe_addstr(0, 0, title, curses.A_BOLD) + self.safe_addstr( + 1, + 0, + "Space toggle, a toggle all, Enter confirm, q cancel", + ) + + list_height = max(height - 4, 1) + if cursor_index < top_index: + top_index = cursor_index + if cursor_index >= top_index + list_height: + top_index = cursor_index - list_height + 1 + + for row_offset in range(list_height): + file_index = top_index + row_offset + if file_index >= len(files): + break + marker = "x" if selected[file_index] else " " + file_entry = files[file_index] + row_text = ( + f"[{marker}] {file_entry.short_status()} {file_entry.path}" + ) + row_attr = ( + curses.A_REVERSE + if file_index == cursor_index + else curses.A_NORMAL + ) + self.safe_addstr(2 + row_offset, 0, row_text, row_attr) + + self.stdscr.refresh() + key = self.stdscr.getch() + + if key in (ord("q"), 27): + return [] + if key in (10, 13, curses.KEY_ENTER): + return [ + file_entry.path + for idx, file_entry in enumerate(files) + if selected[idx] + ] + if key in (curses.KEY_UP, ord("k")): + cursor_index = (cursor_index - 1) % len(files) + continue + if key in (curses.KEY_DOWN, ord("j")): + cursor_index = (cursor_index + 1) % len(files) + continue + if key == ord(" "): + selected[cursor_index] = not selected[cursor_index] + continue + if key == ord("a"): + new_value = not all(selected) + selected = [new_value] * len(files) + continue + + def show_command_result( + self, command: Sequence[str], exit_code: int, output: str + ) -> None: + """Displays command output and updates status message. + + Args: + command: Full command arguments for display. + exit_code: Process exit code from command execution. + output: Captured process output. + """ + + command_text = shlex.join(command) + result_title = "Command succeeded" if exit_code == 0 else "Command failed" + result_body = f"$ {command_text}\n\n{output or '(no output)'}" + self.show_output(result_title, result_body) + + if exit_code == 0: + self.status_message = f"Success: {command_text}" + else: + self.status_message = f"Failed ({exit_code}): {command_text}" + + def show_output(self, title: str, text: str) -> None: + """Shows a scrollable read-only text view. + + Args: + title: Header displayed at the top of the output view. + text: Content rendered line by line. + """ + + lines = text.splitlines() if text else ["(no output)"] + offset = 0 + + while True: + self.stdscr.erase() + height, _ = self.stdscr.getmaxyx() + view_height = max(height - 3, 1) + + self.safe_addstr(0, 0, title, curses.A_BOLD) + for row_offset in range(view_height): + line_index = offset + row_offset + if line_index >= len(lines): + break + self.safe_addstr(1 + row_offset, 0, lines[line_index]) + + self.safe_addstr( + height - 1, + 0, + "Keys: Up/Down scroll, PgUp/PgDn page, q or Enter close", + ) + self.stdscr.refresh() + + key = self.stdscr.getch() + if key in (ord("q"), 27, 10, 13, curses.KEY_ENTER): + return + if key == curses.KEY_UP: + offset = max(0, offset - 1) + continue + if key == curses.KEY_DOWN: + max_offset = max(len(lines) - view_height, 0) + offset = min(max_offset, offset + 1) + continue + if key == curses.KEY_PPAGE: + offset = max(0, offset - view_height) + continue + if key == curses.KEY_NPAGE: + max_offset = max(len(lines) - view_height, 0) + offset = min(max_offset, offset + view_height) + continue + + +def run_app(stdscr: "curses._CursesWindow") -> None: + """Creates and runs the curses Git TUI application. + + Args: + stdscr: Root curses window provided by `curses.wrapper`. + """ + + app = GitTuiApp(stdscr) + app.run() + + +def main() -> int: + """Runs the program entry point and maps terminal failures to exit codes. + + Returns: + Process exit code where 0 indicates success. + """ + + try: + curses.wrapper(run_app) + except KeyboardInterrupt: + return 130 + except curses.error as error: + print(f"Terminal error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())