Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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'"
Expand Down
46 changes: 46 additions & 0 deletions src/jinja2_git_dir/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
109 changes: 109 additions & 0 deletions tests/test_jinja2_git_dir.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
[
Expand Down