diff --git a/README.md b/README.md index 1af9083..ee96f32 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Git Directory Extension -Jinja2 filter extension for detecting if a directory is an (empty) git repository. +Jinja2 filter extension for detecting if a directory is an (empty) git repository and determining the default branch. ## Usage @@ -50,6 +50,29 @@ Examples: - Using `emptygit` in a conditional `{% if (git_path | emptygit) %}{{ git_path }} has commits{% else %}{{ git_path }} has NO commits{% endif %}` +### `gitdefaultbranch` + +Returns the default branch name for the git repository at the given path. Uses a multi-layered +fallback cascade to determine the default branch: + +1. `git symbolic-ref refs/remotes/upstream/HEAD` (local, fast) +2. `git symbolic-ref refs/remotes/origin/HEAD` (local, fast) +3. `git ls-remote --symref upstream HEAD` (network, read-only) +4. `git ls-remote --symref origin HEAD` (network, read-only) +5. `git config init.defaultBranch` (local config) +6. Hardcoded fallback: `main` + +Returns an empty string if the path is not a git repository. + + +Examples: + +- Get the default branch name + `{{ git_path | gitdefaultbranch }}` +- Using `gitdefaultbranch` in a conditional + `{% if (git_path | gitdefaultbranch) %}default branch: {{ git_path | gitdefaultbranch }}{% endif %}` + + ### Copier This can be utilized within a Copier `copier.yaml` file for determining if the destination @@ -66,7 +89,7 @@ _jinja_extensions: _tasks: - command: "git init" when: "{{ _copier_conf.dst_path | realpath | gitdir is false }}" - # `emptygit is false` test must come first, otherwise both tasks trigger + # ORDERING: `emptygit is false` test must come first, otherwise both tasks trigger - command: "git commit -am 'template update applied'" when: "{{ _copier_conf.dst_path | realpath | emptygit is false }}" - command: "git commit -am 'initial commit'" diff --git a/src/jinja2_git_dir/__init__.py b/src/jinja2_git_dir/__init__.py index 68f23f6..f3d2d75 100644 --- a/src/jinja2_git_dir/__init__.py +++ b/src/jinja2_git_dir/__init__.py @@ -23,6 +23,51 @@ def _git_dir(git_path: str) -> bool: return False +def _parse_symbolic_ref(output: str) -> str: + stripped = output.strip() + if stripped and "/" in stripped: + return stripped.split("/")[-1] + return "" + + +def _parse_ls_remote_symref(output: str) -> str: + for line in output.splitlines(): + if line.startswith("ref: refs/heads/"): + ref_path = line.split("\t")[0] + return ref_path.split("/")[-1] + return "" + + +def _git_default_branch(git_path: str) -> str: + # Not a git repo → empty string + if _run_git_command_at_path(git_path, ["rev-parse", "--is-inside-work-tree"]) is None: + return "" + + # Try symbolic-ref for upstream, then origin (local, fast) + for remote in ("upstream", "origin"): + result = _run_git_command_at_path(git_path, ["symbolic-ref", f"refs/remotes/{remote}/HEAD"]) + if result: + branch = _parse_symbolic_ref(result) + if branch: + return branch + + # Try ls-remote for upstream, then origin (network, read-only) + for remote in ("upstream", "origin"): + result = _run_git_command_at_path(git_path, ["ls-remote", "--symref", remote, "HEAD"]) + if result: + branch = _parse_ls_remote_symref(result) + if branch: + return branch + + # Try git config init.defaultBranch + result = _run_git_command_at_path(git_path, ["config", "init.defaultBranch"]) + if result and result.strip(): + return result.strip() + + # Ultimate fallback + return "main" + + def _empty_git(git_path: str) -> bool: opts: list[str] = ["rev-list", "--all", "--count"] num_commits: str | None = _run_git_command_at_path(git_path, opts) @@ -60,3 +105,4 @@ def __init__(self, environment: Environment) -> None: super().__init__(environment) environment.filters["gitdir"] = _git_dir environment.filters["emptygit"] = _empty_git + environment.filters["gitdefaultbranch"] = _git_default_branch diff --git a/tests/test_jinja2_git_dir.py b/tests/test_jinja2_git_dir.py index db9023f..3296852 100644 --- a/tests/test_jinja2_git_dir.py +++ b/tests/test_jinja2_git_dir.py @@ -45,6 +45,115 @@ def test_git_dir(git_path, mocked_toplevel_git_dir, expected, environment, fp): assert template.render(git_path=git_path) == expected +@pytest.mark.parametrize( + ("git_path", "mock_commands", "expected"), + [ + # Cascade level 1: upstream symbolic-ref + ( + "/git-dir", + { + ("git", "-C", "/git-dir", "rev-parse", "--is-inside-work-tree"): "true", + ( + "git", + "-C", + "/git-dir", + "symbolic-ref", + "refs/remotes/upstream/HEAD", + ): "refs/remotes/upstream/develop", + }, + "develop", + ), + # Cascade level 2: origin symbolic-ref (upstream fails) + ( + "/git-dir", + { + ("git", "-C", "/git-dir", "rev-parse", "--is-inside-work-tree"): "true", + ("git", "-C", "/git-dir", "symbolic-ref", "refs/remotes/origin/HEAD"): "refs/remotes/origin/main\n", + }, + "main", + ), + # Cascade level 3: ls-remote upstream + ( + "/git-dir", + { + ("git", "-C", "/git-dir", "rev-parse", "--is-inside-work-tree"): "true", + ( + "git", + "-C", + "/git-dir", + "ls-remote", + "--symref", + "upstream", + "HEAD", + ): "ref: refs/heads/master\tHEAD\nabc123\tHEAD\n", + }, + "master", + ), + # Cascade level 4: ls-remote origin + ( + "/git-dir", + { + ("git", "-C", "/git-dir", "rev-parse", "--is-inside-work-tree"): "true", + ( + "git", + "-C", + "/git-dir", + "ls-remote", + "--symref", + "origin", + "HEAD", + ): "ref: refs/heads/trunk\tHEAD\nabc123\tHEAD\n", + }, + "trunk", + ), + # Cascade level 5: git config init.defaultBranch + ( + "/git-dir", + { + ("git", "-C", "/git-dir", "rev-parse", "--is-inside-work-tree"): "true", + ("git", "-C", "/git-dir", "config", "init.defaultBranch"): "master\n", + }, + "master", + ), + # Cascade level 6: hardcoded fallback "main" + ( + "/git-dir", + { + ("git", "-C", "/git-dir", "rev-parse", "--is-inside-work-tree"): "true", + }, + "main", + ), + # Not a git repo → empty string + ("/non-git-dir", {}, ""), + # Invalid path type → empty string + (["not", "a", "path"], {}, ""), + ], +) +def test_git_default_branch(git_path, mock_commands, expected, environment, fp): + # Register mocked commands that should succeed + for cmd, stdout in mock_commands.items(): + fp.register(list(cmd), stdout=stdout) + + # Let unregistered commands fail (CalledProcessError) + fp.allow_unregistered(allow=True) + + template = environment.from_string("{{ git_path | gitdefaultbranch }}") + assert template.render(git_path=git_path) == expected + + +def test_git_default_branch_conditional(environment, fp): + """Test gitdefaultbranch in a conditional — non-empty string is truthy.""" + fp.register( + ["git", "-C", "/git-dir", "rev-parse", "--is-inside-work-tree"], + stdout="true", + ) + fp.allow_unregistered(allow=True) + + template = environment.from_string("{% if (git_path | gitdefaultbranch) %}yes{% else %}no{% endif %}") + assert template.render(git_path="/git-dir") == "yes" + assert template.render(git_path="/non-git-dir") == "no" + + @pytest.mark.parametrize( ("git_path", "mocked_num_commits", "expected"), [