fix(git): allow deleting the default branch (#4297)

Tal here, human. Trying to help. This bug bugged me...

## Summary

A repository's first branch becomes its symbolic `HEAD`, and Git's
bare-repository default rejects deleting that branch even when another
branch survives.

This change:

- sets `receive.denyDeleteCurrent=ignore` only for the ephemeral `git
receive-pack` process
- preserves the existing server-side `core.hooksPath` override and
authorization hook
- lets the existing CAS publication logic select a surviving branch as
the next manifest `HEAD`
- adds regression coverage using a real stateless `git receive-pack`
request and a manifest HEAD-selection test

This lets users replace an accidental default branch without deleting
the object-storage manifest pointer.

### Related issue

Fixes #3572

### Testing

- `cargo test -p buzz-relay api::git::` (128 passed, 5 ignored)
- `just ci`
- live E2E roundtrip against a release relay with PostgreSQL, Redis, and
MinIO:
  - created a repository through signed Nostr events
  - verified authorized pushes and rejected unauthorized clone/push
  - pushed a surviving `master` branch
  - deleted the active `main` branch over authenticated Smart HTTP
- freshly cloned the repository and verified `master` became HEAD,
`origin/main` was absent, and repository content remained intact

Signed-off-by: Tal Weiss <major.tal@gmail.com>
This commit is contained in:
Tal Weiss
2026-08-02 13:29:41 -04:00
committed by GitHub
parent b7bb15122e
commit fc598f5f8d
2 changed files with 165 additions and 7 deletions
@@ -1370,6 +1370,15 @@ mod tests {
);
}
#[test]
fn published_head_moves_to_surviving_branch_after_current_branch_deletion() {
let refs = BTreeMap::from([("refs/heads/master".to_string(), "1".repeat(40))]);
assert_eq!(
resolve_published_head(&refs, "refs/heads/main".to_string(), "refs/heads/main"),
"refs/heads/master"
);
}
#[test]
fn digest_from_key_strips_prefix() {
let k = format!("manifests/{}", "a".repeat(64));
+156 -7
View File
@@ -1064,7 +1064,7 @@ pub async fn receive_pack(
state.config.bind_addr.port()
);
let hooks_dir = repo.path().join("hooks").display().to_string();
let hook_env = vec![
let mut hook_env = vec![
("BUZZ_HOOK_URL", hook_url),
(
"BUZZ_HOOK_SECRET",
@@ -1077,13 +1077,8 @@ pub async fn receive_pack(
auth.tenant.community().as_uuid().to_string(),
),
("BUZZ_PUSHER_PUBKEY", pusher_hex.clone()),
// Override any repo-local core.hooksPath setting; defense in
// depth even though the hydrated workspace has no inherited
// config.
("GIT_CONFIG_COUNT", "1".to_string()),
("GIT_CONFIG_KEY_0", "core.hooksPath".to_string()),
("GIT_CONFIG_VALUE_0", hooks_dir),
];
hook_env.extend(receive_pack_git_config(hooks_dir));
// Run receive-pack against the tempdir. Returns the *owned* subprocess
// output (PackOutput) — crucially NOT a Response, so the post-push
@@ -1111,6 +1106,23 @@ pub async fn receive_pack(
Ok(finalize_push(&state, ctx).await)
}
/// Per-process git configuration for the hydrated receive-pack workspace.
fn receive_pack_git_config(hooks_dir: String) -> Vec<(&'static str, String)> {
vec![
// Override any repo-local core.hooksPath setting; defense in depth
// even though the hydrated workspace has no inherited config.
("GIT_CONFIG_COUNT", "2".to_string()),
("GIT_CONFIG_KEY_0", "core.hooksPath".to_string()),
("GIT_CONFIG_VALUE_0", hooks_dir),
// A bare repository rejects deletion of its symbolic HEAD branch by
// default. Hydrated repositories are ephemeral, and cas_publish
// selects a surviving branch for the next manifest HEAD, so allow
// receive-pack to apply the deletion before that selection runs.
("GIT_CONFIG_KEY_1", "receive.denyDeleteCurrent".to_string()),
("GIT_CONFIG_VALUE_1", "ignore".to_string()),
]
}
/// Buffered output of a `git --stateless-rpc` subprocess.
///
/// The handler holds this as an owned value between subprocess completion
@@ -1921,11 +1933,148 @@ mod track_c_tests {
use buzz_core::CommunityId;
use nostr::{EventBuilder, Keys, Kind, Tag};
use std::collections::BTreeMap;
use std::io::Write;
use std::process::Output;
fn oid_sha1() -> String {
"cb09a769da1c01f458fa6959d4e8eded38fac8d3".to_string()
}
fn run_test_git(cwd: &Path, args: &[&str], extra_env: &[(&str, String)]) -> Output {
let mut cmd = std::process::Command::new("git");
cmd.current_dir(cwd)
.args(args)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("HOME", "/dev/null");
for (key, value) in extra_env {
cmd.env(key, value);
}
cmd.output().expect("run git")
}
fn run_test_receive_pack(repo: &Path, request: &[u8], extra_env: &[(&str, String)]) -> Output {
let mut cmd = std::process::Command::new("git");
cmd.arg("receive-pack")
.arg("--stateless-rpc")
.arg(repo)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("HOME", "/dev/null");
for (key, value) in extra_env {
cmd.env(key, value);
}
let mut child = cmd.spawn().expect("spawn receive-pack");
child
.stdin
.take()
.expect("receive-pack stdin")
.write_all(request)
.expect("write receive-pack request");
child.wait_with_output().expect("wait for receive-pack")
}
fn assert_git_success(output: Output, operation: &str) {
assert!(
output.status.success(),
"{operation} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn receive_pack_config_allows_deleting_current_branch() {
let root = tempfile::TempDir::new().expect("tempdir");
let remote = root.path().join("remote.git");
let source = root.path().join("source");
let remote_arg = remote.to_str().expect("utf-8 remote path");
let source_arg = source.to_str().expect("utf-8 source path");
assert_git_success(
run_test_git(
root.path(),
&["init", "--bare", "--initial-branch=main", remote_arg],
&[],
),
"initialize bare remote",
);
assert_git_success(
run_test_git(
root.path(),
&["init", "--initial-branch=main", source_arg],
&[],
),
"initialize source repository",
);
assert_git_success(
run_test_git(source.as_path(), &["config", "user.name", "Buzz Test"], &[]),
"configure user name",
);
assert_git_success(
run_test_git(
source.as_path(),
&["config", "user.email", "buzz-test@example.com"],
&[],
),
"configure user email",
);
std::fs::write(source.join("README.md"), "test\n").expect("write fixture");
assert_git_success(
run_test_git(source.as_path(), &["add", "README.md"], &[]),
"stage fixture",
);
assert_git_success(
run_test_git(source.as_path(), &["commit", "-m", "fixture"], &[]),
"commit fixture",
);
assert_git_success(
run_test_git(
source.as_path(),
&["push", remote_arg, "main:main", "main:master"],
&[],
),
"seed main and master",
);
let oid_output = run_test_git(remote.as_path(), &["rev-parse", "refs/heads/main"], &[]);
assert!(oid_output.status.success());
let old_oid = String::from_utf8(oid_output.stdout)
.expect("utf-8 oid")
.trim()
.to_string();
let command = format!(
"{old_oid} {} refs/heads/main\0report-status\n",
"0".repeat(40)
);
let mut request = format!("{:04x}", command.len() + 4).into_bytes();
request.extend_from_slice(command.as_bytes());
request.extend_from_slice(b"0000");
let git_config = receive_pack_git_config(remote.join("hooks").display().to_string());
let output = run_test_receive_pack(remote.as_path(), &request, &git_config);
assert!(
output.status.success(),
"receive-pack failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
!receive_pack_report_rejected(&output.stdout),
"receive-pack rejected the deletion: {}",
String::from_utf8_lossy(&output.stdout)
);
assert!(!remote.join("refs/heads/main").exists());
assert!(remote.join("refs/heads/master").exists());
}
/// A gzip-encoded request body is transparently inflated before it
/// reaches the git subprocess. Git's smart-HTTP client gzips the
/// upload-pack/receive-pack request body past a size threshold (fires