From bcdb4a5ca0925ab800681b38ff4ed0dce754f9c3 Mon Sep 17 00:00:00 2001 From: 0x56696B Date: Tue, 5 May 2026 13:23:07 +0300 Subject: [PATCH 1/6] feat(list): add list command to enumerate worktrees with status --- src/cmds/list/__init__.py | 0 src/cmds/list/args_list.py | 6 ++ src/cmds/list/cmd_list.py | 82 +++++++++++++++++++ src/cmds/list/result_list.py | 3 + src/cmds/list/worktree_info.py | 9 +++ src/main.py | 44 +++++++++++ tests/test_cmd_list.py | 139 +++++++++++++++++++++++++++++++++ 7 files changed, 283 insertions(+) create mode 100644 src/cmds/list/__init__.py create mode 100644 src/cmds/list/args_list.py create mode 100644 src/cmds/list/cmd_list.py create mode 100644 src/cmds/list/result_list.py create mode 100644 src/cmds/list/worktree_info.py create mode 100644 tests/test_cmd_list.py diff --git a/src/cmds/list/__init__.py b/src/cmds/list/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cmds/list/args_list.py b/src/cmds/list/args_list.py new file mode 100644 index 0000000..e986532 --- /dev/null +++ b/src/cmds/list/args_list.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass() +class ListArgs: + current_working_dir: str diff --git a/src/cmds/list/cmd_list.py b/src/cmds/list/cmd_list.py new file mode 100644 index 0000000..6bd4329 --- /dev/null +++ b/src/cmds/list/cmd_list.py @@ -0,0 +1,82 @@ +import logging + +import pygit2 as pg +from result import Err, Ok, Result + +from ...errors.not_bare_repo_err import NotBareRepoErr +from ...helpers.config_file import ensure_config_exists, read_config +from ...helpers.find_git import get_git_dir +from .args_list import ListArgs +from .result_list import ListWorktreesErr +from .worktree_info import WorktreeInfo + + +def list_worktrees(list_args: ListArgs) -> Result[list[WorktreeInfo], ListWorktreesErr]: + log = logging.getLogger(__name__) + + git_dir = get_git_dir(list_args.current_working_dir) + if not git_dir: + log.warning("No bare git repository found; working_directory=%s", list_args.current_working_dir) + return Err(NotBareRepoErr()) + + log.info("Git repository found; git_dir=%s", git_dir) + bare_repo: pg.Repository = pg.Repository(git_dir, flags=pg.enums.RepositoryOpenFlag.BARE) + + worktree_names: list[str] = bare_repo.list_worktrees() + log.debug("Found worktrees; count=%s, names=%s", len(worktree_names), worktree_names) + + worktrees: list[WorktreeInfo] = [] + + for name in worktree_names: + try: + wt: pg.Worktree = bare_repo.lookup_worktree(name) + except (KeyError, pg.GitError) as e: + log.warning("Failed to look up worktree, skipping; name=%s, error=%s", name, e) + continue + + has_unmerged: bool = _has_unmerged_commits(bare_repo, git_dir, name) + + worktrees.append( + WorktreeInfo( + name=wt.name, + path=wt.path, + is_prunable=wt.is_prunable, + has_unmerged_commits=has_unmerged, + ) + ) + + return Ok(worktrees) + + +def _has_unmerged_commits(repo: pg.Repository, git_dir: str, branch_name: str) -> bool: + log = logging.getLogger(__name__) + + ensure_res = ensure_config_exists() + match ensure_res: + case Err(_): + default_branch = "main" + case Ok(config_path): + read_res = read_config(config_path) + match read_res: + case Err(_): + default_branch = "main" + case Ok(config): + default_branch = config.get(git_dir, "default_branch_name", fallback="main") + + log.debug( + "Checking unmerged commits; branch=%s, default_branch=%s", + branch_name, + default_branch, + ) + + try: + branch_commit: pg.Commit = repo.lookup_reference(f"refs/heads/{branch_name}").peel(pg.Commit) + default_commit: pg.Commit = repo.lookup_reference(f"refs/heads/{default_branch}").peel(pg.Commit) + + merge_base: pg.Oid = repo.merge_base(branch_commit.id, default_commit.id) + + return merge_base != branch_commit.id + + except (KeyError, pg.GitError) as e: + log.warning("Could not resolve refs for merge check, skipping; error=%s", e) + return False diff --git a/src/cmds/list/result_list.py b/src/cmds/list/result_list.py new file mode 100644 index 0000000..2df605b --- /dev/null +++ b/src/cmds/list/result_list.py @@ -0,0 +1,3 @@ +from ...errors.not_bare_repo_err import NotBareRepoErr + +ListWorktreesErr = NotBareRepoErr diff --git a/src/cmds/list/worktree_info.py b/src/cmds/list/worktree_info.py new file mode 100644 index 0000000..bc455c5 --- /dev/null +++ b/src/cmds/list/worktree_info.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass() +class WorktreeInfo: + name: str + path: str + is_prunable: bool + has_unmerged_commits: bool diff --git a/src/main.py b/src/main.py index 23b8435..735a1b0 100644 --- a/src/main.py +++ b/src/main.py @@ -13,6 +13,8 @@ from .cmds.config.cmd_config import configure from .cmds.destroy.args_destroy import DestroyArgs from .cmds.destroy.cmd_destroy import destroy_repo +from .cmds.list.args_list import ListArgs +from .cmds.list.cmd_list import list_worktrees from .cmds.pull.args_pull import PullArgs from .cmds.pull.cmd_pull import pull_worktree from .cmds.rm.args_rm import RmArgs @@ -457,6 +459,48 @@ def pull(branch_name: str): exit(ExitCode.SUCCESS) +@cli.command(name="list") +@click.option("--verbose", "-v", is_flag=True, default=False, help="Show worktree paths alongside names.") +def list_cmd(verbose: bool): + """List all worktrees in the current bare repository.""" + + log = logging.getLogger(__name__) + + list_args = ListArgs(current_working_dir=os.getcwd()) + + list_res = list_worktrees(list_args) + + log.debug("Worktree list result; result=%s", list_res) + + match list_res: + case Err(NotBareRepoErr()): + log.error("Cannot find a BARE git repository in the current working directory.") + exit(ExitCode.ERR_NOT_BARE_REPO) + + case Err(_): + log.fatal("Something has gone horribly wrong. Aborting immediately!") + exit(ExitCode.ERR_GENERAL) + + case Ok([]): + click.echo("No worktrees found.") + exit(ExitCode.SUCCESS) + + case Ok(worktrees): + col_width = max(len(wt.name) for wt in worktrees) + 2 + for wt in worktrees: + tags: list[str] = [] + if wt.is_prunable: + tags.append("[prunable]") + if wt.has_unmerged_commits: + tags.append("[unmerged]") + tag_str = " " + " ".join(tags) if tags else "" + if verbose: + click.echo(f"{wt.name:<{col_width}}{wt.path}{tag_str}") + else: + click.echo(f"{wt.name}{tag_str}") + exit(ExitCode.SUCCESS) + + @cli.command() def switch(): """ diff --git a/tests/test_cmd_list.py b/tests/test_cmd_list.py new file mode 100644 index 0000000..4070274 --- /dev/null +++ b/tests/test_cmd_list.py @@ -0,0 +1,139 @@ +from pathlib import Path + +import pygit2 as pg +from result import Err, Ok + +from src.cmds.list.args_list import ListArgs +from src.cmds.list.cmd_list import list_worktrees +from src.errors.not_bare_repo_err import NotBareRepoErr + + +def _make_bare_repo(path: Path) -> pg.Repository: + repo = pg.init_repository(str(path), bare=True) + sig = pg.Signature("Test", "test@test.com") + tree_id = repo.TreeBuilder().write() + repo.create_commit("refs/heads/main", sig, sig, "Initial commit", tree_id, []) + repo.set_head("refs/heads/main") + return repo + + +def _add_branch(repo: pg.Repository, branch_name: str, wt_path: Path) -> pg.Worktree: + main_commit: pg.Commit = repo.lookup_reference("refs/heads/main").peel(pg.Commit) + branch_ref: pg.Reference = repo.create_reference(f"refs/heads/{branch_name}", main_commit.id, False) + return repo.add_worktree(branch_name, str(wt_path), branch_ref) + + +class TestListWorktreesNotBareRepo: + def test_non_bare_repo_returns_err(self, tmp_path: Path) -> None: + non_bare = tmp_path / "not-bare" + non_bare.mkdir() + pg.init_repository(str(non_bare), bare=False) + + args = ListArgs(current_working_dir=str(non_bare), ) + result = list_worktrees(args) + + assert isinstance(result, Err) + assert isinstance(result.err(), NotBareRepoErr) + + def test_plain_directory_returns_err(self, tmp_path: Path) -> None: + args = ListArgs(current_working_dir=str(tmp_path), ) + result = list_worktrees(args) + + assert isinstance(result, Err) + assert isinstance(result.err(), NotBareRepoErr) + + +class TestListWorktreesEmpty: + def test_bare_repo_with_no_worktrees_returns_empty_list(self, tmp_path: Path) -> None: + bare = tmp_path / "bare" + bare.mkdir() + _make_bare_repo(bare) + + args = ListArgs(current_working_dir=str(bare)) + result = list_worktrees(args) + + assert isinstance(result, Ok) + assert result.ok() == [] + + +class TestListWorktreesSingle: + def test_single_worktree_name_and_path(self, tmp_path: Path) -> None: + bare = tmp_path / "bare" + bare.mkdir() + repo = _make_bare_repo(bare) + + wt_path = tmp_path / "feat-x" + _add_branch(repo, "feat-x", wt_path) + + args = ListArgs(current_working_dir=str(bare)) + result = list_worktrees(args) + + assert isinstance(result, Ok) + worktrees = result.ok() + assert len(worktrees) == 1 + assert worktrees[0].name == "feat-x" + assert worktrees[0].path.rstrip("/") == str(wt_path) + + def test_worktree_not_prunable_when_path_exists(self, tmp_path: Path) -> None: + bare = tmp_path / "bare" + bare.mkdir() + repo = _make_bare_repo(bare) + _add_branch(repo, "feat-x", tmp_path / "feat-x") + + args = ListArgs(current_working_dir=str(bare)) + result = list_worktrees(args) + + assert isinstance(result, Ok) + assert result.ok()[0].is_prunable is False + + +class TestListWorktreesMultiple: + def test_multiple_worktrees_all_listed(self, tmp_path: Path) -> None: + bare = tmp_path / "bare" + bare.mkdir() + repo = _make_bare_repo(bare) + + _add_branch(repo, "feat-a", tmp_path / "feat-a") + _add_branch(repo, "feat-b", tmp_path / "feat-b") + _add_branch(repo, "feat-c", tmp_path / "feat-c") + + args = ListArgs(current_working_dir=str(bare)) + result = list_worktrees(args) + + assert isinstance(result, Ok) + names = {wt.name for wt in result.ok()} + assert names == {"feat-a", "feat-b", "feat-c"} + + +class TestListWorktreesUnmerged: + def test_worktree_on_same_commit_as_main_has_no_unmerged(self, tmp_path: Path) -> None: + bare = tmp_path / "bare" + bare.mkdir() + repo = _make_bare_repo(bare) + _add_branch(repo, "my-branch", tmp_path / "my-branch") + + args = ListArgs(current_working_dir=str(bare)) + result = list_worktrees(args) + + assert isinstance(result, Ok) + assert result.ok()[0].has_unmerged_commits is False + + def test_worktree_with_extra_commit_has_unmerged(self, tmp_path: Path) -> None: + bare = tmp_path / "bare" + bare.mkdir() + repo = _make_bare_repo(bare) + _add_branch(repo, "feat-x", tmp_path / "feat-x") + + sig = pg.Signature("Test", "test@test.com") + blob_id = repo.create_blob(b"extra") + tb = repo.TreeBuilder() + tb.insert("extra.txt", blob_id, pg.GIT_FILEMODE_BLOB) + tree_id = tb.write() + parent: pg.Commit = repo.lookup_reference("refs/heads/feat-x").peel(pg.Commit) + repo.create_commit("refs/heads/feat-x", sig, sig, "extra commit", tree_id, [parent.id]) + + args = ListArgs(current_working_dir=str(bare)) + result = list_worktrees(args) + + assert isinstance(result, Ok) + assert result.ok()[0].has_unmerged_commits is True From 7584f06b19b6f18dd0ebf457dcfe827c8aee1244 Mon Sep 17 00:00:00 2001 From: 0x56696B Date: Mon, 11 May 2026 09:40:21 +0300 Subject: [PATCH 2/6] fix(list): resolve actual branch name via worktree HEAD not worktree name --- src/cmds/list/cmd_list.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/cmds/list/cmd_list.py b/src/cmds/list/cmd_list.py index 6bd4329..9f65977 100644 --- a/src/cmds/list/cmd_list.py +++ b/src/cmds/list/cmd_list.py @@ -34,7 +34,14 @@ def list_worktrees(list_args: ListArgs) -> Result[list[WorktreeInfo], ListWorktr log.warning("Failed to look up worktree, skipping; name=%s, error=%s", name, e) continue - has_unmerged: bool = _has_unmerged_commits(bare_repo, git_dir, name) + try: + wt_repo: pg.Repository = pg.Repository(wt.path) + actual_branch: str = wt_repo.head.shorthand + except (KeyError, pg.GitError) as e: + log.warning("Could not resolve branch for worktree; name=%s, error=%s", wt.name, e) + actual_branch = wt.name # fallback to name + + has_unmerged: bool = _has_unmerged_commits(bare_repo, git_dir, actual_branch) worktrees.append( WorktreeInfo( From 4f9772e8c1c77965234df66a48cb69d3b7bf68ac Mon Sep 17 00:00:00 2001 From: 0x56696B Date: Mon, 11 May 2026 09:40:32 +0300 Subject: [PATCH 3/6] fix(list): log warning when config unavailable in merge check --- src/cmds/list/cmd_list.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cmds/list/cmd_list.py b/src/cmds/list/cmd_list.py index 9f65977..932ba02 100644 --- a/src/cmds/list/cmd_list.py +++ b/src/cmds/list/cmd_list.py @@ -60,12 +60,14 @@ def _has_unmerged_commits(repo: pg.Repository, git_dir: str, branch_name: str) - ensure_res = ensure_config_exists() match ensure_res: - case Err(_): + case Err(e): + log.warning("Config not found, defaulting to main; error=%s", e) default_branch = "main" case Ok(config_path): read_res = read_config(config_path) match read_res: - case Err(_): + case Err(e): + log.warning("Failed to read config, defaulting to main; error=%s", e) default_branch = "main" case Ok(config): default_branch = config.get(git_dir, "default_branch_name", fallback="main") From a6d4dc8a3dcd06f97015c72484ae08c13f15ccec Mon Sep 17 00:00:00 2001 From: 0x56696B Date: Mon, 11 May 2026 09:41:56 +0300 Subject: [PATCH 4/6] fix(find-git): normalize returned git_dir to absolute path --- src/helpers/find_git.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/helpers/find_git.py b/src/helpers/find_git.py index 57338d6..77d1800 100644 --- a/src/helpers/find_git.py +++ b/src/helpers/find_git.py @@ -7,7 +7,7 @@ def get_git_dir(start: str) -> str | None: # If git is a file, then we're in a branch of the worktree if git.is_file(): - return str(path.parent) + return str(path.parent.absolute()) # If git is a dir, then we're not in a bare repo if git.is_dir(): From f3f0fff51e0e89b3522f2d8bcc06e5546647b8b6 Mon Sep 17 00:00:00 2001 From: 0x56696B Date: Mon, 11 May 2026 15:54:43 +0300 Subject: [PATCH 5/6] fix(list): guard merge_base None on orphan or unrelated branch --- src/cmds/list/cmd_list.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cmds/list/cmd_list.py b/src/cmds/list/cmd_list.py index 932ba02..d77b0a6 100644 --- a/src/cmds/list/cmd_list.py +++ b/src/cmds/list/cmd_list.py @@ -82,8 +82,9 @@ def _has_unmerged_commits(repo: pg.Repository, git_dir: str, branch_name: str) - branch_commit: pg.Commit = repo.lookup_reference(f"refs/heads/{branch_name}").peel(pg.Commit) default_commit: pg.Commit = repo.lookup_reference(f"refs/heads/{default_branch}").peel(pg.Commit) - merge_base: pg.Oid = repo.merge_base(branch_commit.id, default_commit.id) - + merge_base: pg.Oid | None = repo.merge_base(branch_commit.id, default_commit.id) + if merge_base is None: + return False return merge_base != branch_commit.id except (KeyError, pg.GitError) as e: From 8989449e239686cebbc86e490e0ffea2633f06c0 Mon Sep 17 00:00:00 2001 From: 0x56696B Date: Mon, 11 May 2026 15:54:54 +0300 Subject: [PATCH 6/6] fix(list): compute col_width only when verbose flag set --- src/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.py b/src/main.py index 735a1b0..cb1ad93 100644 --- a/src/main.py +++ b/src/main.py @@ -486,7 +486,7 @@ def list_cmd(verbose: bool): exit(ExitCode.SUCCESS) case Ok(worktrees): - col_width = max(len(wt.name) for wt in worktrees) + 2 + col_width = max(len(wt.name) for wt in worktrees) + 2 if verbose else 0 for wt in worktrees: tags: list[str] = [] if wt.is_prunable: