Skip to content

Commit 75601c7

Browse files
fix(core): advance git cache HEAD on refresh without ref
Closes #214.
1 parent 0d0857e commit 75601c7

2 files changed

Lines changed: 144 additions & 6 deletions

File tree

packages/create-python-app-core/src/create_python_app_core/git_cache.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,50 @@ def _run_git(args: list[str], *, cwd: Path | None = None) -> str:
103103
raise CpaError("git executable not found", code="CPA_GIT") from exc
104104

105105

106+
def _remote_default_ref(entry: Path) -> str:
107+
"""Resolve origin/HEAD (e.g. origin/main) for an existing cache clone."""
108+
try:
109+
sym = _run_git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=entry)
110+
if sym.startswith("refs/remotes/"):
111+
return sym.removeprefix("refs/remotes/")
112+
if sym.startswith("origin/"):
113+
return sym
114+
except CpaError:
115+
pass
116+
for candidate in ("origin/main", "origin/master"):
117+
try:
118+
_run_git(["rev-parse", "--verify", candidate], cwd=entry)
119+
return candidate
120+
except CpaError:
121+
continue
122+
raise CpaError(
123+
"unable to resolve remote default branch for cache refresh",
124+
code="CPA_GIT",
125+
)
126+
127+
128+
def _refresh_cached_repo(entry: Path, ref: str | None) -> str:
129+
"""Fetch and hard-reset the cache clone to the remote tip (CNA pull parity)."""
130+
_run_git(["fetch", "--all", "--tags"], cwd=entry)
131+
if ref:
132+
remote = ref if ref.startswith(("refs/", "origin/")) else f"origin/{ref}"
133+
local_branch = ref.rsplit("/", 1)[-1]
134+
_run_git(["checkout", "--force", "-B", local_branch, remote], cwd=entry)
135+
_run_git(["reset", "--hard", remote], cwd=entry)
136+
else:
137+
remote = _remote_default_ref(entry)
138+
local_branch = remote.rsplit("/", 1)[-1]
139+
_run_git(["checkout", "--force", "-B", local_branch, remote], cwd=entry)
140+
_run_git(["reset", "--hard", remote], cwd=entry)
141+
return _run_git(["rev-parse", "HEAD"], cwd=entry)
142+
143+
144+
def _subdir_missing(entry: Path, source: ResolvedSource) -> bool:
145+
if not source.subdir:
146+
return False
147+
return not (entry / source.subdir).is_dir()
148+
149+
106150
def download_repository(
107151
source: ResolvedSource,
108152
*,
@@ -128,14 +172,14 @@ def download_repository(
128172
)
129173
return entry
130174

131-
if entry.exists() and not _should_refresh(meta, mode):
175+
needs_refresh = _should_refresh(meta, mode) or (
176+
entry.exists() and _subdir_missing(entry, source)
177+
)
178+
if entry.exists() and not needs_refresh:
132179
return entry
133180

134181
if entry.exists() and (entry / ".git").is_dir() and mode != "manual":
135-
_run_git(["fetch", "--all", "--tags"], cwd=entry)
136-
if source.ref:
137-
_run_git(["checkout", source.ref], cwd=entry)
138-
commit = _run_git(["rev-parse", "HEAD"], cwd=entry)
182+
commit = _refresh_cached_repo(entry, source.ref)
139183
else:
140184
if entry.exists():
141185
shutil.rmtree(entry)

packages/create-python-app-core/tests/test_git_cache.py

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
import subprocess
23
import time
34
from pathlib import Path
45

@@ -7,12 +8,29 @@
78
from create_python_app_core.git_cache import (
89
CacheMeta,
910
download_repository,
10-
read_cache_meta,
1111
write_cache_meta,
1212
)
1313
from create_python_app_core.paths import ResolvedSource
1414

1515

16+
def _git(args: list[str], *, cwd: Path) -> str:
17+
return subprocess.check_output(
18+
["git", *args], cwd=cwd, text=True, stderr=subprocess.STDOUT
19+
).strip()
20+
21+
22+
def _init_remote(path: Path) -> None:
23+
path.mkdir(parents=True)
24+
_git(["init", "-b", "main"], cwd=path)
25+
_git(["config", "user.email", "test@example.com"], cwd=path)
26+
_git(["config", "user.name", "Test"], cwd=path)
27+
(path / "README.md").write_text("v1\n", encoding="utf-8")
28+
(path / "extensions" / "legacy").mkdir(parents=True)
29+
(path / "extensions" / "legacy" / "ok.txt").write_text("legacy\n", encoding="utf-8")
30+
_git(["add", "."], cwd=path)
31+
_git(["commit", "-m", "init"], cwd=path)
32+
33+
1634
def test_file_source_returns_path(tmp_path: Path) -> None:
1735
src = ResolvedSource(kind="file", url=f"file://{tmp_path}", local_path=tmp_path)
1836
assert download_repository(src) == tmp_path
@@ -29,6 +47,8 @@ def test_meta_roundtrip(tmp_path: Path) -> None:
2947
entry = tmp_path / "e"
3048
meta = CacheMeta(url="u", ref="main", fetched_at=time.time(), commit="abc")
3149
write_cache_meta(entry, meta)
50+
from create_python_app_core.git_cache import read_cache_meta
51+
3252
loaded = read_cache_meta(entry)
3353
assert loaded is not None
3454
assert loaded.url == "u"
@@ -41,3 +61,77 @@ def test_skip_git_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
4161
with pytest.raises(CpaError) as ei:
4262
download_repository(src, cache_root=tmp_path, refresh="always")
4363
assert ei.value.code == "CPA_SKIP_GIT"
64+
65+
66+
def test_refresh_without_ref_advances_to_remote_tip(tmp_path: Path) -> None:
67+
work = tmp_path / "work"
68+
bare = tmp_path / "bare.git"
69+
cache = tmp_path / "cache"
70+
71+
_init_remote(work)
72+
_git(["clone", "--bare", str(work), str(bare)], cwd=tmp_path)
73+
74+
src = ResolvedSource(kind="git", url=str(bare))
75+
first = download_repository(src, cache_root=cache, refresh="always")
76+
first_sha = _git(["rev-parse", "HEAD"], cwd=first)
77+
assert (first / "extensions" / "legacy" / "ok.txt").is_file()
78+
assert not (first / "extensions" / "all-github-setup").exists()
79+
80+
_git(["clone", str(bare), str(tmp_path / "push")], cwd=tmp_path)
81+
push = tmp_path / "push"
82+
_git(["config", "user.email", "test@example.com"], cwd=push)
83+
_git(["config", "user.name", "Test"], cwd=push)
84+
(push / "extensions" / "all-github-setup").mkdir(parents=True)
85+
(push / "extensions" / "all-github-setup" / "ok.txt").write_text(
86+
"new\n", encoding="utf-8"
87+
)
88+
_git(["add", "."], cwd=push)
89+
_git(["commit", "-m", "rename extension"], cwd=push)
90+
_git(["push", "origin", "main"], cwd=push)
91+
new_sha = _git(["rev-parse", "HEAD"], cwd=push)
92+
assert new_sha != first_sha
93+
94+
refreshed = download_repository(src, cache_root=cache, refresh="always")
95+
assert refreshed == first
96+
assert _git(["rev-parse", "HEAD"], cwd=refreshed) == new_sha
97+
assert (refreshed / "extensions" / "all-github-setup" / "ok.txt").is_file()
98+
99+
100+
def test_missing_subdir_forces_refresh_when_meta_is_fresh(tmp_path: Path) -> None:
101+
work = tmp_path / "work"
102+
bare = tmp_path / "bare.git"
103+
cache = tmp_path / "cache"
104+
_init_remote(work)
105+
_git(["clone", "--bare", str(work), str(bare)], cwd=tmp_path)
106+
107+
src = ResolvedSource(
108+
kind="git",
109+
url=str(bare),
110+
subdir="extensions/all-github-setup",
111+
)
112+
entry = download_repository(src, cache_root=cache, refresh="always")
113+
write_cache_meta(
114+
entry,
115+
CacheMeta(
116+
url=str(bare),
117+
ref=None,
118+
fetched_at=time.time(),
119+
commit=_git(["rev-parse", "HEAD"], cwd=entry),
120+
),
121+
)
122+
assert not (entry / "extensions" / "all-github-setup").exists()
123+
124+
_git(["clone", str(bare), str(tmp_path / "push")], cwd=tmp_path)
125+
push = tmp_path / "push"
126+
_git(["config", "user.email", "test@example.com"], cwd=push)
127+
_git(["config", "user.name", "Test"], cwd=push)
128+
(push / "extensions" / "all-github-setup").mkdir(parents=True)
129+
(push / "extensions" / "all-github-setup" / "ok.txt").write_text(
130+
"new\n", encoding="utf-8"
131+
)
132+
_git(["add", "."], cwd=push)
133+
_git(["commit", "-m", "add all-github-setup"], cwd=push)
134+
_git(["push", "origin", "main"], cwd=push)
135+
136+
updated = download_repository(src, cache_root=cache, refresh="stale")
137+
assert (updated / "extensions" / "all-github-setup" / "ok.txt").is_file()

0 commit comments

Comments
 (0)