diff --git a/CHANGES b/CHANGES index 64c7c3cd..5cefa1bc 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 + ### Development - Cursor rules for development loop and git commit messages (#488) 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