7b31a09a
Adopt a branch that exists when HEAD points at one that does not
a73x 2026-08-18 14:41
Commit message
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 | } | ||