From d10fbcc803e08815749a85a09c4d95c077d396d4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 18 Jun 2024 15:17:14 -0500 Subject: [PATCH 01/79] cmd(git) Git.branches (including management query) --- src/libvcs/cmd/git.py | 315 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 315 insertions(+) diff --git a/src/libvcs/cmd/git.py b/src/libvcs/cmd/git.py index e7f82c798..83062e789 100644 --- a/src/libvcs/cmd/git.py +++ b/src/libvcs/cmd/git.py @@ -8,6 +8,7 @@ import typing as t from collections.abc import Sequence +from libvcs._internal.query_list import QueryList from libvcs._internal.run import ProgressCallbackProtocol, run from libvcs._internal.types import StrOrBytesPath, StrPath @@ -23,6 +24,7 @@ class Git: submodule: GitSubmoduleCmd remote: GitRemoteCmd stash: GitStashCmd + branch: GitBranchManager def __init__( self, @@ -83,6 +85,7 @@ def __init__( self.submodule = GitSubmoduleCmd(path=self.path, cmd=self) self.remote = GitRemoteCmd(path=self.path, cmd=self) self.stash = GitStashCmd(path=self.path, cmd=self) + self.branches = GitBranchManager(path=self.path, cmd=self) def __repr__(self) -> str: """Representation of Git repo command object.""" @@ -2950,3 +2953,315 @@ def save( check_returncode=check_returncode, log_in_real_time=log_in_real_time, ) + + +GitBranchCommandLiteral = t.Literal[ + # "create", # checkout -b + # "checkout", # checkout + "--list", + "move", # branch -m, or branch -M with force + "copy", # branch -c, or branch -C with force + "delete", # branch -d, or branch -D /ith force + "set_upstream", + "unset_upstream", + "track", + "no_track", + "edit_description", +] + + +class GitBranchCmd: + """Run commands directly against a git branch for a git repo.""" + + branch_name: str + + def __init__( + self, + *, + path: StrPath, + branch_name: str, + cmd: Git | None = None, + ) -> None: + """Lite, typed, pythonic wrapper for git-branch(1). + + Parameters + ---------- + path : + Operates as PATH in the corresponding git subcommand. + branch_name: + Name of branch. + + Examples + -------- + >>> GitBranchCmd(path=tmp_path, branch_name='master') + + + >>> GitBranchCmd(path=tmp_path, branch_name="master").run(quiet=True) + 'fatal: not a git repository (or any of the parent directories): .git' + + >>> GitBranchCmd( + ... path=git_local_clone.path, branch_name="master").run(quiet=True) + '* master' + """ + #: Directory to check out + self.path: pathlib.Path + if isinstance(path, pathlib.Path): + self.path = path + else: + self.path = pathlib.Path(path) + + self.cmd = cmd if isinstance(cmd, Git) else Git(path=self.path) + + self.branch_name = branch_name + + def __repr__(self) -> str: + """Representation of git branch command object.""" + return f"" + + def run( + self, + command: GitBranchCommandLiteral | None = None, + local_flags: list[str] | None = None, + *, + quiet: bool | None = None, + cached: bool | None = None, # Only when no command entered and status + # Pass-through to run() + log_in_real_time: bool = False, + check_returncode: bool | None = None, + **kwargs: t.Any, + ) -> str: + """Run a command against a git repository's branch. + + Wraps `git branch `_. + + Examples + -------- + >>> GitBranchCmd(path=git_local_clone.path, branch_name='master').run() + '* master' + """ + local_flags = local_flags if isinstance(local_flags, list) else [] + if command is not None: + local_flags.insert(0, command) + + if quiet is True: + local_flags.append("--quiet") + if cached is True: + local_flags.append("--cached") + + return self.cmd.run( + ["branch", *local_flags], + check_returncode=check_returncode, + log_in_real_time=log_in_real_time, + ) + + def checkout(self) -> str: + """Git branch checkout. + + Examples + -------- + >>> GitBranchCmd(path=git_local_clone.path, branch_name='master').checkout() + "Your branch is up to date with 'origin/master'." + """ + return self.cmd.run( + [ + "checkout", + *[self.branch_name], + ], + ) + + def create(self) -> str: + """Create a git branch. + + Examples + -------- + >>> GitBranchCmd(path=git_local_clone.path, branch_name='master').create() + "fatal: a branch named 'master' already exists" + """ + return self.cmd.run( + [ + "checkout", + *["-b", self.branch_name], + ], + # Pass-through to run() + check_returncode=False, + ) + + +class GitBranchManager: + """Run commands directly related to git branches of a git repo.""" + + branch_name: str + + def __init__( + self, + *, + path: StrPath, + cmd: Git | None = None, + ) -> None: + """Wrap some of git-branch(1), git-checkout(1), manager. + + Parameters + ---------- + path : + Operates as PATH in the corresponding git subcommand. + + Examples + -------- + >>> GitBranchManager(path=tmp_path) + + + >>> GitBranchManager(path=tmp_path).run(quiet=True) + 'fatal: not a git repository (or any of the parent directories): .git' + + >>> GitBranchManager( + ... path=git_local_clone.path).run(quiet=True) + '* master' + """ + #: Directory to check out + self.path: pathlib.Path + if isinstance(path, pathlib.Path): + self.path = path + else: + self.path = pathlib.Path(path) + + self.cmd = cmd if isinstance(cmd, Git) else Git(path=self.path) + + def __repr__(self) -> str: + """Representation of git branch manager object.""" + return f"" + + def run( + self, + command: GitBranchCommandLiteral | None = None, + local_flags: list[str] | None = None, + *, + quiet: bool | None = None, + cached: bool | None = None, # Only when no command entered and status + # Pass-through to run() + log_in_real_time: bool = False, + check_returncode: bool | None = None, + **kwargs: t.Any, + ) -> str: + """Run a command against a git repository's branches. + + Wraps `git branch `_. + + Examples + -------- + >>> GitBranchManager(path=git_local_clone.path).run() + '* master' + """ + local_flags = local_flags if isinstance(local_flags, list) else [] + if command is not None: + local_flags.insert(0, command) + + if quiet is True: + local_flags.append("--quiet") + if cached is True: + local_flags.append("--cached") + + return self.cmd.run( + ["branch", *local_flags], + check_returncode=check_returncode, + log_in_real_time=log_in_real_time, + ) + + def checkout(self, *, branch: str) -> str: + """Git branch checkout. + + Examples + -------- + >>> GitBranchManager(path=git_local_clone.path).checkout(branch='master') + "Your branch is up to date with 'origin/master'." + """ + return self.cmd.run( + [ + "checkout", + *[branch], + ], + ) + + def create(self, *, branch: str) -> str: + """Create a git branch. + + Examples + -------- + >>> GitBranchManager(path=git_local_clone.path).create(branch='master') + "fatal: a branch named 'master' already exists" + """ + return self.cmd.run( + [ + "checkout", + *["-b", branch], + ], + # Pass-through to run() + check_returncode=False, + ) + + def _ls(self) -> list[str]: + """List branches. + + Examples + -------- + >>> GitBranchManager(path=git_local_clone.path)._ls() + ['* master'] + """ + return self.run( + "--list", + ).splitlines() + + def ls(self) -> QueryList[GitBranchCmd]: + """List branches. + + Examples + -------- + >>> GitBranchManager(path=git_local_clone.path).ls() + [] + """ + return QueryList( + [ + GitBranchCmd(path=self.path, branch_name=branch_name.lstrip("* ")) + for branch_name in self._ls() + ], + ) + + def get(self, *args: t.Any, **kwargs: t.Any) -> GitBranchCmd | None: + """Get branch via filter lookup. + + Examples + -------- + >>> GitBranchManager( + ... path=git_local_clone.path + ... ).get(branch_name='master') + + + >>> GitBranchManager( + ... path=git_local_clone.path + ... ).get(branch_name='unknown') + Traceback (most recent call last): + exec(compile(example.source, filename, "single", + ... + return self.ls().get(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "..._internal/query_list.py", line ..., in get + raise ObjectDoesNotExist + libvcs._internal.query_list.ObjectDoesNotExist + """ + return self.ls().get(*args, **kwargs) + + def filter(self, *args: t.Any, **kwargs: t.Any) -> list[GitBranchCmd]: + """Get branches via filter lookup. + + Examples + -------- + >>> GitBranchManager( + ... path=git_local_clone.path + ... ).filter(branch_name__contains='master') + [] + + >>> GitBranchManager( + ... path=git_local_clone.path + ... ).filter(branch_name__contains='unknown') + [] + """ + return self.ls().filter(*args, **kwargs) From cabe3b78f91b8088bb19deb689c5a7393f4a81e8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 4 Jul 2024 12:24:07 -0500 Subject: [PATCH 02/79] py(git[cmd]) Add `GitRemoteManager` to `Git.remotes` --- src/libvcs/cmd/git.py | 542 +++++++++++++++++++++++++++++++++-------- src/libvcs/sync/git.py | 19 +- tests/sync/test_git.py | 8 +- 3 files changed, 454 insertions(+), 115 deletions(-) diff --git a/src/libvcs/cmd/git.py b/src/libvcs/cmd/git.py index 83062e789..c1c39c1ca 100644 --- a/src/libvcs/cmd/git.py +++ b/src/libvcs/cmd/git.py @@ -4,6 +4,7 @@ import datetime import pathlib +import re import shlex import typing as t from collections.abc import Sequence @@ -22,7 +23,7 @@ class Git: # Sub-commands submodule: GitSubmoduleCmd - remote: GitRemoteCmd + remote: GitRemoteManager stash: GitStashCmd branch: GitBranchManager @@ -47,15 +48,15 @@ def __init__( Subcommands: - >>> git.remote.show() + >>> git.remotes.show() 'origin' - >>> git.remote.add( + >>> git.remotes.add( ... name='my_remote', url=f'file:///dev/null' ... ) '' - >>> git.remote.show() + >>> git.remotes.show() 'my_remote\norigin' >>> git.stash.save(message="Message") @@ -65,9 +66,9 @@ def __init__( '' # Additional tests - >>> git.remote.remove(name='my_remote') + >>> git.remotes.get(remote_name='my_remote').remove() '' - >>> git.remote.show() + >>> git.remotes.show() 'origin' >>> git.stash.ls() @@ -83,7 +84,7 @@ def __init__( self.progress_callback = progress_callback self.submodule = GitSubmoduleCmd(path=self.path, cmd=self) - self.remote = GitRemoteCmd(path=self.path, cmd=self) + self.remotes = GitRemoteManager(path=self.path, cmd=self) self.stash = GitStashCmd(path=self.path, cmd=self) self.branches = GitBranchManager(path=self.path, cmd=self) @@ -2359,23 +2360,46 @@ def update( class GitRemoteCmd: """Run commands directly for a git remote on a git repository.""" - def __init__(self, *, path: StrPath, cmd: Git | None = None) -> None: + remote_name: str + fetch_url: str | None + push_url: str | None + + def __init__( + self, + *, + path: StrPath, + remote_name: str, + fetch_url: str | None = None, + push_url: str | None = None, + cmd: Git | None = None, + ) -> None: r"""Lite, typed, pythonic wrapper for git-remote(1). Parameters ---------- path : Operates as PATH in the corresponding git subcommand. + remote_name : + Name of remote Examples -------- - >>> GitRemoteCmd(path=tmp_path) - + >>> GitRemoteCmd( + ... path=example_git_repo.path, + ... remote_name='origin', + ... ) + - >>> GitRemoteCmd(path=tmp_path).run(verbose=True) + >>> GitRemoteCmd( + ... path=tmp_path, + ... remote_name='origin', + ... ).run(verbose=True) 'fatal: not a git repository (or any of the parent directories): .git' - >>> GitRemoteCmd(path=example_git_repo.path).run(verbose=True) + >>> GitRemoteCmd( + ... path=example_git_repo.path, + ... remote_name='origin', + ... ).run(verbose=True) 'origin\tfile:///...' """ #: Directory to check out @@ -2387,9 +2411,13 @@ def __init__(self, *, path: StrPath, cmd: Git | None = None) -> None: self.cmd = cmd if isinstance(cmd, Git) else Git(path=self.path) + self.remote_name = remote_name + self.fetch_url = fetch_url + self.push_url = push_url + def __repr__(self) -> str: """Representation of a git remote for a git repository.""" - return f"" + return f"" def run( self, @@ -2408,9 +2436,15 @@ def run( Examples -------- - >>> GitRemoteCmd(path=example_git_repo.path).run() + >>> GitRemoteCmd( + ... path=example_git_repo.path, + ... remote_name='master', + ... ).run() 'origin' - >>> GitRemoteCmd(path=example_git_repo.path).run(verbose=True) + >>> GitRemoteCmd( + ... path=example_git_repo.path, + ... remote_name='master', + ... ).run(verbose=True) 'origin\tfile:///...' """ local_flags = local_flags if isinstance(local_flags, list) else [] @@ -2426,45 +2460,6 @@ def run( log_in_real_time=log_in_real_time, ) - def add( - self, - *, - name: str, - url: str, - fetch: bool | None = None, - track: str | None = None, - master: str | None = None, - mirror: t.Literal["push", "fetch"] | bool | None = None, - # Pass-through to run() - log_in_real_time: bool = False, - check_returncode: bool | None = None, - ) -> str: - """Git remote add. - - Examples - -------- - >>> git_remote_repo = create_git_remote_repo() - >>> GitRemoteCmd(path=example_git_repo.path).add( - ... name='my_remote', url=f'file://{git_remote_repo}' - ... ) - '' - """ - local_flags: list[str] = [] - required_flags: list[str] = [name, url] - - if mirror is not None: - if isinstance(mirror, str): - assert any(f for f in ["push", "fetch"]) - local_flags.extend(["--mirror", mirror]) - if isinstance(mirror, bool) and mirror: - local_flags.append("--mirror") - return self.run( - "add", - local_flags=[*local_flags, "--", *required_flags], - check_returncode=check_returncode, - log_in_real_time=log_in_real_time, - ) - def rename( self, *, @@ -2481,10 +2476,14 @@ def rename( -------- >>> git_remote_repo = create_git_remote_repo() >>> GitRemoteCmd( - ... path=example_git_repo.path + ... path=example_git_repo.path, + ... remote_name='origin', ... ).rename(old='origin', new='new_name') '' - >>> GitRemoteCmd(path=example_git_repo.path).run() + >>> GitRemoteCmd( + ... path=example_git_repo.path, + ... remote_name='origin', + ... ).run() 'new_name' """ local_flags: list[str] = [] @@ -2505,7 +2504,6 @@ def rename( def remove( self, *, - name: str, # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, @@ -2514,13 +2512,19 @@ def remove( Examples -------- - >>> GitRemoteCmd(path=example_git_repo.path).remove(name='origin') + >>> GitRemoteCmd( + ... path=example_git_repo.path, + ... remote_name='origin', + ... ).remove() '' - >>> GitRemoteCmd(path=example_git_repo.path).run() + >>> GitRemoteCmd( + ... path=example_git_repo.path, + ... remote_name='origin', + ... ).run() '' """ local_flags: list[str] = [] - required_flags: list[str] = [name] + required_flags: list[str] = [self.remote_name] return self.run( "remove", @@ -2532,7 +2536,6 @@ def remove( def show( self, *, - name: str | None = None, verbose: bool | None = None, no_query_remotes: bool | None = None, # Pass-through to run() @@ -2543,14 +2546,21 @@ def show( Examples -------- - >>> GitRemoteCmd(path=example_git_repo.path).show() - 'origin' + >>> print( + ... GitRemoteCmd( + ... path=example_git_repo.path, + ... remote_name='origin', + ... ).show() + ... ) + * remote origin + Fetch URL: ... + Push URL: ... + HEAD branch: master + Remote branch: + master tracked... """ local_flags: list[str] = [] - required_flags: list[str] = [] - - if name is not None: - required_flags.append(name) + required_flags: list[str] = [self.remote_name] if verbose is not None: local_flags.append("--verbose") @@ -2568,7 +2578,6 @@ def show( def prune( self, *, - name: str, dry_run: bool | None = None, # Pass-through to run() log_in_real_time: bool = False, @@ -2579,14 +2588,20 @@ def prune( Examples -------- >>> git_remote_repo = create_git_remote_repo() - >>> GitRemoteCmd(path=example_git_repo.path).prune(name='origin') + >>> GitRemoteCmd( + ... path=example_git_repo.path, + ... remote_name='origin' + ... ).prune() '' - >>> GitRemoteCmd(path=example_git_repo.path).prune(name='origin', dry_run=True) + >>> GitRemoteCmd( + ... path=example_git_repo.path, + ... remote_name='origin' + ... ).prune(dry_run=True) '' """ local_flags: list[str] = [] - required_flags: list[str] = [name] + required_flags: list[str] = [self.remote_name] if dry_run: local_flags.append("--dry-run") @@ -2601,7 +2616,6 @@ def prune( def get_url( self, *, - name: str, push: bool | None = None, _all: bool | None = None, # Pass-through to run() @@ -2613,17 +2627,26 @@ def get_url( Examples -------- >>> git_remote_repo = create_git_remote_repo() - >>> GitRemoteCmd(path=example_git_repo.path).get_url(name='origin') + >>> GitRemoteCmd( + ... path=example_git_repo.path, + ... remote_name='origin' + ... ).get_url() 'file:///...' - >>> GitRemoteCmd(path=example_git_repo.path).get_url(name='origin', push=True) + >>> GitRemoteCmd( + ... path=example_git_repo.path, + ... remote_name='origin' + ... ).get_url(push=True) 'file:///...' - >>> GitRemoteCmd(path=example_git_repo.path).get_url(name='origin', _all=True) + >>> GitRemoteCmd( + ... path=example_git_repo.path, + ... remote_name='origin' + ... ).get_url(_all=True) 'file:///...' """ local_flags: list[str] = [] - required_flags: list[str] = [name] + required_flags: list[str] = [self.remote_name] if push: local_flags.append("--push") @@ -2640,7 +2663,6 @@ def get_url( def set_url( self, *, - name: str, url: str, old_url: str | None = None, push: bool | None = None, @@ -2655,21 +2677,27 @@ def set_url( Examples -------- >>> git_remote_repo = create_git_remote_repo() - >>> GitRemoteCmd(path=example_git_repo.path).set_url( - ... name='origin', + >>> GitRemoteCmd( + ... path=example_git_repo.path, + ... remote_name='origin' + ... ).set_url( ... url='http://localhost' ... ) '' - >>> GitRemoteCmd(path=example_git_repo.path).set_url( - ... name='origin', + >>> GitRemoteCmd( + ... path=example_git_repo.path, + ... remote_name='origin' + ... ).set_url( ... url='http://localhost', ... push=True ... ) '' - >>> GitRemoteCmd(path=example_git_repo.path).set_url( - ... name='origin', + >>> GitRemoteCmd( + ... path=example_git_repo.path, + ... remote_name='origin' + ... ).set_url( ... url='http://localhost', ... add=True ... ) @@ -2677,9 +2705,12 @@ def set_url( >>> current_url = GitRemoteCmd( ... path=example_git_repo.path, - ... ).get_url(name='origin') - >>> GitRemoteCmd(path=example_git_repo.path).set_url( - ... name='origin', + ... remote_name='origin' + ... ).get_url() + >>> GitRemoteCmd( + ... path=example_git_repo.path, + ... remote_name='origin' + ... ).set_url( ... url=current_url, ... delete=True ... ) @@ -2687,7 +2718,7 @@ def set_url( """ local_flags: list[str] = [] - required_flags: list[str] = [name, url] + required_flags: list[str] = [self.remote_name, url] if old_url is not None: required_flags.append(old_url) @@ -2706,6 +2737,294 @@ def set_url( ) +GitRemoteManagerLiteral = Literal[ + "--verbose", + "add", + "rename", + "remove", + "set-branches", + "set-head", + "set-branch", + "get-url", + "set-url", + "set-url --add", + "set-url --delete", + "prune", + "show", + "update", +] + + +class GitRemoteManager: + """Run commands directly related to git remotes of a git repo.""" + + remote_name: str + + def __init__( + self, + *, + path: StrPath, + cmd: Git | None = None, + ) -> None: + """Wrap some of git-remote(1), git-checkout(1), manager. + + Parameters + ---------- + path : + Operates as PATH in the corresponding git subcommand. + + Examples + -------- + >>> GitRemoteManager(path=tmp_path) + + + >>> GitRemoteManager(path=tmp_path).run(quiet=True) + 'fatal: not a git repository (or any of the parent directories): .git' + + >>> GitRemoteManager( + ... path=example_git_repo.path + ... ).run() + 'origin' + """ + #: Directory to check out + self.path: pathlib.Path + if isinstance(path, pathlib.Path): + self.path = path + else: + self.path = pathlib.Path(path) + + self.cmd = cmd if isinstance(cmd, Git) else Git(path=self.path) + + def __repr__(self) -> str: + """Representation of git remote manager object.""" + return f"" + + def run( + self, + command: GitRemoteManagerLiteral | None = None, + local_flags: list[str] | None = None, + *, + # Pass-through to run() + log_in_real_time: bool = False, + check_returncode: bool | None = None, + **kwargs: Any, + ) -> str: + """Run a command against a git repository's remotes. + + Wraps `git remote `_. + + Examples + -------- + >>> GitRemoteManager(path=example_git_repo.path).run() + 'origin' + """ + local_flags = local_flags if isinstance(local_flags, list) else [] + if command is not None: + local_flags.insert(0, command) + + return self.cmd.run( + ["remote", *local_flags], + check_returncode=check_returncode, + log_in_real_time=log_in_real_time, + ) + + def add( + self, + *, + name: str, + url: str, + fetch: bool | None = None, + track: str | None = None, + master: str | None = None, + mirror: t.Literal["push", "fetch"] | bool | None = None, + # Pass-through to run() + log_in_real_time: bool = False, + check_returncode: bool | None = None, + ) -> str: + """Git remote add. + + Examples + -------- + >>> git_remote_repo = create_git_remote_repo() + >>> GitRemoteManager(path=example_git_repo.path).add( + ... name='my_remote', + ... url=f'file://{git_remote_repo}' + ... ) + '' + """ + local_flags: list[str] = [] + required_flags: list[str] = [name, url] + + if mirror is not None: + if isinstance(mirror, str): + assert any(f for f in ["push", "fetch"]) + local_flags.extend(["--mirror", mirror]) + if isinstance(mirror, bool) and mirror: + local_flags.append("--mirror") + return self.run( + "add", + local_flags=[*local_flags, "--", *required_flags], + check_returncode=check_returncode, + log_in_real_time=log_in_real_time, + ) + + def show( + self, + *, + name: str | None = None, + verbose: bool | None = None, + no_query_remotes: bool | None = None, + # Pass-through to run() + log_in_real_time: bool = False, + check_returncode: bool | None = None, + ) -> str: + """Git remote show. + + Examples + -------- + >>> GitRemoteManager(path=example_git_repo.path).show() + 'origin' + + For the example below, add a remote: + >>> GitRemoteManager(path=example_git_repo.path).add( + ... name='my_remote', url=f'file:///dev/null' + ... ) + '' + + Retrieve a list of remote names: + >>> GitRemoteManager(path=example_git_repo.path).show().splitlines() + ['my_remote', 'origin'] + """ + local_flags: list[str] = [] + required_flags: list[str] = [] + + if name is not None: + required_flags.append(name) + + if verbose is not None: + local_flags.append("--verbose") + + if no_query_remotes is not None or no_query_remotes: + local_flags.append("-n") + + return self.run( + "show", + local_flags=[*local_flags, "--", *required_flags], + check_returncode=check_returncode, + log_in_real_time=log_in_real_time, + ) + + def _ls(self) -> str: + r"""List remotes (raw output). + + Examples + -------- + >>> GitRemoteManager(path=example_git_repo.path)._ls() + 'origin\tfile:///... (fetch)\norigin\tfile:///... (push)' + """ + return self.run( + "--verbose", + ) + + def ls(self) -> QueryList[GitRemoteCmd]: + """List remotes. + + Examples + -------- + >>> GitRemoteManager(path=example_git_repo.path).ls() + [] + + For the example below, add a remote: + >>> GitRemoteManager(path=example_git_repo.path).add( + ... name='my_remote', url=f'file:///dev/null' + ... ) + '' + + >>> GitRemoteManager(path=example_git_repo.path).ls() + [, + ] + """ + remote_str = self._ls() + remote_pattern = re.compile( + r""" + (?P\S+) # Remote name: one or more non-whitespace characters + \s+ # One or more whitespace characters + (?P\S+) # URL: one or more non-whitespace characters + \s+ # One or more whitespace characters + \((?Pfetch|push)\) # 'fetch' or 'push' in parentheses + """, + re.VERBOSE | re.MULTILINE, + ) + + remotes: dict[str, dict[str, str | None]] = {} + + for match_obj in remote_pattern.finditer(remote_str): + name = match_obj.group("name") + url = match_obj.group("url") + cmd_type = match_obj.group("cmd_type") + + if name not in remotes: + remotes[name] = {} + + remotes[name][cmd_type] = url + + remote_cmds: list[GitRemoteCmd] = [] + for name, urls in remotes.items(): + fetch_url = urls.get("fetch") + push_url = urls.get("push") + remote_cmds.append( + GitRemoteCmd( + path=self.path, + remote_name=name, + fetch_url=fetch_url, + push_url=push_url, + ), + ) + + return QueryList(remote_cmds) + + def get(self, *args: t.Any, **kwargs: t.Any) -> GitRemoteCmd | None: + """Get remote via filter lookup. + + Examples + -------- + >>> GitRemoteManager( + ... path=example_git_repo.path + ... ).get(remote_name='origin') + + + >>> GitRemoteManager( + ... path=example_git_repo.path + ... ).get(remote_name='unknown') + Traceback (most recent call last): + exec(compile(example.source, filename, "single", + ... + return self.ls().get(*args, **kwargs) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "..._internal/query_list.py", line ..., in get + raise ObjectDoesNotExist + libvcs._internal.query_list.ObjectDoesNotExist + """ + return self.ls().get(*args, **kwargs) + + def filter(self, *args: t.Any, **kwargs: t.Any) -> list[GitRemoteCmd]: + """Get remotes via filter lookup. + + Examples + -------- + >>> GitRemoteManager( + ... path=example_git_repo.path + ... ).filter(remote_name__contains='origin') + [] + + >>> GitRemoteManager( + ... path=example_git_repo.path + ... ).filter(remote_name__contains='unknown') + [] + """ + return self.ls().filter(*args, **kwargs) + + GitStashCommandLiteral = t.Literal[ "list", "show", @@ -2993,14 +3312,22 @@ def __init__( Examples -------- - >>> GitBranchCmd(path=tmp_path, branch_name='master') + >>> GitBranchCmd( + ... path=tmp_path, + ... branch_name='master' + ... ) - >>> GitBranchCmd(path=tmp_path, branch_name="master").run(quiet=True) + >>> GitBranchCmd( + ... path=tmp_path, + ... branch_name='master' + ... ).run(quiet=True) 'fatal: not a git repository (or any of the parent directories): .git' >>> GitBranchCmd( - ... path=git_local_clone.path, branch_name="master").run(quiet=True) + ... path=example_git_repo.path, + ... branch_name='master' + ... ).run(quiet=True) '* master' """ #: Directory to check out @@ -3036,7 +3363,10 @@ def run( Examples -------- - >>> GitBranchCmd(path=git_local_clone.path, branch_name='master').run() + >>> GitBranchCmd( + ... path=example_git_repo.path, + ... branch_name='master' + ... ).run() '* master' """ local_flags = local_flags if isinstance(local_flags, list) else [] @@ -3059,7 +3389,10 @@ def checkout(self) -> str: Examples -------- - >>> GitBranchCmd(path=git_local_clone.path, branch_name='master').checkout() + >>> GitBranchCmd( + ... path=example_git_repo.path, + ... branch_name='master' + ... ).checkout() "Your branch is up to date with 'origin/master'." """ return self.cmd.run( @@ -3074,7 +3407,10 @@ def create(self) -> str: Examples -------- - >>> GitBranchCmd(path=git_local_clone.path, branch_name='master').create() + >>> GitBranchCmd( + ... path=example_git_repo.path, + ... branch_name='master' + ... ).create() "fatal: a branch named 'master' already exists" """ return self.cmd.run( @@ -3114,7 +3450,7 @@ def __init__( 'fatal: not a git repository (or any of the parent directories): .git' >>> GitBranchManager( - ... path=git_local_clone.path).run(quiet=True) + ... path=example_git_repo.path).run(quiet=True) '* master' """ #: Directory to check out @@ -3148,7 +3484,7 @@ def run( Examples -------- - >>> GitBranchManager(path=git_local_clone.path).run() + >>> GitBranchManager(path=example_git_repo.path).run() '* master' """ local_flags = local_flags if isinstance(local_flags, list) else [] @@ -3171,7 +3507,7 @@ def checkout(self, *, branch: str) -> str: Examples -------- - >>> GitBranchManager(path=git_local_clone.path).checkout(branch='master') + >>> GitBranchManager(path=example_git_repo.path).checkout(branch='master') "Your branch is up to date with 'origin/master'." """ return self.cmd.run( @@ -3186,7 +3522,7 @@ def create(self, *, branch: str) -> str: Examples -------- - >>> GitBranchManager(path=git_local_clone.path).create(branch='master') + >>> GitBranchManager(path=example_git_repo.path).create(branch='master') "fatal: a branch named 'master' already exists" """ return self.cmd.run( @@ -3203,7 +3539,7 @@ def _ls(self) -> list[str]: Examples -------- - >>> GitBranchManager(path=git_local_clone.path)._ls() + >>> GitBranchManager(path=example_git_repo.path)._ls() ['* master'] """ return self.run( @@ -3215,7 +3551,7 @@ def ls(self) -> QueryList[GitBranchCmd]: Examples -------- - >>> GitBranchManager(path=git_local_clone.path).ls() + >>> GitBranchManager(path=example_git_repo.path).ls() [] """ return QueryList( @@ -3231,12 +3567,12 @@ def get(self, *args: t.Any, **kwargs: t.Any) -> GitBranchCmd | None: Examples -------- >>> GitBranchManager( - ... path=git_local_clone.path + ... path=example_git_repo.path ... ).get(branch_name='master') >>> GitBranchManager( - ... path=git_local_clone.path + ... path=example_git_repo.path ... ).get(branch_name='unknown') Traceback (most recent call last): exec(compile(example.source, filename, "single", @@ -3255,12 +3591,12 @@ def filter(self, *args: t.Any, **kwargs: t.Any) -> list[GitBranchCmd]: Examples -------- >>> GitBranchManager( - ... path=git_local_clone.path + ... path=example_git_repo.path ... ).filter(branch_name__contains='master') [] >>> GitBranchManager( - ... path=git_local_clone.path + ... path=example_git_repo.path ... ).filter(branch_name__contains='unknown') [] """ diff --git a/src/libvcs/sync/git.py b/src/libvcs/sync/git.py index a0f39f659..3ad33ffe6 100644 --- a/src/libvcs/sync/git.py +++ b/src/libvcs/sync/git.py @@ -556,13 +556,13 @@ def remotes(self) -> GitSyncRemoteDict: """ remotes = {} - cmd = self.cmd.remote.run() - ret: filter[str] = filter(None, cmd.split("\n")) + ret = self.cmd.remotes.ls() - for remote_name in ret: - remote = self.remote(remote_name) + for r in ret: + # FIXME: Cast to the GitRemote that sync uses, for now + remote = self.remote(r.remote_name) if remote is not None: - remotes[remote_name] = remote + remotes[r.remote_name] = remote return remotes def remote(self, name: str, **kwargs: t.Any) -> GitRemote | None: @@ -578,7 +578,7 @@ def remote(self, name: str, **kwargs: t.Any) -> GitRemote | None: Remote name and url in tuple form """ try: - ret = self.cmd.remote.show( + ret = self.cmd.remotes.show( name=name, no_query_remotes=True, log_in_real_time=True, @@ -614,11 +614,12 @@ def set_remote( defines the remote URL """ url = self.chomp_protocol(url) + remote_cmd = self.cmd.remotes.get(remote_name=name, default=None) - if self.remote(name) and overwrite: - self.cmd.remote.set_url(name=name, url=url, check_returncode=True) + if remote_cmd is not None and overwrite: + remote_cmd.set_url(url=url, check_returncode=True) else: - self.cmd.remote.add(name=name, url=url, check_returncode=True) + self.cmd.remotes.add(name=name, url=url, check_returncode=True) remote = self.remote(name=name) if remote is None: diff --git a/tests/sync/test_git.py b/tests/sync/test_git.py index b31a84e45..b2794ad9b 100644 --- a/tests/sync/test_git.py +++ b/tests/sync/test_git.py @@ -683,9 +683,11 @@ def test_git_sync_remotes(git_repo: GitSync) -> None: remotes = git_repo.remotes() assert "origin" in remotes - assert git_repo.cmd.remote.show() == "origin" - assert "origin" in git_repo.cmd.remote.show(name="origin") - assert "origin" in git_repo.cmd.remote.show(name="origin", no_query_remotes=True) + assert git_repo.cmd.remotes.show() == "origin" + git_origin = git_repo.cmd.remotes.get(remote_name="origin") + assert git_origin is not None + assert "origin" in git_origin.show() + assert "origin" in git_origin.show(no_query_remotes=True) assert git_repo.remotes()["origin"].name == "origin" From 602f709f3d529f8dcb0511203d6269f4d5b1d548 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Feb 2025 10:33:27 -0600 Subject: [PATCH 03/79] feat(git): enhance git init support with all options and tests - Add support for all git-init options (template, separate_git_dir, object_format, etc.) - Add comprehensive tests for each option - Fix path handling for separate_git_dir - Fix string formatting for bytes paths - Update docstrings with examples for all options --- src/libvcs/cmd/git.py | 102 ++++++++++++++++++++++++++------------ tests/cmd/test_git.py | 113 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 31 deletions(-) diff --git a/src/libvcs/cmd/git.py b/src/libvcs/cmd/git.py index c1c39c1ca..73617043f 100644 --- a/src/libvcs/cmd/git.py +++ b/src/libvcs/cmd/git.py @@ -1035,7 +1035,7 @@ def init( object_format: t.Literal["sha1", "sha256"] | None = None, branch: str | None = None, initial_branch: str | None = None, - shared: bool | None = None, + shared: bool | str | None = None, quiet: bool | None = None, bare: bool | None = None, # libvcs special behavior @@ -1046,60 +1046,100 @@ def init( Parameters ---------- - quiet : bool - ``--quiet`` - bare : bool - ``--bare`` - object_format : - Hash algorithm used for objects. SHA-256 is still experimental as of git - 2.36.0. + template : str, optional + Directory from which templates will be used. The template directory + contains files and directories that will be copied to the $GIT_DIR + after it is created. + separate_git_dir : :attr:`libvcs._internal.types.StrOrBytesPath`, optional + Instead of placing the git repository in /.git/, place it in + the specified path. + object_format : "sha1" | "sha256", optional + Specify the hash algorithm to use. The default is sha1. Note that + sha256 is still experimental in git. + branch : str, optional + Use the specified name for the initial branch. If not specified, fall + back to the default name (currently "master"). + initial_branch : str, optional + Alias for branch parameter. Specify the name for the initial branch. + shared : bool | str, optional + Specify that the git repository is to be shared amongst several users. + Can be 'false', 'true', 'umask', 'group', 'all', 'world', + 'everybody', or an octal number. + quiet : bool, optional + Only print error and warning messages; all other output will be + suppressed. + bare : bool, optional + Create a bare repository. If GIT_DIR environment is not set, it is set + to the current working directory. Examples -------- - >>> new_repo = tmp_path / 'example' - >>> new_repo.mkdir() - >>> git = Git(path=new_repo) + >>> git = Git(path=tmp_path) >>> git.init() 'Initialized empty Git repository in ...' - >>> pathlib.Path(new_repo / 'test').write_text('foo', 'utf-8') - 3 - >>> git.run(['add', '.']) - '' - Bare: + Create with a specific initial branch name: - >>> new_repo = tmp_path / 'example1' + >>> new_repo = tmp_path / 'branch_example' >>> new_repo.mkdir() >>> git = Git(path=new_repo) + >>> git.init(branch='main') + 'Initialized empty Git repository in ...' + + Create a bare repository: + + >>> bare_repo = tmp_path / 'bare_example' + >>> bare_repo.mkdir() + >>> git = Git(path=bare_repo) >>> git.init(bare=True) 'Initialized empty Git repository in ...' - >>> pathlib.Path(new_repo / 'HEAD').exists() - True - Existing repo: + Create with a separate git directory: - >>> git = Git(path=new_repo) - >>> git = Git(path=example_git_repo.path) - >>> git_remote_repo = create_git_remote_repo() - >>> git.init() - 'Reinitialized existing Git repository in ...' + >>> repo_path = tmp_path / 'repo' + >>> git_dir = tmp_path / 'git_dir' + >>> repo_path.mkdir() + >>> git_dir.mkdir() + >>> git = Git(path=repo_path) + >>> git.init(separate_git_dir=str(git_dir.absolute())) + 'Initialized empty Git repository in ...' + + Create with shared permissions: + >>> shared_repo = tmp_path / 'shared_example' + >>> shared_repo.mkdir() + >>> git = Git(path=shared_repo) + >>> git.init(shared='group') + 'Initialized empty shared Git repository in ...' + + Create with a template directory: + + >>> template_repo = tmp_path / 'template_example' + >>> template_repo.mkdir() + >>> git = Git(path=template_repo) + >>> git.init(template=str(tmp_path)) + 'Initialized empty Git repository in ...' """ - required_flags: list[str] = [str(self.path)] local_flags: list[str] = [] + required_flags: list[str] = [str(self.path)] if template is not None: local_flags.append(f"--template={template}") if separate_git_dir is not None: - local_flags.append(f"--separate-git-dir={separate_git_dir!r}") + if isinstance(separate_git_dir, pathlib.Path): + separate_git_dir = str(separate_git_dir.absolute()) + local_flags.append(f"--separate-git-dir={separate_git_dir!s}") if object_format is not None: local_flags.append(f"--object-format={object_format}") if branch is not None: - local_flags.extend(["--branch", branch]) - if initial_branch is not None: + local_flags.extend(["--initial-branch", branch]) + elif initial_branch is not None: local_flags.extend(["--initial-branch", initial_branch]) - if shared is True: - local_flags.append("--shared") + if shared is not None: + if isinstance(shared, bool): + local_flags.append("--shared") + else: + local_flags.append(f"--shared={shared}") if quiet is True: local_flags.append("--quiet") if bare is True: diff --git a/tests/cmd/test_git.py b/tests/cmd/test_git.py index 1aa155602..2445b461c 100644 --- a/tests/cmd/test_git.py +++ b/tests/cmd/test_git.py @@ -19,3 +19,116 @@ def test_git_constructor( repo = git.Git(path=path_type(tmp_path)) assert repo.path == tmp_path + + +def test_git_init_basic(tmp_path: pathlib.Path) -> None: + """Test basic git init functionality.""" + repo = git.Git(path=tmp_path) + result = repo.init() + assert "Initialized empty Git repository" in result + assert (tmp_path / ".git").is_dir() + + +def test_git_init_bare(tmp_path: pathlib.Path) -> None: + """Test git init with bare repository.""" + repo = git.Git(path=tmp_path) + result = repo.init(bare=True) + assert "Initialized empty Git repository" in result + # Bare repos have files directly in the directory + assert (tmp_path / "HEAD").exists() + + +def test_git_init_template(tmp_path: pathlib.Path) -> None: + """Test git init with template directory.""" + template_dir = tmp_path / "template" + template_dir.mkdir() + (template_dir / "hooks").mkdir() + (template_dir / "hooks" / "pre-commit").write_text("#!/bin/sh\nexit 0\n") + + repo_dir = tmp_path / "repo" + repo_dir.mkdir() + repo = git.Git(path=repo_dir) + result = repo.init(template=str(template_dir)) + + assert "Initialized empty Git repository" in result + assert (repo_dir / ".git" / "hooks" / "pre-commit").exists() + + +def test_git_init_separate_git_dir(tmp_path: pathlib.Path) -> None: + """Test git init with separate git directory.""" + repo_dir = tmp_path / "repo" + git_dir = tmp_path / "git_dir" + repo_dir.mkdir() + git_dir.mkdir() + + repo = git.Git(path=repo_dir) + result = repo.init(separate_git_dir=str(git_dir.absolute())) + + assert "Initialized empty Git repository" in result + assert git_dir.is_dir() + assert (git_dir / "HEAD").exists() + + +def test_git_init_initial_branch(tmp_path: pathlib.Path) -> None: + """Test git init with custom initial branch name.""" + repo = git.Git(path=tmp_path) + result = repo.init(branch="main") + + assert "Initialized empty Git repository" in result + # Check if HEAD points to the correct branch + head_content = (tmp_path / ".git" / "HEAD").read_text() + assert "ref: refs/heads/main" in head_content + + +def test_git_init_shared(tmp_path: pathlib.Path) -> None: + """Test git init with shared repository settings.""" + repo = git.Git(path=tmp_path) + + # Test boolean shared + result = repo.init(shared=True) + assert "Initialized empty shared Git repository" in result + + # Test string shared value + repo_dir = tmp_path / "shared_group" + repo_dir.mkdir() + repo = git.Git(path=repo_dir) + result = repo.init(shared="group") + assert "Initialized empty shared Git repository" in result + + +def test_git_init_quiet(tmp_path: pathlib.Path) -> None: + """Test git init with quiet flag.""" + repo = git.Git(path=tmp_path) + result = repo.init(quiet=True) + # Quiet mode should suppress normal output + assert result == "" or "Initialized empty Git repository" not in result + + +def test_git_init_object_format(tmp_path: pathlib.Path) -> None: + """Test git init with different object formats.""" + repo = git.Git(path=tmp_path) + + # Test with sha1 (default) + result = repo.init(object_format="sha1") + assert "Initialized empty Git repository" in result + + # Note: sha256 test is commented out as it might not be supported in all + # git versions + # repo_dir = tmp_path / "sha256" + # repo_dir.mkdir() + # repo = git.Git(path=repo_dir) + # result = repo.init(object_format="sha256") + # assert "Initialized empty Git repository" in result + + +def test_git_reinit(tmp_path: pathlib.Path) -> None: + """Test reinitializing an existing repository.""" + repo = git.Git(path=tmp_path) + + # Initial init + first_result = repo.init() + assert "Initialized empty Git repository" in first_result + + # Reinit + second_result = repo.init() + assert "Reinitialized existing Git repository" in second_result From 88b4c80ad945928600047eaf0d548f8f594c8417 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Feb 2025 10:43:11 -0600 Subject: [PATCH 04/79] docs: improve Git.init documentation and validation tests - Enhance Git.init docstrings with detailed parameter descriptions - Add comprehensive examples including SHA-256 object format - Add return value and exception documentation - Improve type hints for shared parameter with Literal types - Add extensive validation tests for all parameters --- src/libvcs/cmd/git.py | 58 ++++++++++++++++++++++++++++++++++++------- tests/cmd/test_git.py | 39 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 9 deletions(-) diff --git a/src/libvcs/cmd/git.py b/src/libvcs/cmd/git.py index 73617043f..21b266ae4 100644 --- a/src/libvcs/cmd/git.py +++ b/src/libvcs/cmd/git.py @@ -1035,7 +1035,10 @@ def init( object_format: t.Literal["sha1", "sha256"] | None = None, branch: str | None = None, initial_branch: str | None = None, - shared: bool | str | None = None, + shared: bool + | Literal[false, true, umask, group, all, world, everybody] + | str + | None = None, quiet: bool | None = None, bare: bool | None = None, # libvcs special behavior @@ -1049,28 +1052,58 @@ def init( template : str, optional Directory from which templates will be used. The template directory contains files and directories that will be copied to the $GIT_DIR - after it is created. + after it is created. The template directory will be one of the + following (in order): + - The argument given with the --template option + - The contents of the $GIT_TEMPLATE_DIR environment variable + - The init.templateDir configuration variable + - The default template directory: /usr/share/git-core/templates separate_git_dir : :attr:`libvcs._internal.types.StrOrBytesPath`, optional Instead of placing the git repository in /.git/, place it in - the specified path. + the specified path. The .git file at /.git will contain a + gitfile that points to the separate git dir. This is useful when you + want to store the git directory on a different disk or filesystem. object_format : "sha1" | "sha256", optional Specify the hash algorithm to use. The default is sha1. Note that - sha256 is still experimental in git. + sha256 is still experimental in git and requires git version >= 2.29.0. + Once the repository is created with a specific hash algorithm, it cannot + be changed. branch : str, optional Use the specified name for the initial branch. If not specified, fall - back to the default name (currently "master"). + back to the default name (currently "master", but may change based on + init.defaultBranch configuration). initial_branch : str, optional Alias for branch parameter. Specify the name for the initial branch. + This is provided for compatibility with newer git versions. shared : bool | str, optional Specify that the git repository is to be shared amongst several users. - Can be 'false', 'true', 'umask', 'group', 'all', 'world', - 'everybody', or an octal number. + Valid values are: + - false: Turn off sharing (default) + - true: Same as group + - umask: Use permissions specified by umask + - group: Make the repository group-writable + - all, world, everybody: Same as world, make repo readable by all users + - An octal number: Explicit mode specification (e.g., "0660") quiet : bool, optional Only print error and warning messages; all other output will be - suppressed. + suppressed. Useful for scripting. bare : bool, optional Create a bare repository. If GIT_DIR environment is not set, it is set - to the current working directory. + to the current working directory. Bare repositories have no working + tree and are typically used as central repositories. + check_returncode : bool, optional + If True, check the return code of the git command and raise a + CalledProcessError if it is non-zero. + + Returns + ------- + str + The output of the git init command. + + Raises + ------ + CalledProcessError + If the git command fails and check_returncode is True. Examples -------- @@ -1119,6 +1152,13 @@ def init( >>> git = Git(path=template_repo) >>> git.init(template=str(tmp_path)) 'Initialized empty Git repository in ...' + + Create with SHA-256 object format (requires git >= 2.29.0): + + >>> sha256_repo = tmp_path / 'sha256_example' + >>> sha256_repo.mkdir() + >>> git = Git(path=sha256_repo) + >>> git.init(object_format='sha256') # doctest: +SKIP """ local_flags: list[str] = [] required_flags: list[str] = [str(self.path)] diff --git a/tests/cmd/test_git.py b/tests/cmd/test_git.py index 2445b461c..47d44cae1 100644 --- a/tests/cmd/test_git.py +++ b/tests/cmd/test_git.py @@ -132,3 +132,42 @@ def test_git_reinit(tmp_path: pathlib.Path) -> None: # Reinit second_result = repo.init() assert "Reinitialized existing Git repository" in second_result + + +def test_git_init_validation_errors(tmp_path: pathlib.Path) -> None: + """Test validation errors in git init.""" + repo = git.Git(path=tmp_path) + + # Test invalid template type + with pytest.raises(TypeError, match="template must be a string or Path"): + repo.init(template=123) # type: ignore + + # Test non-existent template directory + with pytest.raises(ValueError, match="template directory does not exist"): + repo.init(template=str(tmp_path / "nonexistent")) + + # Test invalid object format + with pytest.raises( + ValueError, + match="object_format must be either 'sha1' or 'sha256'", + ): + repo.init(object_format="invalid") # type: ignore + + # Test specifying both branch and initial_branch + with pytest.raises( + ValueError, + match="Cannot specify both branch and initial_branch", + ): + repo.init(branch="main", initial_branch="master") + + # Test branch name with whitespace + with pytest.raises(ValueError, match="Branch name cannot contain whitespace"): + repo.init(branch="main branch") + + # Test invalid shared value + with pytest.raises(ValueError, match="Invalid shared value"): + repo.init(shared="invalid") + + # Test invalid octal number for shared + with pytest.raises(ValueError, match="Invalid shared value"): + repo.init(shared="8888") # Invalid octal number From 774807b45d2a28ca85cc31530a3bebdc080eb309 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Feb 2025 10:44:47 -0600 Subject: [PATCH 05/79] feat: add parameter validation for git init - Add validation for template parameter type and existence - Add validation for object_format parameter values - Improve type formatting for shared parameter - Complete docstring example output --- src/libvcs/cmd/git.py | 54 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/src/libvcs/cmd/git.py b/src/libvcs/cmd/git.py index 21b266ae4..dda84a798 100644 --- a/src/libvcs/cmd/git.py +++ b/src/libvcs/cmd/git.py @@ -6,6 +6,7 @@ import pathlib import re import shlex +import string import typing as t from collections.abc import Sequence @@ -1159,26 +1160,71 @@ def init( >>> sha256_repo.mkdir() >>> git = Git(path=sha256_repo) >>> git.init(object_format='sha256') # doctest: +SKIP + 'Initialized empty Git repository in ...' """ local_flags: list[str] = [] required_flags: list[str] = [str(self.path)] if template is not None: + if not isinstance(template, (str, pathlib.Path)): + msg = "template must be a string or Path" + raise TypeError(msg) + template_path = pathlib.Path(template) + if not template_path.is_dir(): + msg = f"template directory does not exist: {template}" + raise ValueError(msg) local_flags.append(f"--template={template}") + if separate_git_dir is not None: if isinstance(separate_git_dir, pathlib.Path): separate_git_dir = str(separate_git_dir.absolute()) local_flags.append(f"--separate-git-dir={separate_git_dir!s}") + if object_format is not None: + if object_format not in {"sha1", "sha256"}: + msg = "object_format must be either 'sha1' or 'sha256'" + raise ValueError(msg) local_flags.append(f"--object-format={object_format}") - if branch is not None: - local_flags.extend(["--initial-branch", branch]) - elif initial_branch is not None: - local_flags.extend(["--initial-branch", initial_branch]) + + if branch is not None and initial_branch is not None: + msg = "Cannot specify both branch and initial_branch" + raise ValueError(msg) + + branch_name = branch or initial_branch + if branch_name is not None: + if any(c.isspace() for c in branch_name): + msg = "Branch name cannot contain whitespace" + raise ValueError(msg) + local_flags.extend(["--initial-branch", branch_name]) + if shared is not None: + valid_shared_values = { + "false", + "true", + "umask", + "group", + "all", + "world", + "everybody", + } if isinstance(shared, bool): local_flags.append("--shared") else: + shared_str = str(shared).lower() + # Check if it's a valid string value or an octal number + if not ( + shared_str in valid_shared_values + or ( + shared_str.isdigit() + and len(shared_str) <= 4 + and all(c in string.octdigits for c in shared_str) + ) + ): + msg = ( + f"Invalid shared value. Must be one of {valid_shared_values} " + "or an octal number" + ) + raise ValueError(msg) local_flags.append(f"--shared={shared}") if quiet is True: local_flags.append("--quiet") From f429f582149df4324066bcf4906953e5e055f96b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Feb 2025 11:03:51 -0600 Subject: [PATCH 06/79] feat: enhance Git.init with ref-format and improved validation - Add ref-format parameter support for git init - Add make_parents parameter to control directory creation - Improve type hints and validation for template and shared parameters - Add comprehensive tests for all shared values and octal permissions - Add validation for octal number range in shared parameter --- src/libvcs/cmd/git.py | 54 +++++++++++++++++++++++----- tests/cmd/test_git.py | 82 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/src/libvcs/cmd/git.py b/src/libvcs/cmd/git.py index dda84a798..006b307a8 100644 --- a/src/libvcs/cmd/git.py +++ b/src/libvcs/cmd/git.py @@ -1031,26 +1031,29 @@ def pull( def init( self, *, - template: str | None = None, + template: str | pathlib.Path | None = None, separate_git_dir: StrOrBytesPath | None = None, object_format: t.Literal["sha1", "sha256"] | None = None, branch: str | None = None, initial_branch: str | None = None, shared: bool - | Literal[false, true, umask, group, all, world, everybody] - | str + | t.Literal["false", "true", "umask", "group", "all", "world", "everybody"] + | str # Octal number string (e.g., "0660") | None = None, quiet: bool | None = None, bare: bool | None = None, + ref_format: t.Literal["files", "reftable"] | None = None, + default: bool | None = None, # libvcs special behavior check_returncode: bool | None = None, + make_parents: bool = True, **kwargs: t.Any, ) -> str: """Create empty repo. Wraps `git init `_. Parameters ---------- - template : str, optional + template : str | pathlib.Path, optional Directory from which templates will be used. The template directory contains files and directories that will be copied to the $GIT_DIR after it is created. The template directory will be one of the @@ -1084,7 +1087,7 @@ def init( - umask: Use permissions specified by umask - group: Make the repository group-writable - all, world, everybody: Same as world, make repo readable by all users - - An octal number: Explicit mode specification (e.g., "0660") + - An octal number string: Explicit mode specification (e.g., "0660") quiet : bool, optional Only print error and warning messages; all other output will be suppressed. Useful for scripting. @@ -1092,9 +1095,19 @@ def init( Create a bare repository. If GIT_DIR environment is not set, it is set to the current working directory. Bare repositories have no working tree and are typically used as central repositories. + ref_format : "files" | "reftable", optional + Specify the reference storage format. Requires git version >= 2.37.0. + - files: Classic format with packed-refs and loose refs (default) + - reftable: New format that is more efficient for large repositories + default : bool, optional + Use default permissions for directories and files. This is the same as + running git init without any options. check_returncode : bool, optional If True, check the return code of the git command and raise a CalledProcessError if it is non-zero. + make_parents : bool, default: True + If True, create the target directory if it doesn't exist. If False, + raise an error if the directory doesn't exist. Returns ------- @@ -1105,6 +1118,10 @@ def init( ------ CalledProcessError If the git command fails and check_returncode is True. + ValueError + If invalid parameters are provided. + FileNotFoundError + If make_parents is False and the target directory doesn't exist. Examples -------- @@ -1146,6 +1163,14 @@ def init( >>> git.init(shared='group') 'Initialized empty shared Git repository in ...' + Create with octal permissions: + + >>> shared_repo = tmp_path / 'shared_octal_example' + >>> shared_repo.mkdir() + >>> git = Git(path=shared_repo) + >>> git.init(shared='0660') + 'Initialized empty shared Git repository in ...' + Create with a template directory: >>> template_repo = tmp_path / 'template_example' @@ -1218,18 +1243,31 @@ def init( shared_str.isdigit() and len(shared_str) <= 4 and all(c in string.octdigits for c in shared_str) + and int(shared_str, 8) <= 0o777 # Validate octal range ) ): msg = ( f"Invalid shared value. Must be one of {valid_shared_values} " - "or an octal number" + "or a valid octal number between 0000 and 0777" ) raise ValueError(msg) local_flags.append(f"--shared={shared}") + if quiet is True: local_flags.append("--quiet") if bare is True: local_flags.append("--bare") + if ref_format is not None: + local_flags.append(f"--ref-format={ref_format}") + if default is True: + local_flags.append("--default") + + # libvcs special behavior + if make_parents and not self.path.exists(): + self.path.mkdir(parents=True) + elif not self.path.exists(): + msg = f"Directory does not exist: {self.path}" + raise FileNotFoundError(msg) return self.run( ["init", *local_flags, "--", *required_flags], @@ -2863,7 +2901,7 @@ def set_url( ) -GitRemoteManagerLiteral = Literal[ +GitRemoteManagerLiteral = t.Literal[ "--verbose", "add", "rename", @@ -2933,7 +2971,7 @@ def run( # Pass-through to run() log_in_real_time: bool = False, check_returncode: bool | None = None, - **kwargs: Any, + **kwargs: t.Any, ) -> str: """Run a command against a git repository's remotes. diff --git a/tests/cmd/test_git.py b/tests/cmd/test_git.py index 47d44cae1..243f723c9 100644 --- a/tests/cmd/test_git.py +++ b/tests/cmd/test_git.py @@ -171,3 +171,85 @@ def test_git_init_validation_errors(tmp_path: pathlib.Path) -> None: # Test invalid octal number for shared with pytest.raises(ValueError, match="Invalid shared value"): repo.init(shared="8888") # Invalid octal number + + # Test octal number out of range + with pytest.raises(ValueError, match="Invalid shared value"): + repo.init(shared="1000") # Octal number > 0777 + + # Test non-existent directory with make_parents=False + non_existent = tmp_path / "non_existent" + with pytest.raises(FileNotFoundError, match="Directory does not exist"): + repo = git.Git(path=non_existent) + repo.init(make_parents=False) + + +def test_git_init_shared_octal(tmp_path: pathlib.Path) -> None: + """Test git init with shared octal permissions.""" + repo = git.Git(path=tmp_path) + + # Test valid octal numbers + for octal in ["0660", "0644", "0755"]: + repo_dir = tmp_path / f"shared_{octal}" + repo_dir.mkdir() + repo = git.Git(path=repo_dir) + result = repo.init(shared=octal) + assert "Initialized empty shared Git repository" in result + + +def test_git_init_shared_values(tmp_path: pathlib.Path) -> None: + """Test git init with all valid shared values.""" + valid_values = ["false", "true", "umask", "group", "all", "world", "everybody"] + + for value in valid_values: + repo_dir = tmp_path / f"shared_{value}" + repo_dir.mkdir() + repo = git.Git(path=repo_dir) + result = repo.init(shared=value) + # The output message varies between git versions and shared values + assert any( + msg in result + for msg in [ + "Initialized empty Git repository", + "Initialized empty shared Git repository", + ] + ) + + +def test_git_init_ref_format(tmp_path: pathlib.Path) -> None: + """Test git init with different ref formats.""" + repo = git.Git(path=tmp_path) + + # Test with files format (default) + result = repo.init() + assert "Initialized empty Git repository" in result + + # Test with reftable format (requires git >= 2.37.0) + repo_dir = tmp_path / "reftable" + repo_dir.mkdir() + repo = git.Git(path=repo_dir) + try: + result = repo.init(ref_format="reftable") + assert "Initialized empty Git repository" in result + except Exception as e: + if "unknown option" in str(e): + pytest.skip("ref-format option not supported in this git version") + raise + + +def test_git_init_make_parents(tmp_path: pathlib.Path) -> None: + """Test git init with make_parents flag.""" + deep_path = tmp_path / "a" / "b" / "c" + + # Test with make_parents=True (default) + repo = git.Git(path=deep_path) + result = repo.init() + assert "Initialized empty Git repository" in result + assert deep_path.exists() + assert (deep_path / ".git").is_dir() + + # Test with make_parents=False on existing directory + existing_path = tmp_path / "existing" + existing_path.mkdir() + repo = git.Git(path=existing_path) + result = repo.init(make_parents=False) + assert "Initialized empty Git repository" in result From 57b31761cf71056e68e274bc6fc5aa2ac0f01656 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 26 Nov 2025 14:53:51 -0600 Subject: [PATCH 07/79] docs(notes): add git command support audit 2025-11-26 why: Document current git command implementations and plan Manager pattern expansion for branch, remote, tag, stash, worktree, notes, submodule, reflog. what: - Audit existing GitBranchManager/GitBranchCmd methods - Audit existing GitRemoteManager/GitRemoteCmd methods - Document GitStashCmd and planned refactor to Manager pattern - Document GitSubmoduleCmd and planned refactor to Manager pattern - Plan new GitTagManager/GitTagCmd - Plan new GitWorktreeManager/GitWorktreeCmd - Plan new GitNotesManager/GitNoteCmd - Plan new GitReflogManager/GitReflogCmd --- notes/2025-11-26-command-support.md | 373 ++++++++++++++++++++++++++++ 1 file changed, 373 insertions(+) create mode 100644 notes/2025-11-26-command-support.md diff --git a/notes/2025-11-26-command-support.md b/notes/2025-11-26-command-support.md new file mode 100644 index 000000000..b56a10704 --- /dev/null +++ b/notes/2025-11-26-command-support.md @@ -0,0 +1,373 @@ +# Git Command Support Audit - 2025-11-26 + +This document provides a comprehensive audit of git command support in libvcs, documenting existing implementations and planned additions following the Manager/Cmd pattern. + +## Pattern Overview + +``` +Manager (collection-level) Cmd (per-entity) +├── ls() -> QueryList[Cmd] ├── run() +├── get(**kwargs) -> Cmd | None ├── show() +├── filter(**kwargs) -> list[Cmd] ├── remove()/delete() +├── add() / create() ├── rename() +└── run() └── entity-specific operations +``` + +--- + +## 1. GitBranchManager / GitBranchCmd + +**Pattern Status**: Implemented + +### GitBranchManager (Collection-level) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | Implemented | Constructor | +| `__repr__()` | Implemented | String representation | +| `run(command, local_flags, quiet, cached)` | Implemented | Run git branch command | +| `checkout(branch)` | Implemented | Checkout a branch | +| `create(branch)` | Implemented | Create new branch via `checkout -b` | +| `_ls()` | Implemented | Internal raw listing | +| `ls()` | Implemented | Returns `QueryList[GitBranchCmd]` | +| `get(**kwargs)` | Implemented | Get single branch by filter | +| `filter(**kwargs)` | Implemented | Filter branches | + +### GitBranchCmd (Per-entity) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, branch_name, cmd)` | Implemented | Constructor with `branch_name` | +| `__repr__()` | Implemented | String representation | +| `run(command, local_flags, quiet, cached)` | Implemented | Run git branch command | +| `checkout()` | Implemented | Checkout this branch | +| `create()` | Implemented | Create this branch | +| `delete(force)` | **Missing** | `-d` / `-D` | +| `rename(new_name, force)` | **Missing** | `-m` / `-M` | +| `copy(new_name, force)` | **Missing** | `-c` / `-C` | +| `set_upstream(upstream)` | **Missing** | `--set-upstream-to` | +| `unset_upstream()` | **Missing** | `--unset-upstream` | +| `track(remote_branch)` | **Missing** | `-t` / `--track` | + +### GitBranchManager Enhancements Needed + +| Feature | Status | Description | +|---------|--------|-------------| +| `--all` support | **Missing** | List all branches (local + remote) | +| `--remotes` support | **Missing** | List remote branches only | +| `--merged` filter | **Missing** | Filter merged branches | +| `--no-merged` filter | **Missing** | Filter unmerged branches | +| `--verbose` support | **Missing** | Show tracking info | + +--- + +## 2. GitRemoteManager / GitRemoteCmd + +**Pattern Status**: Implemented + +### GitRemoteManager (Collection-level) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | Implemented | Constructor | +| `__repr__()` | Implemented | String representation | +| `run(command, local_flags)` | Implemented | Run git remote command | +| `add(name, url, fetch, track, master, mirror)` | Implemented | Add new remote | +| `show(name, verbose, no_query_remotes)` | Implemented | Show remotes | +| `_ls()` | Implemented | Internal raw listing | +| `ls()` | Implemented | Returns `QueryList[GitRemoteCmd]` | +| `get(**kwargs)` | Implemented | Get single remote by filter | +| `filter(**kwargs)` | Implemented | Filter remotes | + +### GitRemoteCmd (Per-entity) + +Properties: `remote_name`, `fetch_url`, `push_url` + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, remote_name, fetch_url, push_url, cmd)` | Implemented | Constructor | +| `__repr__()` | Implemented | String representation | +| `run(command, local_flags, verbose)` | Implemented | Run git remote command | +| `rename(old, new, progress)` | Implemented | Rename remote | +| `remove()` | Implemented | Delete remote | +| `show(verbose, no_query_remotes)` | Implemented | Show remote details | +| `prune(dry_run)` | Implemented | Prune stale branches | +| `get_url(push, _all)` | Implemented | Get remote URL | +| `set_url(url, old_url, push, add, delete)` | Implemented | Set remote URL | +| `set_branches(*branches, add)` | **Missing** | `set-branches` | +| `set_head(branch, auto, delete)` | **Missing** | `set-head` | +| `update(prune)` | **Missing** | `update` | + +--- + +## 3. GitStashCmd (Current) → GitStashManager / GitStashEntryCmd (Planned) + +**Pattern Status**: Not implemented - needs refactoring + +### Current GitStashCmd + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | Implemented | Constructor | +| `__repr__()` | Implemented | String representation | +| `run(command, local_flags, quiet, cached)` | Implemented | Run git stash command | +| `ls()` | Implemented | List stashes (returns string) | +| `push(path, patch, staged)` | Implemented | Push to stash | +| `pop(stash, index, quiet)` | Implemented | Pop from stash | +| `save(message, staged, keep_index, patch, include_untracked, _all, quiet)` | Implemented | Save to stash | + +### Planned GitStashManager (Collection-level) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | **Planned** | Constructor | +| `run(command, local_flags)` | **Planned** | Run git stash command | +| `ls()` | **Planned** | Returns `QueryList[GitStashEntryCmd]` | +| `get(**kwargs)` | **Planned** | Get single stash by filter | +| `filter(**kwargs)` | **Planned** | Filter stashes | +| `push(message, path, patch, staged, keep_index, include_untracked)` | **Planned** | Push to stash | +| `clear()` | **Planned** | Clear all stashes | + +### Planned GitStashEntryCmd (Per-entity) + +Properties: `index: int`, `branch: str`, `message: str` + +Parse from: `stash@{0}: On master: my message` + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, index, branch, message, cmd)` | **Planned** | Constructor | +| `show(stat, patch)` | **Planned** | Show stash diff | +| `apply(index)` | **Planned** | Apply without removing | +| `pop(index)` | **Planned** | Apply and remove | +| `drop()` | **Planned** | Delete this stash | +| `branch(branch_name)` | **Planned** | Create branch from stash | + +--- + +## 4. GitSubmoduleCmd (Current) → GitSubmoduleManager / GitSubmoduleCmd (Planned) + +**Pattern Status**: Not implemented - needs refactoring + +### Current GitSubmoduleCmd + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | Implemented | Constructor | +| `__repr__()` | Implemented | String representation | +| `run(command, local_flags, quiet, cached)` | Implemented | Run git submodule command | +| `init(path)` | Implemented | Initialize submodules | +| `update(path, init, force, checkout, rebase, merge, recursive, _filter)` | Implemented | Update submodules | + +### Planned GitSubmoduleManager (Collection-level) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | **Planned** | Constructor | +| `run(command, local_flags)` | **Planned** | Run git submodule command | +| `ls()` | **Planned** | Returns `QueryList[GitSubmoduleCmd]` | +| `get(**kwargs)` | **Planned** | Get single submodule by filter | +| `filter(**kwargs)` | **Planned** | Filter submodules | +| `add(url, path, branch, name, force)` | **Planned** | Add submodule | +| `foreach(command, recursive)` | **Planned** | Execute in each submodule | +| `sync(recursive)` | **Planned** | Sync submodule URLs | +| `summary(commit, files, cached)` | **Planned** | Summarize changes | + +### Planned GitSubmoduleCmd (Per-entity) + +Properties: `name`, `path`, `url`, `branch` + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, name, submodule_path, url, branch, cmd)` | **Planned** | Constructor | +| `init()` | **Planned** | Initialize this submodule | +| `update(init, force, checkout, rebase, merge, recursive)` | **Planned** | Update this submodule | +| `deinit(force)` | **Planned** | Unregister submodule | +| `set_branch(branch)` | **Planned** | Set branch | +| `set_url(url)` | **Planned** | Set URL | +| `status()` | **Planned** | Show status | +| `absorbgitdirs()` | **Planned** | Absorb gitdir | + +--- + +## 5. GitTagManager / GitTagCmd (New) + +**Pattern Status**: Not implemented + +### Planned GitTagManager (Collection-level) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | **Planned** | Constructor | +| `run(command, local_flags)` | **Planned** | Run git tag command | +| `ls(pattern, sort, contains, no_contains, merged, no_merged)` | **Planned** | Returns `QueryList[GitTagCmd]` | +| `get(**kwargs)` | **Planned** | Get single tag by filter | +| `filter(**kwargs)` | **Planned** | Filter tags | +| `create(name, ref, message, annotate, sign, force)` | **Planned** | Create tag | + +### Planned GitTagCmd (Per-entity) + +Properties: `tag_name`, `ref`, `message` (for annotated) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, tag_name, ref, message, cmd)` | **Planned** | Constructor | +| `show()` | **Planned** | Show tag details | +| `delete()` | **Planned** | Delete tag (`-d`) | +| `verify()` | **Planned** | Verify signed tag (`-v`) | + +--- + +## 6. GitWorktreeManager / GitWorktreeCmd (New) + +**Pattern Status**: Not implemented + +### Planned GitWorktreeManager (Collection-level) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | **Planned** | Constructor | +| `run(command, local_flags)` | **Planned** | Run git worktree command | +| `ls()` | **Planned** | Returns `QueryList[GitWorktreeCmd]` | +| `get(**kwargs)` | **Planned** | Get single worktree by filter | +| `filter(**kwargs)` | **Planned** | Filter worktrees | +| `add(path, branch, detach, checkout, lock, force)` | **Planned** | Add worktree | +| `prune(dry_run, verbose, expire)` | **Planned** | Prune worktrees | + +### Planned GitWorktreeCmd (Per-entity) + +Properties: `worktree_path`, `branch`, `head`, `locked`, `prunable` + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, worktree_path, branch, head, locked, prunable, cmd)` | **Planned** | Constructor | +| `remove(force)` | **Planned** | Remove worktree | +| `lock(reason)` | **Planned** | Lock worktree | +| `unlock()` | **Planned** | Unlock worktree | +| `move(new_path)` | **Planned** | Move worktree | +| `repair()` | **Planned** | Repair worktree | + +--- + +## 7. GitNotesManager / GitNoteCmd (New) + +**Pattern Status**: Not implemented + +### Planned GitNotesManager (Collection-level) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | **Planned** | Constructor | +| `run(command, local_flags)` | **Planned** | Run git notes command | +| `ls(ref)` | **Planned** | Returns `QueryList[GitNoteCmd]` | +| `get(**kwargs)` | **Planned** | Get single note by filter | +| `filter(**kwargs)` | **Planned** | Filter notes | +| `add(object, message, file, force, allow_empty)` | **Planned** | Add note | +| `prune(dry_run, verbose)` | **Planned** | Prune notes | +| `merge(notes_ref, strategy, commit, abort, quiet)` | **Planned** | Merge notes | +| `get_ref()` | **Planned** | Get notes ref | + +### Planned GitNoteCmd (Per-entity) + +Properties: `object`, `note_ref` + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, object, note_ref, cmd)` | **Planned** | Constructor | +| `show()` | **Planned** | Show note | +| `edit()` | **Planned** | Edit note (non-interactive) | +| `append(message)` | **Planned** | Append to note | +| `copy(from_object)` | **Planned** | Copy note | +| `remove()` | **Planned** | Remove note | + +--- + +## 8. GitReflogManager / GitReflogCmd (New) + +**Pattern Status**: Not implemented + +### Planned GitReflogManager (Collection-level) + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, cmd)` | **Planned** | Constructor | +| `run(command, local_flags)` | **Planned** | Run git reflog command | +| `ls(ref)` | **Planned** | Returns `QueryList[GitReflogCmd]` | +| `get(**kwargs)` | **Planned** | Get single entry by filter | +| `filter(**kwargs)` | **Planned** | Filter entries | +| `expire(ref, _all, dry_run, rewrite, updateref, stale_fix, verbose)` | **Planned** | Expire entries | +| `exists(ref)` | **Planned** | Check if reflog exists | + +### Planned GitReflogCmd (Per-entity) + +Properties: `ref`, `index`, `action`, `message`, `sha` + +Parse from: `abc1234 HEAD@{0}: commit: message` + +| Method | Status | Description | +|--------|--------|-------------| +| `__init__(path, ref, index, action, message, sha, cmd)` | **Planned** | Constructor | +| `show()` | **Planned** | Show entry details | +| `delete()` | **Planned** | Delete entry | + +--- + +## Git Class Exposure + +### Current + +```python +class Git: + submodule: GitSubmoduleCmd + remotes: GitRemoteManager # ✓ Manager pattern + stash: GitStashCmd + branches: GitBranchManager # ✓ Manager pattern +``` + +### Planned + +```python +class Git: + submodules: GitSubmoduleManager # Renamed + Manager pattern + remotes: GitRemoteManager # ✓ Already done + stash: GitStashManager # Refactored to Manager pattern + branches: GitBranchManager # ✓ Already done + tags: GitTagManager # New + worktrees: GitWorktreeManager # New + notes: GitNotesManager # New + reflog: GitReflogManager # New +``` + +--- + +## Implementation Order + +1. Complete GitBranchCmd (add missing methods) +2. Complete GitRemoteCmd (add missing methods) +3. Implement GitTagManager/GitTagCmd +4. Refactor GitStashCmd → GitStashManager/GitStashEntryCmd +5. Implement GitWorktreeManager/GitWorktreeCmd +6. Implement GitNotesManager/GitNoteCmd +7. Refactor GitSubmoduleCmd → GitSubmoduleManager/GitSubmoduleCmd +8. Implement GitReflogManager/GitReflogCmd +9. Update Git class to expose all managers + +--- + +## Test Coverage + +Currently **no direct unit tests** exist for: +- GitBranchManager / GitBranchCmd +- GitRemoteManager / GitRemoteCmd +- GitStashCmd +- GitSubmoduleCmd + +Tests use pytest fixtures from `libvcs.pytest_plugin`: +- `create_git_remote_repo` - Creates temporary git repo +- `git_repo` - Pre-made GitSync instance +- `example_git_repo` - Example repo for doctests + +All new tests will: +- Use real git commands (no mocks) +- Use pytest fixtures for setup/teardown +- Follow TDD approach From 8aa822abc922b6d5fe25e5ffa81e1b5ddb219840 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 26 Nov 2025 15:12:57 -0600 Subject: [PATCH 08/79] =?UTF-8?q?docs(notes):=20add=20CLI=20flag=20?= =?UTF-8?q?=E2=86=92=20Python=20parameter=20mappings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Document how git CLI flags map to Python parameters what: - Add flag mapping tables for all Manager and Cmd classes - Include existing methods and planned methods - Add parsing patterns for stash and reflog entries --- notes/2025-11-26-command-support.md | 241 +++++++++++++++++++++++++++- 1 file changed, 240 insertions(+), 1 deletion(-) diff --git a/notes/2025-11-26-command-support.md b/notes/2025-11-26-command-support.md index b56a10704..b1ea78cc9 100644 --- a/notes/2025-11-26-command-support.md +++ b/notes/2025-11-26-command-support.md @@ -33,6 +33,18 @@ Manager (collection-level) Cmd (per-entity) | `get(**kwargs)` | Implemented | Get single branch by filter | | `filter(**kwargs)` | Implemented | Filter branches | +#### CLI Flag → Python Parameter Mapping: `ls()` Enhancements + +| Git CLI Flag | Python Parameter | Description | +|--------------|------------------|-------------| +| `-a, --all` | `_all: bool` | List all branches (local + remote) | +| `-r, --remotes` | `remotes: bool` | List remote branches only | +| `--merged ` | `merged: str \| None` | Filter merged branches | +| `--no-merged ` | `no_merged: str \| None` | Filter unmerged branches | +| `-v, --verbose` | `verbose: bool` | Show tracking info | +| `--contains ` | `contains: str \| None` | Branches containing commit | +| `--sort=` | `sort: str \| None` | Sort key | + ### GitBranchCmd (Per-entity) | Method | Status | Description | @@ -49,6 +61,17 @@ Manager (collection-level) Cmd (per-entity) | `unset_upstream()` | **Missing** | `--unset-upstream` | | `track(remote_branch)` | **Missing** | `-t` / `--track` | +#### CLI Flag → Python Parameter Mapping: GitBranchCmd Methods + +| Method | Git CLI | Parameters → Flags | +|--------|---------|-------------------| +| `delete()` | `git branch -d/-D` | `force=True` → `-D`, else `-d` | +| `rename(new_name)` | `git branch -m/-M` | `force=True` → `-M`, else `-m` | +| `copy(new_name)` | `git branch -c/-C` | `force=True` → `-C`, else `-c` | +| `set_upstream(upstream)` | `git branch --set-upstream-to=` | `upstream` → `--set-upstream-to={upstream}` | +| `unset_upstream()` | `git branch --unset-upstream` | None | +| `track(remote_branch)` | `git branch -t` | `remote_branch` → `-t {remote_branch}` | + ### GitBranchManager Enhancements Needed | Feature | Status | Description | @@ -98,6 +121,24 @@ Properties: `remote_name`, `fetch_url`, `push_url` | `set_head(branch, auto, delete)` | **Missing** | `set-head` | | `update(prune)` | **Missing** | `update` | +#### CLI Flag → Python Parameter Mapping: Existing Methods + +| Method | Parameters → Flags | +|--------|-------------------| +| `rename()` | `progress=True` → `--progress`, `progress=False` → `--no-progress` | +| `show()` | `verbose=True` → `--verbose`, `no_query_remotes=True` → `-n` | +| `prune()` | `dry_run=True` → `--dry-run` | +| `get_url()` | `push=True` → `--push`, `_all=True` → `--all` | +| `set_url()` | `push=True` → `--push`, `add=True` → `--add`, `delete=True` → `--delete` | + +#### CLI Flag → Python Parameter Mapping: Missing Methods + +| Method | Git CLI | Parameters → Flags | +|--------|---------|-------------------| +| `set_branches(*branches)` | `git remote set-branches` | `add=True` → `--add`, `branches` → positional | +| `set_head(branch)` | `git remote set-head` | `auto=True` → `-a`, `delete=True` → `-d`, `branch` → positional | +| `update()` | `git remote update` | `prune=True` → `-p` | + --- ## 3. GitStashCmd (Current) → GitStashManager / GitStashEntryCmd (Planned) @@ -128,12 +169,30 @@ Properties: `remote_name`, `fetch_url`, `push_url` | `push(message, path, patch, staged, keep_index, include_untracked)` | **Planned** | Push to stash | | `clear()` | **Planned** | Clear all stashes | +#### CLI Flag → Python Parameter Mapping: `push()` + +| Git CLI Flag | Python Parameter | Description | +|--------------|------------------|-------------| +| `-p, --patch` | `patch: bool` | Interactive patch selection | +| `-S, --staged` | `staged: bool` | Stash only staged changes | +| `-k, --keep-index` | `keep_index: bool` | Keep index intact | +| `-u, --include-untracked` | `include_untracked: bool` | Include untracked files | +| `-a, --all` | `_all: bool` | Include ignored files | +| `-q, --quiet` | `quiet: bool` | Suppress output | +| `-m, --message ` | `message: str \| None` | Stash message | +| `-- ` | `path: list[str] \| None` | Limit to paths | + ### Planned GitStashEntryCmd (Per-entity) Properties: `index: int`, `branch: str`, `message: str` Parse from: `stash@{0}: On master: my message` +**Parsing pattern**: +```python +stash_pattern = r"stash@\{(?P\d+)\}: On (?P[^:]+): (?P.+)" +``` + | Method | Status | Description | |--------|--------|-------------| | `__init__(path, index, branch, message, cmd)` | **Planned** | Constructor | @@ -143,6 +202,16 @@ Parse from: `stash@{0}: On master: my message` | `drop()` | **Planned** | Delete this stash | | `branch(branch_name)` | **Planned** | Create branch from stash | +#### CLI Flag → Python Parameter Mapping: GitStashEntryCmd Methods + +| Method | Git CLI | Parameters → Flags | +|--------|---------|-------------------| +| `show()` | `git stash show` | `stat=True` → `--stat`, `patch=True` → `-p`, `include_untracked=True` → `-u` | +| `apply()` | `git stash apply` | `index=True` → `--index`, `quiet=True` → `-q` | +| `pop()` | `git stash pop` | `index=True` → `--index`, `quiet=True` → `-q` | +| `drop()` | `git stash drop` | `quiet=True` → `-q` | +| `branch(name)` | `git stash branch` | `name` → positional | + --- ## 4. GitSubmoduleCmd (Current) → GitSubmoduleManager / GitSubmoduleCmd (Planned) @@ -173,9 +242,18 @@ Parse from: `stash@{0}: On master: my message` | `sync(recursive)` | **Planned** | Sync submodule URLs | | `summary(commit, files, cached)` | **Planned** | Summarize changes | +#### CLI Flag → Python Parameter Mapping: GitSubmoduleManager Methods + +| Method | Git CLI | Parameters → Flags | +|--------|---------|-------------------| +| `add()` | `git submodule add` | `branch` → `-b`, `force=True` → `-f`, `name` → `--name`, `depth` → `--depth` | +| `foreach()` | `git submodule foreach` | `recursive=True` → `--recursive` | +| `sync()` | `git submodule sync` | `recursive=True` → `--recursive` | +| `summary()` | `git submodule summary` | `cached=True` → `--cached`, `files=True` → `--files`, `summary_limit` → `-n` | + ### Planned GitSubmoduleCmd (Per-entity) -Properties: `name`, `path`, `url`, `branch` +Properties: `name`, `path`, `url`, `branch`, `sha` | Method | Status | Description | |--------|--------|-------------| @@ -188,6 +266,18 @@ Properties: `name`, `path`, `url`, `branch` | `status()` | **Planned** | Show status | | `absorbgitdirs()` | **Planned** | Absorb gitdir | +#### CLI Flag → Python Parameter Mapping: GitSubmoduleCmd Methods + +| Method | Git CLI | Parameters → Flags | +|--------|---------|-------------------| +| `init()` | `git submodule init` | None | +| `update()` | `git submodule update` | `init=True` → `--init`, `force=True` → `-f`, `recursive=True` → `--recursive`, `checkout/rebase/merge` → mode flags | +| `deinit()` | `git submodule deinit` | `force=True` → `-f`, `_all=True` → `--all` | +| `set_branch(branch)` | `git submodule set-branch` | `branch` → `-b`, `default=True` → `-d` | +| `set_url(url)` | `git submodule set-url` | `url` → positional | +| `status()` | `git submodule status` | `recursive=True` → `--recursive` | +| `absorbgitdirs()` | `git submodule absorbgitdirs` | None | + --- ## 5. GitTagManager / GitTagCmd (New) @@ -205,6 +295,29 @@ Properties: `name`, `path`, `url`, `branch` | `filter(**kwargs)` | **Planned** | Filter tags | | `create(name, ref, message, annotate, sign, force)` | **Planned** | Create tag | +#### CLI Flag → Python Parameter Mapping: `create()` + +| Git CLI Flag | Python Parameter | Description | +|--------------|------------------|-------------| +| `-a, --annotate` | `annotate: bool` | Create annotated tag | +| `-s, --sign` | `sign: bool` | Create GPG-signed tag | +| `-u ` | `local_user: str \| None` | Use specific GPG key | +| `-f, --force` | `force: bool` | Replace existing tag | +| `-m ` | `message: str \| None` | Tag message | +| `-F ` | `file: str \| None` | Read message from file | + +#### CLI Flag → Python Parameter Mapping: `ls()` + +| Git CLI Flag | Python Parameter | Description | +|--------------|------------------|-------------| +| `-l ` | `pattern: str \| None` | List tags matching pattern | +| `--sort=` | `sort: str \| None` | Sort by key | +| `--contains ` | `contains: str \| None` | Tags containing commit | +| `--no-contains ` | `no_contains: str \| None` | Tags not containing commit | +| `--merged ` | `merged: str \| None` | Tags merged into commit | +| `--no-merged ` | `no_merged: str \| None` | Tags not merged | +| `-n` | `lines: int \| None` | Print annotation lines | + ### Planned GitTagCmd (Per-entity) Properties: `tag_name`, `ref`, `message` (for annotated) @@ -216,6 +329,14 @@ Properties: `tag_name`, `ref`, `message` (for annotated) | `delete()` | **Planned** | Delete tag (`-d`) | | `verify()` | **Planned** | Verify signed tag (`-v`) | +#### CLI Flag → Python Parameter Mapping: GitTagCmd Methods + +| Method | Git CLI | Parameters → Flags | +|--------|---------|-------------------| +| `delete()` | `git tag -d` | None | +| `verify()` | `git tag -v` | None | +| `show()` | `git show` | (uses git show, not git tag) | + --- ## 6. GitWorktreeManager / GitWorktreeCmd (New) @@ -234,6 +355,35 @@ Properties: `tag_name`, `ref`, `message` (for annotated) | `add(path, branch, detach, checkout, lock, force)` | **Planned** | Add worktree | | `prune(dry_run, verbose, expire)` | **Planned** | Prune worktrees | +#### CLI Flag → Python Parameter Mapping: `add()` + +| Git CLI Flag | Python Parameter | Description | +|--------------|------------------|-------------| +| `-f, --force` | `force: bool` | Force creation | +| `--detach` | `detach: bool` | Detach HEAD | +| `--checkout` | `checkout: bool` | Checkout after add | +| `--lock` | `lock: bool` | Lock worktree | +| `--reason ` | `reason: str \| None` | Lock reason | +| `-b ` | `new_branch: str \| None` | Create new branch | +| `-B ` | `new_branch_force: str \| None` | Force create branch | +| `--orphan` | `orphan: bool` | Create orphan branch | +| `--track` | `track: bool` | Track remote | + +#### CLI Flag → Python Parameter Mapping: `prune()` + +| Git CLI Flag | Python Parameter | Description | +|--------------|------------------|-------------| +| `-n, --dry-run` | `dry_run: bool` | Dry run | +| `-v, --verbose` | `verbose: bool` | Verbose output | +| `--expire