Skip to content

Commit eaccb3c

Browse files
authored
fix(git): checkout DWIMs remote-only branches (#277) (#278)
git-checkout:REF failed with 'not found even after fetch' for branches that exist on a remote but were never checked out locally. git's own DWIM can fail to fire (checkout.guess=false, FETCH_HEAD-only fetch). Resolve it explicitly: when checkout fails as pathspec, find the single remote carrying refs/remotes/<remote>/REF and create the local tracking branch via 'checkout -b REF --track <remote>/REF'. Error on >1 match.
1 parent 7bbee3e commit eaccb3c

2 files changed

Lines changed: 96 additions & 0 deletions

File tree

presets/git/checkout.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ def main() -> int:
4242
if prev_sha_res.returncode == 0:
4343
prev_sha = prev_sha_res.stdout.strip()
4444

45+
stderr = ""
46+
s = ""
4547
result = _git(["checkout", ref])
4648
if result.returncode != 0:
4749
stderr = result.stderr.strip() or result.stdout.strip()
@@ -57,6 +59,32 @@ def main() -> int:
5759
else:
5860
stderr = result.stderr.strip() or result.stdout.strip()
5961
s = stderr.lower()
62+
# #277: remote-only branch. git's DWIM (`checkout <branch>` →
63+
# create local tracking branch) can fail to fire — e.g.
64+
# checkout.guess=false, or after a fetch that only moved FETCH_HEAD.
65+
# Resolve it ourselves: if exactly one remote has
66+
# refs/remotes/<remote>/REF, create the tracking branch explicitly.
67+
# Multiple matches → error like git does.
68+
if result.returncode != 0 and ("did not match" in s or "pathspec" in s):
69+
remotes_res = _git(["remote"])
70+
remotes = remotes_res.stdout.split() if remotes_res.returncode == 0 else []
71+
matches = [
72+
r for r in remotes
73+
if _git(["rev-parse", "--verify", "--quiet", f"{r}/{ref}"]).returncode == 0
74+
]
75+
if len(matches) == 1:
76+
track = _git(["checkout", "-b", ref, "--track", f"{matches[0]}/{ref}"])
77+
if track.returncode == 0:
78+
print(f"# (created local branch tracking {matches[0]}/{ref})")
79+
result = track
80+
else:
81+
stderr = track.stderr.strip() or track.stdout.strip()
82+
s = stderr.lower()
83+
elif len(matches) > 1:
84+
joined = ", ".join(f"{r}/{ref}" for r in matches)
85+
print(f"ERROR: ref {ref!r} matches multiple remotes: {joined}. "
86+
f"Disambiguate, e.g. git checkout -b {ref} --track <remote>/{ref}")
87+
return 1
6088
if result.returncode != 0:
6189
if "not a git repository" in s:
6290
print("ERROR: not inside a git repository.")

tests/test_edge_cases_git_ops_silent_data_loss.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,74 @@ def test_clean_error_outside_git_repo(self, tmp_path, monkeypatch, capsys):
269269
), f"Unexpected error message: {out!r}"
270270

271271

272+
class TestCheckoutRemoteOnlyBranch:
273+
"""#277: branch exists on a remote but was never checked out locally.
274+
275+
git's DWIM (`git checkout <branch>` → create local tracking branch) can
276+
fail to fire; the op must resolve it explicitly via the remote-tracking
277+
ref instead of erroring 'not found even after fetch'.
278+
"""
279+
280+
def _make_clone_with_remote_branch(self, tmp_path: Path) -> Path:
281+
"""Clone with a remote-only branch 'feature/remote-only'.
282+
283+
Returns the clone repo. The branch exists on origin and as a
284+
remote-tracking ref, but has no local branch.
285+
"""
286+
origin = self._make_origin(tmp_path)
287+
clone = tmp_path / "clone"
288+
_git(tmp_path, "clone", str(origin), str(clone), check=True)
289+
_git(clone, "config", "user.email", "test@test.com", check=True)
290+
_git(clone, "config", "user.name", "Test", check=True)
291+
# Tracking ref exists (from clone), but no local branch.
292+
assert _git(clone, "rev-parse", "--verify", "--quiet",
293+
"origin/feature/remote-only").returncode == 0
294+
assert _git(clone, "rev-parse", "--verify", "--quiet",
295+
"feature/remote-only").returncode != 0
296+
return clone
297+
298+
def _make_origin(self, tmp_path: Path) -> Path:
299+
seed = _make_repo(tmp_path)
300+
_git(seed, "checkout", "-b", "feature/remote-only", check=True)
301+
(seed / "feature.txt").write_text("remote work\n")
302+
_git(seed, "add", "feature.txt", check=True)
303+
_git(seed, "commit", "-m", "remote-only feature", check=True)
304+
_git(seed, "checkout", "master", check=True)
305+
origin = tmp_path / "origin.git"
306+
_git(tmp_path, "clone", "--bare", str(seed), str(origin), check=True)
307+
return origin
308+
309+
def test_checks_out_remote_only_branch(self, tmp_path, monkeypatch, capsys):
310+
clone = self._make_clone_with_remote_branch(tmp_path)
311+
312+
rc, out = _run_checkout(clone, "feature/remote-only", monkeypatch, capsys)
313+
314+
assert rc == 0, f"Expected success, got rc={rc}: {out!r}"
315+
branch = _git(clone, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
316+
assert branch == "feature/remote-only", f"On wrong branch: {branch!r}"
317+
# Local tracking branch must now exist with the remote's content.
318+
assert (clone / "feature.txt").read_text() == "remote work\n"
319+
320+
def test_dwim_disabled_still_resolves(self, tmp_path, monkeypatch, capsys):
321+
"""checkout.guess=false disables git's DWIM — op must still resolve."""
322+
clone = self._make_clone_with_remote_branch(tmp_path)
323+
_git(clone, "config", "checkout.guess", "false", check=True)
324+
325+
rc, out = _run_checkout(clone, "feature/remote-only", monkeypatch, capsys)
326+
327+
assert rc == 0, f"Expected success with guess disabled, got rc={rc}: {out!r}"
328+
branch = _git(clone, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
329+
assert branch == "feature/remote-only"
330+
331+
def test_genuinely_missing_branch_still_errors(self, tmp_path, monkeypatch, capsys):
332+
clone = self._make_clone_with_remote_branch(tmp_path)
333+
334+
rc, out = _run_checkout(clone, "no/such/branch", monkeypatch, capsys)
335+
336+
assert rc != 0
337+
assert "not found" in out.lower(), f"Expected not-found error, got: {out!r}"
338+
339+
272340
# ===========================================================================
273341
# git-resolve tests
274342
# ===========================================================================

0 commit comments

Comments
 (0)