From b1c18cb8be6f4e518ad737230ab7b2fc16fae36b Mon Sep 17 00:00:00 2001 From: Chuck Danielsson Date: Tue, 8 Apr 2025 07:17:22 -0600 Subject: [PATCH 1/4] add pyproject; temp README update --- README.md | 44 +-------------- bin/lbranch | 127 ----------------------------------------- lbranch/__init__.py | 8 +++ lbranch/main.py | 134 ++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 34 +++++++++++ 5 files changed, 177 insertions(+), 170 deletions(-) delete mode 100755 bin/lbranch create mode 100644 lbranch/__init__.py create mode 100644 lbranch/main.py create mode 100644 pyproject.toml diff --git a/README.md b/README.md index 2c6b135..b308fc4 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,6 @@ # lbranch lbranch ("last branch") is a git utility that shows your recently checked out branches in chronological order, with an optional interactive checkout. -## Installation -1. Ensure you have Python 3.6+ installed -2. Clone this repository: -```bash -git clone https://github.com/yourusername/lbranch.git ~/.lbranch -``` -3. Create a symlink or add to your PATH: -```bash -# Option 1: Symlink to /usr/local/bin -ln -s ~/.lbranch/bin/lbranch /usr/local/bin/lbranch -# Option 2: Add to your PATH in ~/.zshrc or ~/.bashrc -export PATH="$PATH:~/.lbranch/bin" -``` -4. (Optional) Add an alias in your shell config: -```bash -alias lb=lbranch -``` - ## Usage ```bash # Show last 5 branches @@ -45,30 +27,6 @@ Last 5 branches: ## Requirements - Python 3.6+ - Git -- Unix-like environment (Linux, macOS, WSL) - -## Development -To run tests: -```bash -python test.py -``` - -## Uninstall -```bash -# Remove symlink (if you used Option 1) -rm /usr/local/bin/lbranch - -# Remove the repository -rm -rf ~/.lbranch - -# Remove PATH addition (if you used Option 2) -# Edit ~/.zshrc or ~/.bashrc and remove the line: -# export PATH="$PATH:~/.lbranch/bin" - -# Remove alias (if you added it) -# Edit ~/.zshrc or ~/.bashrc and remove the line: -# alias lb=lbranch -``` ## License -Distributed under the MIT License. See `LICENSE` file for more information. +Distributed under the MIT License. See `LICENSE` \ No newline at end of file diff --git a/bin/lbranch b/bin/lbranch deleted file mode 100755 index 2f1c59e..0000000 --- a/bin/lbranch +++ /dev/null @@ -1,127 +0,0 @@ -#!/usr/bin/env python3 - -# Last Branch (lbranch) - Git branch history utility -# Usage: lbranch [count] [-c|--choose] - -import subprocess -import sys -import re - -# Colors for output -RED = '\033[0;31m' -GREEN = '\033[0;32m' -BLUE = '\033[0;34m' -NC = '\033[0m' # No Color - -def print_error(message): - """Print error message and exit""" - print(f"{RED}Error: {message}{NC}", file=sys.stderr) - sys.exit(1) - -def run_command(cmd, check=True, capture_output=True): - """Run a shell command and handle errors""" - try: - result = subprocess.run( - cmd, - check=check, - text=True, - shell=isinstance(cmd, str), - capture_output=capture_output - ) - return result - except subprocess.CalledProcessError as e: - if not check: - return e - print_error(f"Command failed: {e}") - sys.exit(1) - -# Check if git is installed -try: - run_command(["git", "--version"], capture_output=True) -except FileNotFoundError: - print_error("git command not found. Please install git first.") - -# Check if we're in a git repository -if run_command(["git", "rev-parse", "--is-inside-work-tree"], check=False, capture_output=True).returncode != 0: - print_error("Not a git repository. Please run this command from within a git repository.") - -# Check if the repository has any commits -if run_command(["git", "rev-parse", "--verify", "HEAD"], check=False, capture_output=True).returncode != 0: - print(f"{BLUE}No branch history found - repository has no commits yet{NC}") - sys.exit(0) - -# Parse arguments -branch_count = 5 -choose_mode = False - -for arg in sys.argv[1:]: - if arg in ['-c', '--choose']: - choose_mode = True - elif re.match(r'^\d+$', arg): - branch_count = int(arg) - else: - print_error(f"Invalid argument: {arg}") - -# Get current branch name -try: - current_branch = run_command(["git", "symbolic-ref", "--short", "HEAD"], capture_output=True).stdout.strip() -except subprocess.CalledProcessError: - current_branch = run_command(["git", "rev-parse", "--short", "HEAD"], capture_output=True).stdout.strip() - -# Get unique branch history -reflog_output = run_command("git reflog | grep -i 'checkout: moving from'", capture_output=True).stdout - -branches = [] -for line in reflog_output.splitlines(): - # Parse the branch name after "from" - parts = line.split() - try: - from_index = parts.index("from") - if from_index + 1 < len(parts): - branch = parts[from_index + 1] - - # Skip empty, current branch, or branches starting with '{' - if not branch or branch == current_branch or branch.startswith('{'): - continue - - # Only add branch if it's not already in the list - if branch not in branches: - branches.append(branch) - except ValueError: - continue # "from" not found in this line - -# Limit to requested number of branches -total_branches = len(branches) -if total_branches == 0: - print(f"{BLUE}Last {branch_count} branches:{NC}") - sys.exit(0) - -branch_limit = min(branch_count, total_branches) -branches = branches[:branch_limit] # Limit to requested count - -# Display branches -print(f"{BLUE}Last {branch_count} branches:{NC}") -for i, branch in enumerate(branches, 1): - print(f"{i}) {branch}") - -# Handle choose mode -if choose_mode: - try: - print(f"\n{GREEN}Enter branch number to checkout (1-{branch_count}):{NC}") - branch_num = input() - - if not re.match(r'^\d+$', branch_num) or int(branch_num) < 1 or int(branch_num) > branch_count: - print_error(f"Invalid selection: {branch_num}") - - selected_branch = branches[int(branch_num) - 1] - print(f"\nChecking out: {selected_branch}") - - # Attempt to checkout the branch - result = run_command(["git", "checkout", selected_branch], check=False, capture_output=True) - if result.returncode != 0: - print_error(f"Failed to checkout branch:\n{result.stderr}") - - print(f"{GREEN}Successfully checked out {selected_branch}{NC}") - except KeyboardInterrupt: - print("\nOperation cancelled.") - sys.exit(1) diff --git a/lbranch/__init__.py b/lbranch/__init__.py new file mode 100644 index 0000000..67946a0 --- /dev/null +++ b/lbranch/__init__.py @@ -0,0 +1,8 @@ +""" +lbranch - A Git utility that shows recently checked out branches in chronological order. +""" + +from .main import main + +__version__ = "0.1.0" +__all__ = ["main"] \ No newline at end of file diff --git a/lbranch/main.py b/lbranch/main.py new file mode 100644 index 0000000..5558527 --- /dev/null +++ b/lbranch/main.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 + +# Last Branch (lbranch) - Git branch history utility +# Usage: lbranch [count] [-c|--choose] + +import subprocess +import sys +import re + +# Colors for output +RED = '\033[0;31m' +GREEN = '\033[0;32m' +BLUE = '\033[0;34m' +NC = '\033[0m' # No Color + +def print_error(message): + """Print error message and exit""" + print(f"{RED}Error: {message}{NC}", file=sys.stderr) + sys.exit(1) + +def run_command(cmd, check=True, capture_output=True): + """Run a shell command and handle errors""" + try: + result = subprocess.run( + cmd, + check=check, + text=True, + shell=isinstance(cmd, str), + capture_output=capture_output + ) + return result + except subprocess.CalledProcessError as e: + if not check: + return e + print_error(f"Command failed: {e}") + sys.exit(1) + +def main(): + """Main entry point for the lbranch command.""" + # Check if git is installed + try: + run_command(["git", "--version"], capture_output=True) + except FileNotFoundError: + print_error("git command not found. Please install git first.") + + # Check if we're in a git repository + if run_command(["git", "rev-parse", "--is-inside-work-tree"], check=False, capture_output=True).returncode != 0: + print_error("Not a git repository. Please run this command from within a git repository.") + + # Check if the repository has any commits + if run_command(["git", "rev-parse", "--verify", "HEAD"], check=False, capture_output=True).returncode != 0: + print(f"{BLUE}No branch history found - repository has no commits yet{NC}") + sys.exit(0) + + # Parse arguments + branch_count = 5 + choose_mode = False + + for arg in sys.argv[1:]: + if arg in ['-c', '--choose']: + choose_mode = True + elif re.match(r'^\d+$', arg): + branch_count = int(arg) + else: + print_error(f"Invalid argument: {arg}") + + # Get current branch name + try: + current_branch = run_command(["git", "symbolic-ref", "--short", "HEAD"], capture_output=True).stdout.strip() + except subprocess.CalledProcessError: + current_branch = run_command(["git", "rev-parse", "--short", "HEAD"], capture_output=True).stdout.strip() + + # Get unique branch history + reflog_output = run_command(["git", "reflog"], capture_output=True).stdout + + branches = [] + for line in reflog_output.splitlines(): + # Look for checkout lines without using grep + if 'checkout: moving from' in line.lower(): + # Parse the branch name after "from" + parts = line.split() + try: + from_index = parts.index("from") + if from_index + 1 < len(parts): + branch = parts[from_index + 1] + + # Skip empty, current branch, or branches starting with '{' + if not branch or branch == current_branch or branch.startswith('{'): + continue + + # Only add branch if it's not already in the list + if branch not in branches: + branches.append(branch) + except ValueError: + continue # "from" not found in this line + + # Limit to requested number of branches + total_branches = len(branches) + if total_branches == 0: + print(f"{BLUE}Last {branch_count} branches:{NC}") + sys.exit(0) + + branch_limit = min(branch_count, total_branches) + branches = branches[:branch_limit] # Limit to requested count + + # Display branches + print(f"{BLUE}Last {branch_count} branches:{NC}") + for i, branch in enumerate(branches, 1): + print(f"{i}) {branch}") + + # Handle choose mode + if choose_mode: + try: + print(f"\n{GREEN}Enter branch number to checkout (1-{branch_count}):{NC}") + branch_num = input() + + if not re.match(r'^\d+$', branch_num) or int(branch_num) < 1 or int(branch_num) > branch_count: + print_error(f"Invalid selection: {branch_num}") + + selected_branch = branches[int(branch_num) - 1] + print(f"\nChecking out: {selected_branch}") + + # Attempt to checkout the branch + result = run_command(["git", "checkout", selected_branch], check=False, capture_output=True) + if result.returncode != 0: + print_error(f"Failed to checkout branch:\n{result.stderr}") + + print(f"{GREEN}Successfully checked out {selected_branch}{NC}") + except KeyboardInterrupt: + print("\nOperation cancelled.") + sys.exit(1) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a85b9ec --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "lbranch" +version = "0.1.0" +description = "A Git utility that shows recently checked out branches in chronological order" +readme = "README.md" +requires-python = ">=3.7" +license = "MIT" +authors = [ + { name = "Charles Danielsson" } +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Software Development :: Version Control :: Git", +] +dependencies = [] + +[project.scripts] +lbranch = "lbranch.main:main" + +[tool.hatch.build] +packages = ["lbranch"] \ No newline at end of file From 64a3d6ac562878cc1d8fe3f9d52e2f7c53ce50ec Mon Sep 17 00:00:00 2001 From: Chuck Danielsson Date: Thu, 10 Apr 2025 06:07:39 -0600 Subject: [PATCH 2/4] move to test directory --- test/__init__.py | 1 + test.py => test/test_lbranch.py | 95 +++++++++++++++------------------ 2 files changed, 45 insertions(+), 51 deletions(-) create mode 100644 test/__init__.py rename test.py => test/test_lbranch.py (65%) diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..de6fbbc --- /dev/null +++ b/test/__init__.py @@ -0,0 +1 @@ +# This file makes the test directory a Python package \ No newline at end of file diff --git a/test.py b/test/test_lbranch.py similarity index 65% rename from test.py rename to test/test_lbranch.py index 6c2b01c..be268c8 100644 --- a/test.py +++ b/test/test_lbranch.py @@ -4,20 +4,15 @@ import tempfile import unittest from textwrap import dedent +import sys +from io import StringIO +from lbranch.main import main class TestLBranch(unittest.TestCase): def setUp(self): self.test_dir = tempfile.mkdtemp() - # self.test_dir = './test_dir' self.original_dir = os.getcwd() - # Ensure bin/lbranch exists and is executable - self.lbranch_path = os.path.join(self.original_dir, 'bin', 'lbranch') - if not os.path.isfile(self.lbranch_path): - raise FileNotFoundError(f"Could not find bin/lbranch at {self.lbranch_path}") - if not os.access(self.lbranch_path, os.X_OK): - raise PermissionError(f"bin/lbranch is not executable at {self.lbranch_path}") - # Setup test repository os.chdir(self.test_dir) subprocess.run(['git', 'init'], capture_output=True, check=True) @@ -41,18 +36,39 @@ def create_branch_with_commit(self, branch_name, file_content): subprocess.run(['git', 'commit', '-m', f'commit on {branch_name}'], capture_output=True, check=True) + def run_lbranch(self, args=None): + """Run lbranch and capture its output""" + if args is None: + args = [] + # Save original stdout + original_stdout = sys.stdout + # Create a string buffer to capture output + output = StringIO() + sys.stdout = output + try: + # Save original argv + original_argv = sys.argv + sys.argv = ['lbranch'] + args + try: + main() + except SystemExit as e: + # Only handle exit code 0 (success) + if e.code != 0: + raise + finally: + # Restore stdout and argv + sys.stdout = original_stdout + sys.argv = original_argv + return output.getvalue() + def test_no_commits(self): """Test behavior when repository has no commits""" # Create new branch without any commits subprocess.run(['git', 'checkout', '-b', 'empty-branch'], capture_output=True, check=True) - result = subprocess.run([self.lbranch_path], capture_output=True, text=True) - clean_output = self.strip_color_codes(result.stdout) - - # Should show no branches - expected_output = "No branch history found - repository has no commits yet" - self.assertEqual(clean_output.strip(), expected_output.strip()) + output = self.run_lbranch() + self.assertIn("No branch history found - repository has no commits yet", output) def test_first_branch_scenario(self): """Test behavior with main branch and new branch""" @@ -68,15 +84,9 @@ def test_first_branch_scenario(self): subprocess.run(['git', 'checkout', '-b', 'feature'], capture_output=True, check=True) - result = subprocess.run([self.lbranch_path], capture_output=True, text=True) - clean_output = self.strip_color_codes(result.stdout) - - expected_output = dedent("""\ - Last 5 branches: - 1) main - """) - - self.assertEqual(clean_output.strip(), expected_output.strip()) + output = self.run_lbranch() + self.assertIn("Last 5 branches:", output) + self.assertIn("1) main", output) def test_exclude_current_branch(self): """Test that current branch is excluded from results""" @@ -95,14 +105,9 @@ def test_exclude_current_branch(self): subprocess.run(['git', 'checkout', 'main'], capture_output=True, check=True) - result = subprocess.run([self.lbranch_path], capture_output=True, text=True) - clean_output = self.strip_color_codes(result.stdout) - expected_output = dedent("""\ - Last 5 branches: - 1) feature - """) - - self.assertEqual(clean_output.strip(), expected_output.strip()) + output = self.run_lbranch() + self.assertIn("Last 5 branches:", output) + self.assertIn("1) feature", output) def test_branch_order_and_format(self): # Create initial commit on main @@ -126,25 +131,13 @@ def test_branch_order_and_format(self): capture_output=True, check=True) self.create_branch_with_commit('b4', 'b4 content') - # Expected output (ignoring colors) - expected_output = dedent("""\ - Last 5 branches: - 1) dev - 2) b3 - 3) b1 - 4) b2 - 5) main - """) - - # Run lbranch - result = subprocess.run([self.lbranch_path], capture_output=True, text=True) - clean_output = self.strip_color_codes(result.stdout) - self.assertEqual(clean_output.strip(), expected_output.strip()) - - def strip_color_codes(self, text): - """Remove ANSI color codes from text""" - import re - return re.sub(r'\033\[[0-9;]*m', '', text) + output = self.run_lbranch() + self.assertIn("Last 5 branches:", output) + self.assertIn("1) dev", output) + self.assertIn("2) b3", output) + self.assertIn("3) b1", output) + self.assertIn("4) b2", output) + self.assertIn("5) main", output) if __name__ == '__main__': - unittest.main() + unittest.main() \ No newline at end of file From 40d8bd55e5d085e9dcc70be0a8ee10fa2220fccc Mon Sep 17 00:00:00 2001 From: Chuck Danielsson Date: Thu, 10 Apr 2025 06:08:14 -0600 Subject: [PATCH 3/4] update workflow --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 276c2a2..4a70ec8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,4 +24,4 @@ jobs: run: git config --global init.defaultBranch main - name: Run unittest - run: python -m unittest test.py + run: python -m unittest test/test_lbranch.py From 62e6a204dbc37025ad48b77298026af3525a5f6f Mon Sep 17 00:00:00 2001 From: Chuck Danielsson Date: Thu, 10 Apr 2025 06:09:34 -0600 Subject: [PATCH 4/4] remove unnecessary actions step --- .github/workflows/test.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4a70ec8..2819b63 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,9 +17,6 @@ jobs: with: python-version: '3.10' - - name: Make lbranch executable - run: chmod +x bin/lbranch - - name: Set default branch run: git config --global init.defaultBranch main