Skip to content

Commit 48bac75

Browse files
committed
feat(filter): adds gitdefaultbranch filter
Returns the default branch name using a multi-layered fallback cascade: symbolic-ref (upstream/origin) → ls-remote (upstream/origin) → git config init.defaultBranch → hardcoded "main". Returns empty string for non-git paths.
1 parent cca1a9e commit 48bac75

3 files changed

Lines changed: 180 additions & 2 deletions

File tree

README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Git Directory Extension
22

3-
Jinja2 filter extension for detecting if a directory is an (empty) git repository.
3+
Jinja2 filter extension for detecting if a directory is an (empty) git repository and determining the default branch.
44

55
## Usage
66

@@ -50,6 +50,29 @@ Examples:
5050
- Using `emptygit` in a conditional
5151
`{% if (git_path | emptygit) %}{{ git_path }} has commits{% else %}{{ git_path }} has NO commits{% endif %}`
5252

53+
### `gitdefaultbranch`
54+
55+
Returns the default branch name for the git repository at the given path. Uses a multi-layered
56+
fallback cascade to determine the default branch:
57+
58+
1. `git symbolic-ref refs/remotes/upstream/HEAD` (local, fast)
59+
2. `git symbolic-ref refs/remotes/origin/HEAD` (local, fast)
60+
3. `git ls-remote --symref upstream HEAD` (network, read-only)
61+
4. `git ls-remote --symref origin HEAD` (network, read-only)
62+
5. `git config init.defaultBranch` (local config)
63+
6. Hardcoded fallback: `main`
64+
65+
Returns an empty string if the path is not a git repository.
66+
67+
68+
Examples:
69+
70+
- Get the default branch name
71+
`{{ git_path | gitdefaultbranch }}`
72+
- Using `gitdefaultbranch` in a conditional
73+
`{% if (git_path | gitdefaultbranch) %}default branch: {{ git_path | gitdefaultbranch }}{% endif %}`
74+
75+
5376
### Copier
5477

5578
This can be utilized within a Copier `copier.yaml` file for determining if the destination
@@ -66,7 +89,7 @@ _jinja_extensions:
6689
_tasks:
6790
- command: "git init"
6891
when: "{{ _copier_conf.dst_path | realpath | gitdir is false }}"
69-
# `emptygit is false` test must come first, otherwise both tasks trigger
92+
# ORDERING: `emptygit is false` test must come first, otherwise both tasks trigger
7093
- command: "git commit -am 'template update applied'"
7194
when: "{{ _copier_conf.dst_path | realpath | emptygit is false }}"
7295
- command: "git commit -am 'initial commit'"

src/jinja2_git_dir/__init__.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,51 @@ def _git_dir(git_path: str) -> bool:
2323
return False
2424

2525

26+
def _parse_symbolic_ref(output: str) -> str:
27+
stripped = output.strip()
28+
if stripped and "/" in stripped:
29+
return stripped.split("/")[-1]
30+
return ""
31+
32+
33+
def _parse_ls_remote_symref(output: str) -> str:
34+
for line in output.splitlines():
35+
if line.startswith("ref: refs/heads/"):
36+
ref_path = line.split("\t")[0]
37+
return ref_path.split("/")[-1]
38+
return ""
39+
40+
41+
def _git_default_branch(git_path: str) -> str:
42+
# Not a git repo → empty string
43+
if _run_git_command_at_path(git_path, ["rev-parse", "--is-inside-work-tree"]) is None:
44+
return ""
45+
46+
# Try symbolic-ref for upstream, then origin (local, fast)
47+
for remote in ("upstream", "origin"):
48+
result = _run_git_command_at_path(git_path, ["symbolic-ref", f"refs/remotes/{remote}/HEAD"])
49+
if result:
50+
branch = _parse_symbolic_ref(result)
51+
if branch:
52+
return branch
53+
54+
# Try ls-remote for upstream, then origin (network, read-only)
55+
for remote in ("upstream", "origin"):
56+
result = _run_git_command_at_path(git_path, ["ls-remote", "--symref", remote, "HEAD"])
57+
if result:
58+
branch = _parse_ls_remote_symref(result)
59+
if branch:
60+
return branch
61+
62+
# Try git config init.defaultBranch
63+
result = _run_git_command_at_path(git_path, ["config", "init.defaultBranch"])
64+
if result and result.strip():
65+
return result.strip()
66+
67+
# Ultimate fallback
68+
return "main"
69+
70+
2671
def _empty_git(git_path: str) -> bool:
2772
opts: list[str] = ["rev-list", "--all", "--count"]
2873
num_commits: str | None = _run_git_command_at_path(git_path, opts)
@@ -60,3 +105,4 @@ def __init__(self, environment: Environment) -> None:
60105
super().__init__(environment)
61106
environment.filters["gitdir"] = _git_dir
62107
environment.filters["emptygit"] = _empty_git
108+
environment.filters["gitdefaultbranch"] = _git_default_branch

tests/test_jinja2_git_dir.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,115 @@ def test_git_dir(git_path, mocked_toplevel_git_dir, expected, environment, fp):
4545
assert template.render(git_path=git_path) == expected
4646

4747

48+
@pytest.mark.parametrize(
49+
("git_path", "mock_commands", "expected"),
50+
[
51+
# Cascade level 1: upstream symbolic-ref
52+
(
53+
"/git-dir",
54+
{
55+
("git", "-C", "/git-dir", "rev-parse", "--is-inside-work-tree"): "true",
56+
(
57+
"git",
58+
"-C",
59+
"/git-dir",
60+
"symbolic-ref",
61+
"refs/remotes/upstream/HEAD",
62+
): "refs/remotes/upstream/develop",
63+
},
64+
"develop",
65+
),
66+
# Cascade level 2: origin symbolic-ref (upstream fails)
67+
(
68+
"/git-dir",
69+
{
70+
("git", "-C", "/git-dir", "rev-parse", "--is-inside-work-tree"): "true",
71+
("git", "-C", "/git-dir", "symbolic-ref", "refs/remotes/origin/HEAD"): "refs/remotes/origin/main\n",
72+
},
73+
"main",
74+
),
75+
# Cascade level 3: ls-remote upstream
76+
(
77+
"/git-dir",
78+
{
79+
("git", "-C", "/git-dir", "rev-parse", "--is-inside-work-tree"): "true",
80+
(
81+
"git",
82+
"-C",
83+
"/git-dir",
84+
"ls-remote",
85+
"--symref",
86+
"upstream",
87+
"HEAD",
88+
): "ref: refs/heads/master\tHEAD\nabc123\tHEAD\n",
89+
},
90+
"master",
91+
),
92+
# Cascade level 4: ls-remote origin
93+
(
94+
"/git-dir",
95+
{
96+
("git", "-C", "/git-dir", "rev-parse", "--is-inside-work-tree"): "true",
97+
(
98+
"git",
99+
"-C",
100+
"/git-dir",
101+
"ls-remote",
102+
"--symref",
103+
"origin",
104+
"HEAD",
105+
): "ref: refs/heads/trunk\tHEAD\nabc123\tHEAD\n",
106+
},
107+
"trunk",
108+
),
109+
# Cascade level 5: git config init.defaultBranch
110+
(
111+
"/git-dir",
112+
{
113+
("git", "-C", "/git-dir", "rev-parse", "--is-inside-work-tree"): "true",
114+
("git", "-C", "/git-dir", "config", "init.defaultBranch"): "master\n",
115+
},
116+
"master",
117+
),
118+
# Cascade level 6: hardcoded fallback "main"
119+
(
120+
"/git-dir",
121+
{
122+
("git", "-C", "/git-dir", "rev-parse", "--is-inside-work-tree"): "true",
123+
},
124+
"main",
125+
),
126+
# Not a git repo → empty string
127+
("/non-git-dir", {}, ""),
128+
# Invalid path type → empty string
129+
(["not", "a", "path"], {}, ""),
130+
],
131+
)
132+
def test_git_default_branch(git_path, mock_commands, expected, environment, fp):
133+
# Register mocked commands that should succeed
134+
for cmd, stdout in mock_commands.items():
135+
fp.register(list(cmd), stdout=stdout)
136+
137+
# Let unregistered commands fail (CalledProcessError)
138+
fp.allow_unregistered(allow=True)
139+
140+
template = environment.from_string("{{ git_path | gitdefaultbranch }}")
141+
assert template.render(git_path=git_path) == expected
142+
143+
144+
def test_git_default_branch_conditional(environment, fp):
145+
"""Test gitdefaultbranch in a conditional — non-empty string is truthy."""
146+
fp.register(
147+
["git", "-C", "/git-dir", "rev-parse", "--is-inside-work-tree"],
148+
stdout="true",
149+
)
150+
fp.allow_unregistered(allow=True)
151+
152+
template = environment.from_string("{% if (git_path | gitdefaultbranch) %}yes{% else %}no{% endif %}")
153+
assert template.render(git_path="/git-dir") == "yes"
154+
assert template.render(git_path="/non-git-dir") == "no"
155+
156+
48157
@pytest.mark.parametrize(
49158
("git_path", "mocked_num_commits", "expected"),
50159
[

0 commit comments

Comments
 (0)