diff --git a/CHANGES b/CHANGES index 286a3a09..19dcd660 100644 --- a/CHANGES +++ b/CHANGES @@ -15,6 +15,18 @@ $ pip install --user --upgrade --pre libvcs +### New features + +#### cmd: Enhanced Git.init() with comprehensive validation and features (#487) + +- Added support for all git init parameters with full validation: + - `template`: Support for both string and Path objects with directory validation + - `separate_git_dir`: Support for custom git directory locations + - `object_format`: SHA-1/SHA-256 hash algorithm selection with validation + - `shared`: Extended support for all git-supported sharing modes including octal permissions + - `ref_format`: Support for 'files' and 'reftable' formats + - `make_parents`: Option to create parent directories automatically + ## libvcs 0.35.1 (2025-06-21) ### Bug fixes diff --git a/src/libvcs/cmd/git.py b/src/libvcs/cmd/git.py index f32ce9c4..c885fd58 100644 --- a/src/libvcs/cmd/git.py +++ b/src/libvcs/cmd/git.py @@ -5,6 +5,7 @@ import datetime import pathlib import shlex +import string import typing as t from collections.abc import Sequence @@ -1026,80 +1027,243 @@ 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 | None = None, + shared: bool + | 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 ---------- - 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 | 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 + 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 .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 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", 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. + 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 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. + bare : bool, optional + 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 + ------- + str + The output of the git init command. + + Raises + ------ + 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 -------- - >>> 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 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' + >>> template_repo.mkdir() + >>> 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 + '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: + 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: - 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: + 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(["--branch", branch]) - if initial_branch is not None: - local_flags.extend(["--initial-branch", initial_branch]) - if shared is True: - local_flags.append("--shared") + + 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) + and int(shared_str, 8) <= 0o777 # Validate octal range + ) + ): + msg = ( + f"Invalid shared value. Must be one of {valid_shared_values} " + "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], diff --git a/tests/cmd/test_git.py b/tests/cmd/test_git.py index 1aa15560..8c504e4d 100644 --- a/tests/cmd/test_git.py +++ b/tests/cmd/test_git.py @@ -19,3 +19,242 @@ 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 + + # Verify bare repository structure and configuration + assert (tmp_path / "HEAD").exists() + config_path = tmp_path / "config" + assert config_path.exists(), "Config file does not exist in bare repository" + config_text = config_path.read_text() + assert "bare = true" in config_text, "Repository core.bare flag not set to true" + + +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 + + +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 + + # 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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy