First Release

This commit is contained in:
2026-02-14 19:27:08 -03:00
commit 74a3b88806
5 changed files with 727 additions and 0 deletions

12
CHANGELOG.md Normal file
View File

@@ -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.

13
doc/README.md Normal file
View File

@@ -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.

38
doc/architecture.md Normal file
View File

@@ -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)

53
doc/usage.md Normal file
View File

@@ -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 -- <paths...>`).
- Unstage selected files (`git restore --staged -- <paths...>`).
- Commit staged changes (`git commit -m "<message>"`).
- 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)

611
git-tui Executable file
View File

@@ -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())