a73x

7b31a09a

Adopt a branch that exists when HEAD points at one that does not

a73x   2026-08-18 14:41

Commit message
Adopt a branch that exists when HEAD points at one that does not

A repository the server creates on first push is born from `init_bare`,
which aims HEAD at whatever `init.defaultBranch` says. On a container
with no gitconfig that is `master`, and nothing in the push path ever
moves it. Push `main` and HEAD dangles for the life of the repository:
no HEAD is advertised, and a clone fetches every object but has nothing
to check out. The client reports it as a warning and exits 0, so the
result is an empty working tree from a repository full of files.

Five of the seven repositories on the live server were in this state.

Adopting after the push is the alternative to guessing a default branch
name at creation time, which is a guess that can be wrong. This one
cannot: it fires only on a HEAD that resolves to nothing, so a branch an
operator chose deliberately is never second-guessed, and because it runs
on every push an already-dangling repository heals on its next one.
`main` and `master` are preferred over the alphabetical pick only to
keep the choice predictable when a first push carries several branches.

Failing to settle HEAD does not fail the push the client has already
completed; it is logged and the push stands.

The test pushes a branch named neither `main` nor `master`. Named
either, it passes on any machine whose `init.defaultBranch` happens to
match — which is how this went unnoticed: libgit2 reads the developer's
gitconfig, so the bug is invisible everywhere except where it lives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

src/server/repos.rs
Old New
@@ -540,6 +540,53 @@ pub fn resolve_clone_target(repos_dir: &Path, requested: &str) -> Option<RepoEnt
540 resolve(repos_dir, requested).or_else(|| resolve(repos_dir, requested.strip_suffix(".git")?)) 540 resolve(repos_dir, requested).or_else(|| resolve(repos_dir, requested.strip_suffix(".git")?))
541 } 541 }
542 542
543 /// Point HEAD at a branch that exists, when it currently points at one that
544 /// does not.
545 ///
546 /// A repository born from `init_bare` has HEAD aimed at whatever
547 /// `init.defaultBranch` said — a branch nobody has pushed yet. Nothing in the
548 /// push path moves it, so unless something adopts the branch that actually
549 /// arrives, HEAD dangles for the life of the repository: no HEAD is
550 /// advertised, and a clone fetches every object but has nothing to check out.
551 ///
552 /// Adoption is the alternative to guessing a default branch name at creation
553 /// time, which is a guess that can be wrong. This one cannot: it only ever
554 /// fires on a HEAD that resolves to nothing, so a default branch an operator
555 /// chose deliberately is never second-guessed. Running it after every push
556 /// also means an already-dangling repository heals on its next one.
557 ///
558 /// `main` and `master` are preferred over an alphabetical pick only to keep
559 /// the choice predictable when a first push carries several branches at once.
560 pub fn adopt_head_if_unborn(repo_path: &Path) -> Result<(), git2::Error> {
561 let repo = git2::Repository::open(repo_path)?;
562
563 // A HEAD that resolves is already an answer. Leave it alone.
564 if repo.head().is_ok() {
565 return Ok(());
566 }
567
568 let mut names: Vec<String> = repo
569 .branches(Some(git2::BranchType::Local))?
570 .filter_map(Result::ok)
571 .filter_map(|(branch, _)| branch.name().ok().flatten().map(str::to_string))
572 .collect();
573 names.sort();
574
575 let chosen = ["main", "master"]
576 .iter()
577 .find(|preferred| names.iter().any(|n| n == *preferred))
578 .map(|preferred| (*preferred).to_string())
579 .or_else(|| names.first().cloned());
580
581 // No branches at all — a push of nothing but collab refs, say. There is
582 // nothing to adopt, and an unborn HEAD is the honest description of that.
583 let Some(chosen) = chosen else {
584 return Ok(());
585 };
586
587 repo.set_head(&format!("refs/heads/{chosen}"))
588 }
589
543 /// Open a git2::Repository from a RepoEntry. 590 /// Open a git2::Repository from a RepoEntry.
544 pub fn open(entry: &RepoEntry) -> Result<git2::Repository, git2::Error> { 591 pub fn open(entry: &RepoEntry) -> Result<git2::Repository, git2::Error> {
545 if entry.bare { 592 if entry.bare {
src/server/ssh/session.rs
Old New
@@ -974,6 +974,16 @@ async fn run_git_command(
974 let status = child.wait().await?; 974 let status = child.wait().await?;
975 let exit_code = status.code().unwrap_or(1) as u32; 975 let exit_code = status.code().unwrap_or(1) as u32;
976 976
977 // A push is the only thing that can create the branch HEAD should name, so
978 // this is the point at which a repository whose HEAD never resolved can
979 // start resolving. Failing to settle it is not a reason to fail the push
980 // the client already completed.
981 if git_cmd == GitCmd::ReceivePack && exit_code == 0 {
982 if let Err(e) = crate::repos::adopt_head_if_unborn(repo_path) {
983 warn!("Could not settle HEAD in {:?}: {}", repo_path, e);
984 }
985 }
986
977 let _ = handle.exit_status_request(channel, exit_code).await; 987 let _ = handle.exit_status_request(channel, exit_code).await;
978 let _ = handle.eof(channel).await; 988 let _ = handle.eof(channel).await;
979 let _ = handle.close(channel).await; 989 let _ = handle.close(channel).await;
tests/common/mod.rs
Old New
@@ -1380,7 +1380,7 @@ impl ServerHarness {
1380 /// Run `git push` over SSH from an arbitrary working tree, as `key`, 1380 /// Run `git push` over SSH from an arbitrary working tree, as `key`,
1381 /// returning the raw output rather than asserting success. 1381 /// returning the raw output rather than asserting success.
1382 pub fn ssh_push_from(&self, dir: &Path, key: &Path, repo: &str, refspec: &str) -> Output { 1382 pub fn ssh_push_from(&self, dir: &Path, key: &Path, repo: &str, refspec: &str) -> Output {
1383 let url = format!("ssh://git@127.0.0.1:{}/{}.git", self.ssh_addr.port(), repo); 1383 let url = self.ssh_url_for(repo);
1384 Command::new("git") 1384 Command::new("git")
1385 .args(["push", &url, refspec]) 1385 .args(["push", &url, refspec])
1386 .env("GIT_SSH_COMMAND", ssh_command_for(key)) 1386 .env("GIT_SSH_COMMAND", ssh_command_for(key))
@@ -1399,7 +1399,7 @@ impl ServerHarness {
1399 /// Fetch over SSH as `key`, returning raw output. Used to assert that an 1399 /// Fetch over SSH as `key`, returning raw output. Used to assert that an
1400 /// unreadable repository is refused rather than merely empty. 1400 /// unreadable repository is refused rather than merely empty.
1401 pub fn ssh_fetch(&self, dir: &Path, key: &Path, repo: &str) -> Output { 1401 pub fn ssh_fetch(&self, dir: &Path, key: &Path, repo: &str) -> Output {
1402 let url = format!("ssh://git@127.0.0.1:{}/{}.git", self.ssh_addr.port(), repo); 1402 let url = self.ssh_url_for(repo);
1403 Command::new("git") 1403 Command::new("git")
1404 .args(["ls-remote", &url]) 1404 .args(["ls-remote", &url])
1405 .env("GIT_SSH_COMMAND", ssh_command_for(key)) 1405 .env("GIT_SSH_COMMAND", ssh_command_for(key))
@@ -1495,11 +1495,27 @@ impl ServerHarness {
1495 1495
1496 /// ssh:// URL for the harness repo, for use as a git-collab remote. 1496 /// ssh:// URL for the harness repo, for use as a git-collab remote.
1497 pub fn repo_ssh_url(&self) -> String { 1497 pub fn repo_ssh_url(&self) -> String {
1498 format!( 1498 self.ssh_url_for(&self.repo_name)
1499 "ssh://git@127.0.0.1:{}/{}.git", 1499 }
1500 self.ssh_addr.port(), 1500
1501 self.repo_name 1501 /// ssh:// URL for any repository name on this server, including one the
1502 ) 1502 /// server has never seen — which is how a test reaches the create-on-push
1503 /// path.
1504 pub fn ssh_url_for(&self, repo: &str) -> String {
1505 format!("ssh://git@127.0.0.1:{}/{}.git", self.ssh_addr.port(), repo)
1506 }
1507
1508 /// Clone over SSH into `dest`, returning raw output rather than asserting
1509 /// success. A clone is the only thing that exercises HEAD end to end: it
1510 /// is the client's checkout, not the ref advertisement, that a dangling
1511 /// HEAD actually breaks.
1512 pub fn ssh_clone(&self, repo: &str, dest: &Path) -> Output {
1513 Command::new("git")
1514 .args(["clone", &self.ssh_url_for(repo), dest.to_str().unwrap()])
1515 .env("GIT_SSH_COMMAND", ssh_command_for(&self.ssh_client_key()))
1516 .env("GIT_TERMINAL_PROMPT", "0")
1517 .output()
1518 .expect("failed to run git clone")
1503 } 1519 }
1504 1520
1505 pub fn work_repo(&self) -> &TestRepo { 1521 pub fn work_repo(&self) -> &TestRepo {
tests/server_behavior_test.rs
Old New
@@ -1,5 +1,7 @@
1 mod common; 1 mod common;
2 2
3 use std::process::Command;
4
3 use common::ServerHarness; 5 use common::ServerHarness;
4 6
5 #[test] 7 #[test]
@@ -393,3 +395,101 @@ fn branch_selectors_never_interpolate_data_into_inline_javascript() {
393 tree.body 395 tree.body
394 ); 396 );
395 } 397 }
398
399 /// A repository the server creates on first push must end up with a HEAD that
400 /// resolves to a branch that actually arrived.
401 ///
402 /// `git init --bare` stamps HEAD at a branch name nobody has pushed yet —
403 /// whatever `init.defaultBranch` happens to say, which on a server with no
404 /// gitconfig is `master`. If nothing adopts the branch that does arrive, HEAD
405 /// dangles forever: the server advertises no HEAD, and the client clones every
406 /// object but has nothing to check out — an empty working tree from a
407 /// repository full of files, reported only as a warning.
408 ///
409 /// The pushed branch is deliberately named neither `main` nor `master`, so no
410 /// `init.defaultBranch` on the machine running the tests can make HEAD line up
411 /// by luck and hide the bug.
412 #[test]
413 fn cloning_a_server_created_repository_checks_out_the_pushed_branch() {
414 let harness = ServerHarness::new("behavior-head-adoption");
415 let key = harness.ssh_client_key();
416
417 harness
418 .work_repo()
419 .commit_file("README.md", "hello\n", "add readme");
420
421 // A name the server has never seen, so the push is what creates it.
422 let push = harness.ssh_push_from(
423 harness.work_repo().dir.path(),
424 &key,
425 "created-on-push",
426 "main:trunk",
427 );
428 assert!(
429 push.status.success(),
430 "push failed: {}",
431 String::from_utf8_lossy(&push.stderr)
432 );
433
434 let dest = harness.scratch("clone-of-created").join("created-on-push");
435 let clone = harness.ssh_clone("created-on-push", &dest);
436 assert!(
437 clone.status.success(),
438 "clone failed: {}",
439 String::from_utf8_lossy(&clone.stderr)
440 );
441 assert!(
442 dest.join("README.md").is_file(),
443 "clone left no working tree; HEAD did not resolve.\n{}",
444 String::from_utf8_lossy(&clone.stderr)
445 );
446 }
447
448 /// Adoption settles HEAD once. A later push that brings a branch the
449 /// preference order rates more highly must not move it.
450 ///
451 /// Without this, HEAD would drift under the operator: a repository serving
452 /// `trunk` would silently switch to `main` the moment a `main` appeared, and
453 /// every subsequent clone would check out a different branch than the last.
454 #[test]
455 fn a_settled_head_is_not_moved_by_later_pushes() {
456 let harness = ServerHarness::new("behavior-head-stable");
457 let key = harness.ssh_client_key();
458 let work = harness.work_repo();
459
460 work.commit_file("README.md", "hello\n", "add readme");
461 let push = harness.ssh_push_from(work.dir.path(), &key, "settled", "main:trunk");
462 assert!(
463 push.status.success(),
464 "first push failed: {}",
465 String::from_utf8_lossy(&push.stderr)
466 );
467
468 // `main` outranks `trunk` in the adoption preference order, so a HEAD that
469 // is still up for grabs would land on it.
470 let push = harness.ssh_push_from(work.dir.path(), &key, "settled", "main:main");
471 assert!(
472 push.status.success(),
473 "second push failed: {}",
474 String::from_utf8_lossy(&push.stderr)
475 );
476
477 let dest = harness.scratch("clone-of-settled").join("settled");
478 let clone = harness.ssh_clone("settled", &dest);
479 assert!(
480 clone.status.success(),
481 "clone failed: {}",
482 String::from_utf8_lossy(&clone.stderr)
483 );
484
485 let checked_out = Command::new("git")
486 .args(["rev-parse", "--abbrev-ref", "HEAD"])
487 .current_dir(&dest)
488 .output()
489 .expect("failed to run git rev-parse");
490 assert_eq!(
491 String::from_utf8_lossy(&checked_out.stdout).trim(),
492 "trunk",
493 "HEAD drifted off the branch it had already settled on"
494 );
495 }