From 2ad7f4a92a76c9b7236ede098971adab38f0815f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 18 Jun 2024 15:17:14 -0500 Subject: [PATCH 1/6] 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 f32ce9c4..400310fb 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 2cae58a50340135467cdf864957eeea4823e2782 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 4 Jul 2024 12:24:07 -0500 Subject: [PATCH 2/6] 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 400310fb..c9821951 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 63cd5190..d20fbbbb 100644 --- a/src/libvcs/sync/git.py +++ b/src/libvcs/sync/git.py @@ -557,13 +557,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: @@ -579,7 +579,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, @@ -615,11 +615,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 b31a84e4..b2794ad9 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 1a9ccf2b770a970dc6535b76b65d21b737feb8fc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Feb 2025 10:33:27 -0600 Subject: [PATCH 3/6] 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 c9821951..2b02db3f 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 1aa15560..2445b461 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 8976f02fa25eb85af813ce37367bbff592e49720 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Feb 2025 10:43:11 -0600 Subject: [PATCH 4/6] 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 2b02db3f..be7ba314 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 2445b461..47d44cae 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 c2b3361916467e695c4d9df10e159611c3e7c1ec Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Feb 2025 10:44:47 -0600 Subject: [PATCH 5/6] 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 be7ba314..5c9a5314 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 908b72a84f64b5198ae4f8d7734ea3990e1e56f9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 22 Feb 2025 11:03:51 -0600 Subject: [PATCH 6/6] 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 5c9a5314..6731f9a9 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 47d44cae..243f723c 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