diff --git a/AGENTS.md b/AGENTS.md index 8fc7d20b..496e745a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -487,6 +487,19 @@ full cold-read/resource acceptance gates listed in `docs/spec/README.md`. and candidate external-boundary proof remain separate obligations; local loose objects and retired download membership cannot justify retirement. See the cost and remaining-evidence rows in the linked docs. +- **D50 (2026-09-30): HEAD is WAL state; creation records it, pushes heal it, admins move it.** HEAD's + target lives where refs live (checkpoint `RefSnapshot.head_target` + symbolic `HEAD` updates in log + transactions); no record means `walgit_git::IMPLICIT_HEAD` (`refs/heads/main`), a format constant. New + repositories point at `git.default_branch` (host) or `PUT /{o}/{r}?default_branch=` (short name, validated): + the implicit target records nothing (one Create, as before); any other is the first entry, its log slot + claimed before and listed by the manifest Create (2 requests, one commit). A publish that creates + `refs/heads/*` while HEAD resolves to nothing before and after it carries the retarget in its own entry — + `default_branch` if created, else the first created branch by name; a transaction naming HEAD keeps it; a + delete of HEAD's branch leaves it dangling until such a publish — computed on each attempt's CAS basis, no + extra request. `PUT /{o}/{r}/api/head` (admin) retargets through the same publisher, the branch re-checked + on every attempt, idempotent. Receive-pack refuses `HEAD` as a command name (`funny refname`), so policy + sees every ref move; HEAD retargets emit no events (D32). + ## 5. Working rules - **No backwards compatibility (pre-1.0, banner at top):** change the shape and delete the old one in the same diff --git a/crates/walgit-cli/src/lib.rs b/crates/walgit-cli/src/lib.rs index c7b49b35..5bed13d6 100644 --- a/crates/walgit-cli/src/lib.rs +++ b/crates/walgit-cli/src/lib.rs @@ -228,6 +228,9 @@ enum RepoAction { /// `sha1` or `sha256`. #[arg(long, default_value = "sha1")] object_format: String, + /// HEAD's branch (short name); default `git.default_branch`. + #[arg(long)] + default_branch: Option, }, /// List all repositories. List, diff --git a/crates/walgit-cli/src/repo.rs b/crates/walgit-cli/src/repo.rs index 1d17fb87..81b99a24 100644 --- a/crates/walgit-cli/src/repo.rs +++ b/crates/walgit-cli/src/repo.rs @@ -25,6 +25,7 @@ pub async fn run(action: RepoAction, cfg: &Arc) -> Result<()> { RepoAction::Create { repo, object_format, + default_branch, } => { let (owner, name) = parse_repo_id(&repo)?; let id = walgit_git::RepoId::new(owner, name)?; @@ -33,10 +34,14 @@ pub async fn run(action: RepoAction, cfg: &Arc) -> Result<()> { "sha256" => ObjectFormat::Sha256, other => bail!("unknown object format `{other}` (expected sha1 or sha256)"), }; - let handle = registry.create(&id, format).await?; + let branch = default_branch.unwrap_or_else(|| cfg.git.default_branch.clone()); + let handle = registry + .create_with_default_branch(&id, format, &branch) + .await?; let manifest = handle.manifest(); println_kv("repo", &id); println_kv("object_format", &manifest.object_format); + println_kv("default_branch", &branch); println_kv("head_seq", manifest.head_seq); info!(repo = %id, "repo created"); } diff --git a/crates/walgit-config/src/lib.rs b/crates/walgit-config/src/lib.rs index e3cdaf15..2c5407b4 100644 --- a/crates/walgit-config/src/lib.rs +++ b/crates/walgit-config/src/lib.rs @@ -572,6 +572,13 @@ pub struct GitConfig { pub allow_any_sha1_in_want: bool, /// Default object format for new repos. pub object_format: ObjectFormat, + /// Branch HEAD names in a repository created on this host (`PUT /{o}/{r}`, + /// `walgit repo create`, auto-create on push), unless the request names + /// one. A short name (`main`, not `refs/heads/main`). The bucket records + /// the choice, so hosts with different values never disagree about an + /// existing repository; it is also the preferred target when a push heals + /// a dangling HEAD (D50). + pub default_branch: String, /// Maintain a split commit-graph chain per local repo: tier-2 packs that /// publish a commit-graph layer install it as the chain base; every other /// installed pack is folded in incrementally (`commit-graph write --split`). @@ -929,6 +936,7 @@ impl Default for GitConfig { allow_filter: true, allow_any_sha1_in_want: false, object_format: ObjectFormat::Sha1, + default_branch: "main".into(), commit_graph: true, commit_graph_changed_paths: false, history_pack: true, @@ -1079,6 +1087,8 @@ impl Config { pub fn validate(&self) -> Result<()> { self.refs.validate()?; self.packs.validate()?; + refs::branch_ref(&self.git.default_branch) + .map_err(|e| anyhow::anyhow!("git.default_branch: {e}"))?; anyhow::ensure!( self.packfile_uri.uri_min_bytes.as_u64() > 0, "packfile_uri.uri_min_bytes must be positive" @@ -1438,6 +1448,21 @@ mod tests { assert_eq!(c.placement.serve_exclude, vec!["acme/monorepo"]); } + /// `git.default_branch` is a short, Git-valid branch name; a bad one fails at startup. + #[test] + fn validate_refuses_an_invalid_default_branch() { + let mut c = Config::default(); + c.store.bucket = "b".into(); + assert_eq!(c.git.default_branch, "main"); + c.git.default_branch = "trunk".into(); + c.validate().unwrap(); + for bad in ["refs/heads/main", "a..b", ""] { + c.git.default_branch = bad.into(); + let err = c.validate().unwrap_err().to_string(); + assert!(err.contains("git.default_branch"), "{bad:?}: {err}"); + } + } + /// You cannot maintain what you refuse to serve. #[test] fn validate_refuses_maintaining_a_repo_the_host_does_not_serve() { diff --git a/crates/walgit-config/src/refs.rs b/crates/walgit-config/src/refs.rs index 9a2c7241..c7e41856 100644 --- a/crates/walgit-config/src/refs.rs +++ b/crates/walgit-config/src/refs.rs @@ -76,6 +76,25 @@ fn valid_ref(name: &str) -> bool { .all(|p| !p.is_empty() && !p.starts_with('.') && !p.ends_with(".lock")) } +/// `refs/heads/` for a short branch name (`main`, `release/1.x`), refused +/// unless Git would accept it as a branch: HEAD's target is always a branch, and +/// a full ref name here (`refs/heads/main`) would silently become +/// `refs/heads/refs/heads/main`. One rule for `git.default_branch`, the create +/// override and the HEAD admin route. +pub fn branch_ref(name: &str) -> Result { + ensure!( + !name.is_empty() && name != "HEAD" && !name.starts_with('-'), + "invalid branch name {name:?}" + ); + ensure!( + !name.starts_with("refs/"), + "a branch name is short (\"main\"), not a ref (got {name:?})" + ); + let full = format!("refs/heads/{name}"); + ensure!(valid_ref(&full), "invalid branch name {name:?}"); + Ok(full) +} + fn validate_selectors(selectors: &[String]) -> Result<()> { ensure!(selectors.len() <= 256, "at most 256 ref selectors per list"); for selector in selectors { @@ -288,6 +307,27 @@ mod tests { }; assert!(cfg.validate().is_err()); } + #[test] + fn branch_names_are_short_and_git_valid() { + assert_eq!(branch_ref("main").unwrap(), "refs/heads/main"); + assert_eq!(branch_ref("release/1.x").unwrap(), "refs/heads/release/1.x"); + for bad in [ + "", + "HEAD", + "-x", + "refs/heads/main", + "a..b", + "a b", + "a/", + "a.lock", + ".hidden", + "a//b", + "x@{1}", + "a\nb", + ] { + assert!(branch_ref(bad).is_err(), "{bad:?}"); + } + } } #[cfg(test)] diff --git a/crates/walgit-git/src/lib.rs b/crates/walgit-git/src/lib.rs index d1ce0ce8..1fcc53ed 100644 --- a/crates/walgit-git/src/lib.rs +++ b/crates/walgit-git/src/lib.rs @@ -68,6 +68,13 @@ fn ge(e: E) -> GitError { GitError::Gix(Box::new(e)) } +/// HEAD's target in a repository whose WAL records none: every repository +/// created before targets were recorded, and every one created with this +/// target since (creation records only a different one, D50). A format +/// constant, never configuration — changing it would retarget those +/// repositories' HEAD on every reader. +pub const IMPLICIT_HEAD: &str = "refs/heads/main"; + /// Reject ref names that would inject `git update-ref --stdin` commands or /// poison packed-refs (newlines, NULs, git-illegal bytes). #[expect( @@ -624,9 +631,11 @@ impl LocalRepo { stderr: String::from_utf8_lossy(&out.stderr).into_owned(), }); } - // Deterministic HEAD -> refs/heads/main regardless of the host's - // init.defaultBranch (git init writes master/main depending on config). - std::fs::write(path.join("HEAD"), "ref: refs/heads/main\n").map_err(GitError::Io)?; + // Deterministic HEAD regardless of the host's init.defaultBranch (git + // init writes master/main depending on config): the WAL's meaning of a + // HEAD it has no record of. A recorded target replaces it on replay. + std::fs::write(path.join("HEAD"), format!("ref: {IMPLICIT_HEAD}\n")) + .map_err(GitError::Io)?; // Permissive upload-pack config so filter / any-sha1 fetches work. // `pack.writeReverseIndex`: every pack this repo writes (index-pack on // ingest, repack in compaction) gets a `.rev` — git < 2.41 defaults it diff --git a/crates/walgit-server/src/admin.rs b/crates/walgit-server/src/admin.rs index d9f2863b..6e56cfb1 100644 --- a/crates/walgit-server/src/admin.rs +++ b/crates/walgit-server/src/admin.rs @@ -1,5 +1,6 @@ //! Admin endpoints: `PUT /{owner}/{repo}` (create), `DELETE /{owner}/{repo}` -//! (delete manifest + prefix objects), `GET /` (list repos, text/plain). +//! (delete manifest + prefix objects), `PUT /{o}/{r}/api/head` (move HEAD), +//! `GET /` (list repos, text/plain). use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; @@ -9,7 +10,9 @@ use crate::AppState; use crate::error::ApiError; use crate::repo::RepoRoute; -/// `PUT /{owner}/{repo}` — create repo. 201 on new, 409 if it exists. +/// `PUT /{owner}/{repo}[?object_format=sha1|sha256][&default_branch=]` — create repo. +/// 201 on new, 409 if it exists, 400 for an unsupported format or an invalid branch name. +/// `default_branch` (a short name) overrides `git.default_branch` as HEAD's target (D50). pub async fn create( st: &AppState, route: &RepoRoute, @@ -17,10 +20,17 @@ pub async fn create( query: &str, ) -> Result { let _principal = st.auth.require_write(headers).await.map_err(auth_err)?; - let format = match query - .split('&') - .find_map(|part| part.strip_prefix("object_format=")) - { + let param = |key: &str| { + query + .split('&') + .filter_map(|kv| kv.split_once('=')) + .find(|(k, _)| *k == key) + .map(|(_, v)| crate::settings::percent_decode(v)) + }; + let branch = param("default_branch").unwrap_or_else(|| st.cfg.git.default_branch.clone()); + walgit_config::refs::branch_ref(&branch) + .map_err(|e| ApiError::BadRequest(format!("default_branch: {e}")))?; + let format = match param("object_format").as_deref() { Some("sha256") => ObjectFormat::Sha256, Some("sha1") => ObjectFormat::Sha1, Some(other) => { @@ -30,7 +40,11 @@ pub async fn create( } None => ObjectFormat::from(st.cfg.git.object_format), }; - match st.registry.create(&route.id, format).await { + match st + .registry + .create_with_default_branch(&route.id, format, &branch) + .await + { Ok(_h) => Ok((StatusCode::CREATED, "created").into_response()), Err(walgit_wal::WalError::AlreadyExists) => { Ok((StatusCode::CONFLICT, "already exists").into_response()) @@ -50,6 +64,52 @@ pub async fn delete( Ok((StatusCode::NO_CONTENT, "").into_response()) } +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct HeadRequest { + /// Short branch name (`main`), as `git.default_branch`. + branch: String, +} + +/// `PUT /{o}/{r}/api[-browser]/head` `{"branch": ""}` — point HEAD at an existing +/// branch (admin). One WAL entry through the push publisher (`RepoHandle::publish_head`): +/// CAS-checked like any ref update, the branch's existence re-checked on every attempt. +/// 200 `{head: {name, sha}, seq}` (`seq` 0 = HEAD was already there, nothing written); +/// 400 invalid name/body, 404 unknown repository, 409 the branch does not exist. +pub async fn set_head( + st: &AppState, + route: &RepoRoute, + headers: &HeaderMap, + body: axum::body::Body, +) -> Result { + let principal = st.auth.require_admin(headers).await.map_err(auth_err)?; + let bytes = crate::collect_body(body).await?; + let req: HeadRequest = serde_json::from_slice(&bytes) + .map_err(|e| ApiError::BadRequest(format!("expected {{\"branch\": \"\"}}: {e}")))?; + let target = walgit_config::refs::branch_ref(&req.branch) + .map_err(|e| ApiError::BadRequest(format!("branch: {e}")))?; + let handle = st.registry.open(&route.id).await.map_err(wal_err)?; + let meta = std::collections::HashMap::from([("principal".to_string(), principal.name)]); + let result = handle.publish_head(&target, meta).await.map_err(wal_err)?; + if let Some((_, Err(e))) = result.per_ref.iter().find(|(name, _)| name == "HEAD") { + return Err(ApiError::Conflict(e.to_string())); + } + let sha = handle + .local() + .ref_view() + .map_err(|e| ApiError::Internal(format!("refs: {e}")))? + .get(&target) + .unwrap_or_default(); + Ok(( + StatusCode::OK, + axum::Json(serde_json::json!({ + "head": {"name": req.branch, "sha": sha}, + "seq": result.seq, + })), + ) + .into_response()) +} + /// `GET /` — list repos as text/plain, one `owner/name` per line. pub async fn list_repos(st: &AppState, headers: &HeaderMap) -> Result { let _ = st.auth.require_read(headers).await.map_err(auth_err)?; @@ -84,6 +144,7 @@ fn auth_err(e: crate::auth::AuthError) -> ApiError { fn wal_err(e: walgit_wal::WalError) -> ApiError { match &e { walgit_wal::WalError::NotFound => ApiError::NotFound(e.to_string()), + walgit_wal::WalError::Invalid(why) => ApiError::BadRequest(why.clone()), _ => ApiError::Internal(format!("wal: {e}")), } } diff --git a/crates/walgit-server/src/lib.rs b/crates/walgit-server/src/lib.rs index 5f6c6154..64b6040b 100644 --- a/crates/walgit-server/src/lib.rs +++ b/crates/walgit-server/src/lib.rs @@ -397,6 +397,9 @@ pub(crate) async fn dispatch_route( } (&Method::PUT, "") => admin::create(st, route, &headers, &query).await, (&Method::DELETE, "") => admin::delete(st, route, &headers).await, + (&Method::PUT, "head") => { + admin::set_head(st, route, &headers, body.take().unwrap()).await + } // Admin routes reach here only through `/{o}/{r}/api[-browser]/…` (web::v1). (&Method::GET, "policy") => policy::http_get(st, route, &headers).await, (&Method::PUT, "policy") => { diff --git a/crates/walgit-server/src/policy.rs b/crates/walgit-server/src/policy.rs index 33a3dd74..4b8b1d45 100644 --- a/crates/walgit-server/src/policy.rs +++ b/crates/walgit-server/src/policy.rs @@ -535,6 +535,14 @@ fn deny_reason( u: &RefUpdate, is_force: &impl Fn(&RefUpdate) -> bool, ) -> Option { + // A pushed command names a ref under `refs/`, never `HEAD` (git's own receive-pack refuses + // it as a "funny refname"). `HEAD ` would move HEAD's branch through the symref under a + // name no rule matches — around every `protect` on `refs/heads/*`. HEAD's target moves only + // by the WAL's heal rule, the admin route (D50) and import; all are symbolic updates, never + // pushed. + if u.new_symbolic_target.is_empty() && !u.name.starts_with("refs/") { + return Some("funny refname".into()); + } let op = classify(&u.old_oid, &u.new_oid); if op == RefOp::NoOp { return None; @@ -871,6 +879,31 @@ mod tests { assert!(ev.per_ref[0].1.is_ok()); } + /// `HEAD ` would update main through the symref under a name `lock-main` never sees: + /// refused under any policy, including none. Symbolic HEAD updates (heal, admin route, + /// imports — never pushed) are not ref moves and pass. + #[test] + fn a_pushed_head_is_a_funny_refname_under_any_policy() { + for p in [RepoPolicy::empty(), lock_main()] { + let t = txn(vec![upd("HEAD", "aaa", "bbb")], false); + let ev = evaluate(&p, "Alice@example.com", &t, |_| false); + assert_eq!(ev.per_ref[0].1.as_ref().unwrap_err(), "funny refname"); + assert!(!ev.any_allowed()); + } + let retarget = RefUpdate { + name: "HEAD".into(), + new_symbolic_target: "refs/heads/dev".into(), + ..Default::default() + }; + let ev = evaluate( + &lock_main(), + "bob@example.com", + &txn(vec![retarget], false), + |_| false, + ); + assert!(ev.per_ref[0].1.is_ok()); + } + #[test] fn atomic_denies_all() { let p = lock_main(); diff --git a/crates/walgit-server/src/settings.rs b/crates/walgit-server/src/settings.rs index 0c6da1e9..2a90af02 100644 --- a/crates/walgit-server/src/settings.rs +++ b/crates/walgit-server/src/settings.rs @@ -176,7 +176,7 @@ async fn publish( } } -fn percent_decode(v: &str) -> String { +pub(crate) fn percent_decode(v: &str) -> String { let mut out = Vec::with_capacity(v.len()); let b = v.as_bytes(); let mut i = 0; diff --git a/crates/walgit-server/src/web/v1.rs b/crates/walgit-server/src/web/v1.rs index d297dec3..31935227 100644 --- a/crates/walgit-server/src/web/v1.rs +++ b/crates/walgit-server/src/web/v1.rs @@ -20,7 +20,7 @@ use axum::{ http::{HeaderMap, HeaderValue, Method, StatusCode, header}, middleware::Next, response::{Html, IntoResponse, Response}, - routing::{get, post}, + routing::{get, post, put}, }; use serde::Serialize; @@ -48,10 +48,11 @@ pub fn router(state: Arc) -> Router { &format!("{API_V1}/owners/{{owner}}/repos"), get(crate::web::api::owner_repos), ); - // Repo admin under both lanes: summary/create/delete, policy, settings. + // Repo admin under both lanes: summary/create/delete, HEAD, policy, settings. for base in crate::web::api::REPO_API_BASES { r = r .route(base, get(repo_summary).put(repo_admin).delete(repo_admin)) + .route(&format!("{base}/head"), put(repo_admin)) .route( &format!("{base}/policy"), get(repo_admin).put(repo_admin).delete(repo_admin), @@ -375,7 +376,7 @@ async fn repo_admin_sub( repo_admin(State(st), Path((owner, name)), req).await } -/// `PUT|DELETE /{o}/{r}/api[-browser]` and `GET|PUT|DELETE …/policy|settings`: the +/// `PUT|DELETE /{o}/{r}/api[-browser]`, `PUT …/head` and `GET|PUT|DELETE …/policy|settings`: the /// same handlers as the repo root (`crate::dispatch_route`). async fn repo_admin( State(st): State>, diff --git a/crates/walgit-server/tests/api_v1.rs b/crates/walgit-server/tests/api_v1.rs index 5708254f..8a70d484 100644 --- a/crates/walgit-server/tests/api_v1.rs +++ b/crates/walgit-server/tests/api_v1.rs @@ -631,3 +631,143 @@ async fn policy_and_settings_writes_require_admin() -> TestResult { assert_eq!(st, 200, "{text}"); Ok(()) } + +/// D50: a repository's HEAD — named at creation (`?default_branch=`), healed by a push that +/// publishes branches while HEAD resolves to nothing, moved by `PUT …/api/head` (admin only, +/// existing branch, idempotent, both lanes). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn default_branch_is_chosen_at_creation_healed_by_push_and_moved_by_admin() -> TestResult { + let server = Server::start_with_tweak(|c| { + c.server.auth.mode = walgit_config::AuthMode::Token; + c.server.auth.anonymous_read = false; + c.server.auto_create_on_push = true; + c.server.auth.tokens = vec![ + walgit_config::StaticToken { + principal: "writer".into(), + token: "writer-token".into(), + token_env: None, + write: true, + admin: false, + }, + walgit_config::StaticToken { + principal: "admin".into(), + token: "admin-token".into(), + token_env: None, + write: true, + admin: true, + }, + ]; + }) + .await?; + let writer = [("Authorization", "Bearer writer-token")]; + let admin = [("Authorization", "Bearer admin-token")]; + let summary_head = |path: &'static str| { + let server = &server; + async move { + let (st, text, _) = req(server, reqwest::Method::GET, path, &admin).await?; + anyhow::ensure!(st == 200, "GET {path} -> {st}: {text}"); + let v: Value = serde_json::from_str(&text)?; + Ok::(v["head"].clone()) + } + }; + + // Creation: the override is validated and recorded before any push. + let (st, text, _) = req( + &server, + reqwest::Method::PUT, + "/h/named/api?default_branch=a..b", + &writer, + ) + .await?; + assert_eq!(st, 400, "{text}"); + let (st, text, _) = req( + &server, + reqwest::Method::PUT, + "/h/named/api?default_branch=release%2Fnext", + &writer, + ) + .await?; + assert_eq!(st, 201, "{text}"); + let log = server.read_log("h", "named").await?; + assert_eq!(log.len(), 1); + assert_eq!( + log[0].txn.as_ref().unwrap().updates[0].new_symbolic_target, + "refs/heads/release/next" + ); + assert_eq!(summary_head("/h/named/api").await?, Value::Null, "unborn"); + + // A first push (auto-created repository) without `main`: HEAD heals to its first branch. + let dir = tempfile::tempdir()?; + let work = dir.path(); + git_in(work, &["init", "-q", "-b", "zeta"])?; + git_in(work, &["config", "user.email", "t@t"])?; + git_in(work, &["config", "user.name", "Tester"])?; + std::fs::write(work.join("f"), "x\n")?; + git_in(work, &["add", "."])?; + git_in(work, &["commit", "-q", "-m", "one"])?; + git_in(work, &["branch", "dev"])?; + let auth = "http.extraHeader=Authorization: Bearer admin-token"; + let url = server.repo_url("h", "healed"); + git_in(work, &["-c", auth, "push", "-q", &url, "zeta", "dev"])?; + assert_eq!(summary_head("/h/healed/api").await?["name"], "dev"); + let symref = git_in(work, &["-c", auth, "ls-remote", "--symref", &url, "HEAD"])?; + assert!(symref.contains("ref: refs/heads/dev\tHEAD"), "{symref}"); + + // The admin route: admin only, a valid short name, an existing branch. + let put_head = + |lane: &'static str, who: &'static [(&'static str, &'static str)], body: &'static str| { + let server = &server; + async move { + req_body( + server, + reqwest::Method::PUT, + &format!("/h/healed/{lane}/head"), + who, + body, + ) + .await + } + }; + // Promoted constants (`'static`, as the closure needs), not items after statements. + let writer_hdr: &'static [(&str, &str)] = &[("Authorization", "Bearer writer-token")]; + let admin_hdr: &'static [(&str, &str)] = &[("Authorization", "Bearer admin-token")]; + assert_eq!( + put_head("api", writer_hdr, r#"{"branch":"zeta"}"#).await?.0, + 403 + ); + for bad in [ + r#"{"branch":"refs/heads/zeta"}"#, + r#"{"branch":""}"#, + r#"{"name":"zeta"}"#, + "zeta", + ] { + let (st, text) = put_head("api", admin_hdr, bad).await?; + assert_eq!(st, 400, "{bad}: {text}"); + } + let (st, text) = put_head("api", admin_hdr, r#"{"branch":"nope"}"#).await?; + assert_eq!(st, 409, "{text}"); + let (st, text) = put_head("api", admin_hdr, r#"{"branch":"zeta"}"#).await?; + assert_eq!(st, 200, "{text}"); + let moved: Value = serde_json::from_str(&text)?; + assert_eq!(moved["head"]["name"], "zeta"); + assert_eq!(moved["head"]["sha"].as_str().map(str::len), Some(40)); + assert!(moved["seq"].as_u64().unwrap() > 0); + let (st, text) = put_head("api-browser", admin_hdr, r#"{"branch":"zeta"}"#).await?; + assert_eq!(st, 200, "{text}"); + assert_eq!( + serde_json::from_str::(&text)?["seq"], + 0, + "idempotent" + ); + assert_eq!(summary_head("/h/healed/api").await?["name"], "zeta"); + let (st, _) = req_body( + &server, + reqwest::Method::PUT, + "/h/missing/api/head", + &admin, + r#"{"branch":"zeta"}"#, + ) + .await?; + assert_eq!(st, 404); + Ok(()) +} diff --git a/crates/walgit-server/tests/sim.rs b/crates/walgit-server/tests/sim.rs index 7998424c..543080b3 100644 --- a/crates/walgit-server/tests/sim.rs +++ b/crates/walgit-server/tests/sim.rs @@ -564,7 +564,13 @@ async fn check_truth(c: &Cluster, pushers: &[Pusher]) -> Result<()> { // (errored-but-committed) push moved it further along the same chain. for e in &log { if let Some(t) = &e.txn { - for u in &t.updates { + // A symbolic update (HEAD's target: the heal a push into a repository without + // `main` carries, D52) names a ref, not an object: nothing to fold or fetch. + for u in t + .updates + .iter() + .filter(|u| u.new_symbolic_target.is_empty()) + { folded.insert(u.name.clone(), u.new_oid.clone()); } } diff --git a/crates/walgit-wal/src/handle.rs b/crates/walgit-wal/src/handle.rs index 85b262bc..fdf42e4e 100644 --- a/crates/walgit-wal/src/handle.rs +++ b/crates/walgit-wal/src/handle.rs @@ -996,6 +996,7 @@ impl RepoHandle { meta, false, Some(walgit_proto::time::from_system(at)), + false, ) .await } @@ -1007,7 +1008,8 @@ impl RepoHandle { meta: HashMap, synced: bool, ) -> Result { - self.enqueue_publish_at(pack, txn, meta, synced, None).await + self.enqueue_publish_at(pack, txn, meta, synced, None, false) + .await } async fn enqueue_publish_at( @@ -1017,6 +1019,7 @@ impl RepoHandle { meta: HashMap, synced: bool, created_at: Option, + head_must_exist: bool, ) -> Result { if pack.is_some() && txn.updates.is_empty() { return Err(WalError::Invalid( @@ -1032,6 +1035,7 @@ impl RepoHandle { meta, synced, created_at, + head_must_exist, response: tx, }; @@ -1055,6 +1059,31 @@ impl RepoHandle { self.publish_push(None, txn, meta).await } + /// Point HEAD at `target` (a full `refs/heads/…` name), through the same publisher, log + /// entry and manifest CAS as a push (D50). The target must exist in the ref state the + /// entry commits on — checked on every CAS attempt, so a concurrent delete wins or loses + /// cleanly — else the result's `HEAD` entry is rejected. Idempotent: HEAD already there + /// is a no-op (`seq` 0, no entry). Refs-level: no pack is downloaded to move a symref. + pub async fn publish_head( + &self, + target: &str, + meta: HashMap, + ) -> Result { + let update = walgit_proto::v1::RefUpdate { + name: "HEAD".into(), + new_symbolic_target: target.to_string(), + ..Default::default() + }; + walgit_git::validate_ref_update(&update)?; + drop(self.sync_refs().await?); + let txn = walgit_proto::v1::RefTransaction { + updates: vec![update], + ..Default::default() + }; + self.enqueue_publish_at(None, txn, meta, true, None, true) + .await + } + /// Publish a compact entry. pub async fn publish_compact( &self, diff --git a/crates/walgit-wal/src/publish.rs b/crates/walgit-wal/src/publish.rs index 07a23bda..5a378708 100644 --- a/crates/walgit-wal/src/publish.rs +++ b/crates/walgit-wal/src/publish.rs @@ -66,6 +66,10 @@ pub(crate) struct PublishRequest { /// Explicit entry time (history replay); None = now. Validated monotonic /// (>= the head entry's `created_at`) before the batch is written. pub(crate) created_at: Option, + /// A symbolic HEAD update in `txn` is rejected unless its target exists in + /// the ref state this attempt commits on (the admin route; checked on every + /// CAS attempt, so a concurrent delete of the target cannot slip past it). + pub(crate) head_must_exist: bool, pub(crate) response: oneshot::Sender>, } @@ -403,7 +407,10 @@ pub(crate) fn is_null_oid(hex: &str) -> bool { pub(crate) fn apply_txn_to_map(txn: &RefTransaction, refs: &mut walgit_git::RefView) { for u in &txn.updates { if !u.new_symbolic_target.is_empty() { - refs.set(&u.name, u.new_symbolic_target.clone()); + // Only HEAD is symbolic (`validate_ref_update`). Its target lives beside the ref map, + // where `head_target()`/`head_oid()` read it: storing it *in* the map made a later + // request of the same batch see the old target (and `get("HEAD")` a ref name). + refs.set_head_target(u.new_symbolic_target.clone()); } else if is_null_oid(&u.new_oid) { refs.remove(&u.name); } else { @@ -412,6 +419,53 @@ pub(crate) fn apply_txn_to_map(txn: &RefTransaction, refs: &mut walgit_git::RefV } } +/// D50: the HEAD retarget a publish carries when HEAD's target was absent before it and +/// still is after it, and the publish creates branches — a repository whose first pushes +/// named no `main` would otherwise advertise a HEAD that resolves to nothing (clones warn +/// "remote HEAD refers to nonexistent ref", the UI shows an empty repository) while it has +/// branches. The target is `refs/heads/` when this publish creates it, +/// else the lexicographically first `refs/heads/*` it creates — a rule of the ref names +/// alone, so it does not depend on the order a client lists its commands in. A transaction +/// that names HEAD itself keeps what it says (import, the admin route). A HEAD whose branch +/// is deleted is left dangling by that delete, as a delete moves nothing else; the next +/// publish that creates a branch heals it. +/// +/// The retarget is appended to the publishing entry's own transaction, computed against the +/// attempt's CAS basis: it commits in the same log entry and manifest CAS as the refs that +/// justify it, is recomputed on every retry, and costs no request. Policy has nothing to add: +/// HEAD moves only onto a branch this very transaction was allowed to create. +fn heal_head( + txn: &RefTransaction, + after: &walgit_git::RefView, + default_branch: &str, +) -> Option { + if after.head_oid().is_some() || txn.updates.iter().any(|u| u.name == "HEAD") { + return None; + } + let preferred = format!("refs/heads/{default_branch}"); + let created = || { + txn.updates + .iter() + .filter(|u| { + u.new_symbolic_target.is_empty() + && u.name.starts_with("refs/heads/") + && is_null_oid(&u.old_oid) + && !is_null_oid(&u.new_oid) + }) + .map(|u| u.name.as_str()) + }; + let target = if created().any(|name| name == preferred) { + preferred.as_str() + } else { + created().min()? + }; + Some(walgit_proto::v1::RefUpdate { + name: "HEAD".into(), + new_symbolic_target: target.to_string(), + ..Default::default() + }) +} + #[allow(clippy::too_many_arguments)] fn build_log_entry_at( seq: u64, @@ -498,6 +552,9 @@ pub(crate) async fn publisher_task( struct Verified { per_ref: Vec<(String, Result<(), RefError>)>, valid: bool, + /// The request's transaction plus a healed HEAD ([`heal_head`]), when one applies: what + /// the entry records and the local copy applies. `None` = the request's own `txn`. + healed: Option, } /// Process a batch of publish requests through the full CAS loop. @@ -549,6 +606,21 @@ async fn process_batch(handle: &RepoHandle, batch: Vec) -> Resul let mut floor: Option = *handle.last_entry_time.lock(); for req in &batch { let mut per_ref = verify_txn(&req.txn, &working_refs); + // HEAD as the batch left it so far: the heal rule needs "dangling before". + let head_dangling = working_refs.head_oid().is_none(); + if req.head_must_exist { + for (u, (_, status)) in req.txn.updates.iter().zip(per_ref.iter_mut()) { + if !u.new_symbolic_target.is_empty() + && status.is_ok() + && working_refs.get(&u.new_symbolic_target).is_none() + { + *status = Err(RefError::Rejected(format!( + "{} does not exist", + u.new_symbolic_target + ))); + } + } + } let changes = req.pack.is_some() || req.txn.updates.iter().any(|update| { if !update.new_symbolic_target.is_empty() { @@ -581,8 +653,19 @@ async fn process_batch(handle: &RepoHandle, batch: Vec) -> Resul } } let all_ok = per_ref.iter().all(|(_, r)| r.is_ok()); + let mut healed = None; if all_ok { apply_txn_to_map(&req.txn, &mut working_refs); + if changes + && head_dangling + && let Some(head) = + heal_head(&req.txn, &working_refs, &handle.cfg.git.default_branch) + { + working_refs.set_head_target(head.new_symbolic_target.clone()); + let mut txn = req.txn.clone(); + txn.updates.push(head); + healed = Some(txn); + } } else { for (_, status) in &mut per_ref { if status.is_ok() { @@ -597,6 +680,7 @@ async fn process_batch(handle: &RepoHandle, batch: Vec) -> Resul // A no-op still validates its preconditions and reports success, // but must not manufacture a log entry or claim a sibling's seq. valid: all_ok && changes, + healed, }); } @@ -630,7 +714,7 @@ async fn process_batch(handle: &RepoHandle, batch: Vec) -> Resul let build = |first_seq: u64| -> (Vec, Vec) { let mut entries = Vec::with_capacity(valid_indices.len()); let mut new_packs = Vec::new(); - for (offset, (req, _)) in batch + for (offset, (req, v)) in batch .iter() .zip(&verified) .filter(|(_, v)| v.valid) @@ -649,7 +733,7 @@ async fn process_batch(handle: &RepoHandle, batch: Vec) -> Resul seq, EntryKind::Push, pack_ref, - Some(&req.txn), + Some(v.healed.as_ref().unwrap_or(&req.txn)), Vec::new(), &req.meta, &writer, @@ -803,12 +887,13 @@ async fn process_batch(handle: &RepoHandle, batch: Vec) -> Resul tracing::warn!(error = %e, "landed push catch-up failed; next sync repairs it"); local_ok = false; } - for (req, _) in batch + for (req, v) in batch .iter() .zip(&verified) .filter(|(_, v)| v.valid && !already_applied && committed.head_seq == last_seq) { - if let Err(e) = handle.local.apply_ref_txn(&req.txn, false) { + let txn = v.healed.as_ref().unwrap_or(&req.txn); + if let Err(e) = handle.local.apply_ref_txn(txn, false) { tracing::warn!(repo = %handle.id, seq = last_seq, error = %e, "published (CAS ok), but applying the ref txn to the local copy failed; the next sync replays it"); metrics::counter!("walgit_publish_local_apply_failed_total").increment(1); local_ok = false; @@ -984,7 +1069,7 @@ fn maybe_trigger_checkpoint(handle: &RepoHandle, _head_seq: u64) { /// Every entry this process commits moves `last_entry_time`: the checkpoint's `as_of` (the time /// of the newest folded entry, D22) is then known locally for COMPACT/SETTINGS entries too, not /// only for pushes and replayed segments — no log read at checkpoint time. -fn note_entry_time(handle: &RepoHandle, seq: u64, at: &prost_types::Timestamp) { +pub(crate) fn note_entry_time(handle: &RepoHandle, seq: u64, at: &prost_types::Timestamp) { let t = time::to_system(at); let mut slot = handle.last_entry_time.lock(); if slot.map(|p| t > p).unwrap_or(true) { diff --git a/crates/walgit-wal/src/registry.rs b/crates/walgit-wal/src/registry.rs index 9fc0147e..47ea8d14 100644 --- a/crates/walgit-wal/src/registry.rs +++ b/crates/walgit-wal/src/registry.rs @@ -11,11 +11,12 @@ use prost::Message; use walgit_git::{LocalRepo, ObjectFormat, RepoId}; use walgit_proto::WAL_FORMAT_VERSION; use walgit_proto::keys; -use walgit_proto::v1::Manifest; +use walgit_proto::v1::{EntryKind, LogEntry, LogSegmentRef, Manifest, RefTransaction, RefUpdate}; use walgit_store::{DynStore, ObjectStore, Prefixed, PutBody, PutMode, StoreError}; use crate::error::WalError; use crate::handle::RepoHandle; +use crate::publish::{ClaimOutcome, cas_landed, claim_log_slot, drop_own_slot, sweep_burned}; use crate::state::{RepoState, load_state, save_state}; use crate::store_proto::get_message; @@ -193,12 +194,38 @@ impl Registry { Ok(()) } - /// CAS-create manifest.pb (`PutMode::Create`). Err(AlreadyExists) on 412. + /// Create a repository whose HEAD names this host's `git.default_branch` + /// ([`Self::create_with_default_branch`]). pub async fn create( &self, id: &RepoId, format: ObjectFormat, ) -> Result, WalError> { + let branch = self.cfg.git.default_branch.clone(); + self.create_with_default_branch(id, format, &branch).await + } + + /// CAS-create manifest.pb (`PutMode::Create`) with HEAD → `refs/heads/`. + /// Err(AlreadyExists) on 412, Err(Invalid) for a bad branch name. + /// + /// HEAD lives where every ref lives — checkpoint `RefSnapshot.head_target` plus the + /// log's symbolic `HEAD` updates — and a repository with no record means + /// [`walgit_git::IMPLICIT_HEAD`]. That target needs no record: creation stays the one + /// Create it always was. Any other target is recorded as the repository's first entry + /// (a HEAD-only `REF_UPDATE`): its log slot is claimed exactly as a publish claims one + /// (`claim_log_slot`, orphans burned) and the manifest Create lists it, so the + /// repository appears with its HEAD in one commit or not at all — 2 sequential + /// requests instead of 1 (docs/ROUNDTRIPS.md). A Create lost to a concurrent creator + /// deletes our slot as a lost publish CAS does; an ambiguous Create error is resolved + /// by `cas_landed` (the exact segment listed ⇒ it is ours). + pub async fn create_with_default_branch( + &self, + id: &RepoId, + format: ObjectFormat, + branch: &str, + ) -> Result, WalError> { + let head = walgit_config::refs::branch_ref(branch) + .map_err(|e| WalError::Invalid(format!("default branch: {e}")))?; if let Some(h) = self.repos.get(id) { return Ok(h.clone()); } @@ -210,66 +237,125 @@ impl Registry { let prefix = id.store_prefix(); let prefixed = Prefixed::new(self.store.clone(), prefix); + let writer = crate::handle::instance_id(); + + let initial = (head != walgit_git::IMPLICIT_HEAD).then(|| RefTransaction { + updates: vec![RefUpdate { + name: "HEAD".into(), + new_symbolic_target: head.clone(), + ..Default::default() + }], + ..Default::default() + }); + let created_at = walgit_proto::time::now(); + let slot = match &initial { + None => None, + Some(txn) => { + let entry = |seq: u64| LogEntry { + seq, + kind: EntryKind::RefUpdate as i32, + txn: Some(txn.clone()), + created_at: Some(created_at), + writer: writer.clone(), + ..Default::default() + }; + match claim_log_slot(&prefixed, 0, |seq| { + walgit_proto::frame::encode_entries(std::iter::once(&entry(seq))) + }) + .await? + { + ClaimOutcome::Claimed(slot) => Some(slot), + // A committed entry holds the slot: the repository exists. + ClaimOutcome::Contended => return Err(WalError::AlreadyExists), + } + } + }; - // Create manifest with PutMode::Create let manifest = Manifest { format_version: WAL_FORMAT_VERSION, repo: id.to_string(), object_format: format.as_str().to_string(), - head_seq: 0, + head_seq: slot.as_ref().map_or(0, |s| s.first_seq), min_seq: 0, checkpoint: None, - log_segments: vec![], + log_segments: slot + .iter() + .map(|s| LogSegmentRef { + key: s.key.clone(), + first_seq: s.first_seq, + last_seq: s.first_seq, + size: s.bytes.len() as u64, + sealed: true, + }) + .collect(), packs: vec![], updated_at: Some(walgit_proto::time::now()), - writer: crate::handle::instance_id(), + writer, revision: 1, settings: None, retired_packs: Vec::new(), }; let buf = manifest.encode_to_vec(); - match prefixed + let put = prefixed .put( keys::MANIFEST, PutBody::Bytes(bytes::Bytes::from(buf)), PutMode::Create.into(), ) - .await - { - Ok(meta) => { - // Init local repo - let local = LocalRepo::init(&self.cache_root, id, format)?; - - let state = RepoState { - manifest_version: Some(meta.version.as_str().to_string()), - revision: manifest.revision, - packs_revision: manifest.revision, - ..Default::default() - }; - save_state(local.path(), &state)?; - - let handle = RepoHandle::new( - id.clone(), - local, - prefixed, - self.cfg.clone(), - manifest, - Some(meta.version), - state, - self.tasks.clone(), - self.blocks.clone(), - ); - let handle = Arc::new(handle); - handle.set_self_arc(handle.clone()); - - self.repos.insert(id.clone(), handle.clone()); - self.invalidate_listing(); - Ok(handle) + .await; + let (manifest, version) = match (put, &slot) { + (Ok(meta), _) => (manifest, meta.version), + (Err(StoreError::PreconditionFailed { .. }), slot) => { + if let Some(slot) = slot { + drop_own_slot(&prefixed, slot).await; + } + return Err(WalError::AlreadyExists); } - Err(StoreError::PreconditionFailed { .. }) => Err(WalError::AlreadyExists), - Err(e) => Err(WalError::Store(e)), + (Err(e), None) => return Err(WalError::Store(e)), + (Err(e), Some(slot)) => match cas_landed(&prefixed, slot).await { + Ok(Some(landed)) => landed, + _ => return Err(WalError::CommitUnknown(e.to_string())), + }, + }; + if let Some(slot) = &slot { + sweep_burned(&prefixed, slot).await; } + + let local = LocalRepo::init(&self.cache_root, id, format)?; + if let Some(txn) = &initial { + local.apply_ref_txn(txn, false)?; + } + let state = RepoState { + manifest_version: Some(version.as_str().to_string()), + applied_seq: manifest.head_seq, + revision: manifest.revision, + packs_revision: manifest.revision, + ..Default::default() + }; + save_state(local.path(), &state)?; + + let head_seq = manifest.head_seq; + let handle = RepoHandle::new( + id.clone(), + local, + prefixed, + self.cfg.clone(), + manifest, + Some(version), + state, + self.tasks.clone(), + self.blocks.clone(), + ); + let handle = Arc::new(handle); + handle.set_self_arc(handle.clone()); + if head_seq > 0 { + crate::publish::note_entry_time(&handle, head_seq, &created_at); + } + + self.repos.insert(id.clone(), handle.clone()); + self.invalidate_listing(); + Ok(handle) } /// Open or create. diff --git a/crates/walgit-wal/tests/wal.rs b/crates/walgit-wal/tests/wal.rs index ce85399e..a6090570 100644 --- a/crates/walgit-wal/tests/wal.rs +++ b/crates/walgit-wal/tests/wal.rs @@ -2988,3 +2988,333 @@ async fn readiness_carries_only_proven_inventory_and_rechecks_revision_only_rest "reconciliation must settle at the held revision" ); } + +// ---- HEAD: recorded at creation, healed by pushes, moved by publish_head (D50) ---- + +fn head_target(handle: &walgit_wal::RepoHandle) -> String { + handle.local().refs().unwrap().head_target +} + +/// The HEAD updates an entry's transaction carries (symbolic targets). +fn head_updates(entry: &walgit_proto::v1::LogEntry) -> Vec { + entry + .txn + .iter() + .flat_map(|t| &t.updates) + .filter(|u| !u.new_symbolic_target.is_empty()) + .map(|u| u.new_symbolic_target.clone()) + .collect() +} + +/// A commit known to `handle`'s local copy, so ref-only publishes can point at it. +async fn seed_commit(handle: &walgit_wal::RepoHandle, work: &WorkRepo) -> String { + let tip = work.commit("seed", "content"); + let pack = ingest_pack_data(handle, work.create_pack()).await.unwrap(); + // Install the pack locally without publishing a ref: a throwaway ref, then its delete. + let seeded = handle + .publish_push( + Some(pack), + make_txn(vec![("refs/tags/seed", "", &tip)]), + HashMap::new(), + ) + .await + .unwrap(); + assert!(seeded.per_ref.iter().all(|(_, r)| r.is_ok())); + tip +} + +#[tokio::test] +async fn create_records_only_a_non_implicit_head_in_the_same_commit() { + use walgit_store::fault::{FaultPlan, FaultStore}; + let cache = tempfile::tempdir().unwrap(); + let truth = MemoryStore::shared(); + let link = FaultStore::new(truth.clone(), "create", 1); + let registry = Registry::new(link.clone(), Arc::new(make_config(cache.path(), 0))); + let ops = || link.stats().ops.load(std::sync::atomic::Ordering::Relaxed); + + // The host default (`main`) is the implicit HEAD: one Create, no entry — as before. + let before = ops(); + let plain = registry + .create(&repo_id("o", "plain"), ObjectFormat::Sha1) + .await + .unwrap(); + assert_eq!(ops() - before, 1, "an implicit-HEAD create is one Create"); + assert_eq!(plain.manifest().head_seq, 0); + assert_eq!(head_target(&plain), "refs/heads/main"); + + // Another target is the first entry, claimed before and listed by the manifest Create. + let before = ops(); + let id = repo_id("o", "trunk"); + let trunk = registry + .create_with_default_branch(&id, ObjectFormat::Sha1, "trunk") + .await + .unwrap(); + assert_eq!(ops() - before, 2, "log slot Create -> manifest Create"); + assert_eq!(trunk.manifest().head_seq, 1); + assert_eq!(head_target(&trunk), "refs/heads/trunk"); + let log = trunk.read_log(1, None).await.unwrap(); + assert_eq!(log.len(), 1); + assert_eq!(log[0].kind, EntryKind::RefUpdate as i32); + assert_eq!(head_updates(&log[0]), ["refs/heads/trunk"]); + + // Every other instance replays it; a second creator loses cleanly. + let other_cache = tempfile::tempdir().unwrap(); + let other = Registry::new(truth.clone(), Arc::new(make_config(other_cache.path(), 0))); + let replica = other.open(&id).await.unwrap(); + assert_eq!(head_target(&replica), "refs/heads/trunk"); + let third_cache = tempfile::tempdir().unwrap(); + let third = Registry::new(truth.clone(), Arc::new(make_config(third_cache.path(), 0))); + assert!(matches!( + third + .create_with_default_branch(&id, ObjectFormat::Sha1, "other") + .await, + Err(walgit_wal::WalError::AlreadyExists) + )); + assert!(matches!( + registry + .create_with_default_branch(&repo_id("o", "bad"), ObjectFormat::Sha1, "a..b") + .await, + Err(walgit_wal::WalError::Invalid(_)) + )); + + // A lost Create reply is resolved from the exact claimed segment, not reported failed. + link.set(FaultPlan { + p_err_after: 1.0, + only_keys: Some(vec!["manifest.pb".into()]), + ..Default::default() + }); + let lost = registry + .create_with_default_branch(&repo_id("o", "lost"), ObjectFormat::Sha1, "dev") + .await + .unwrap(); + link.heal(); + assert_eq!(lost.manifest().head_seq, 1); + assert_eq!(head_target(&lost), "refs/heads/dev"); +} + +#[tokio::test] +async fn a_push_without_heads_target_heals_head_in_its_own_entry() { + let cache = tempfile::tempdir().unwrap(); + let store = MemoryStore::shared(); + let registry = Registry::new(store.clone(), Arc::new(make_config(cache.path(), 0))); + let id = repo_id("o", "heal"); + let handle = registry.create(&id, ObjectFormat::Sha1).await.unwrap(); + let work = WorkRepo::new(); + let tip = seed_commit(&handle, &work).await; + // Tags alone never move HEAD: it names a branch. + assert_eq!(head_target(&handle), "refs/heads/main"); + assert!(head_updates(&handle.read_log(1, None).await.unwrap()[0]).is_empty()); + + // No `main` in the push: HEAD goes to the first created branch by name, not by command order. + let r = handle + .publish_push( + None, + make_txn(vec![ + ("refs/heads/zeta", "", &tip), + ("refs/heads/alpha", "", &tip), + ]), + HashMap::new(), + ) + .await + .unwrap(); + assert_eq!(r.seq, 2); + let reported: Vec<&str> = r.per_ref.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!( + reported, + ["refs/heads/zeta", "refs/heads/alpha"], + "HEAD is not reported to the client" + ); + assert_eq!(head_target(&handle), "refs/heads/alpha"); + let log = handle.read_log(1, None).await.unwrap(); + assert_eq!(head_updates(&log[1]), ["refs/heads/alpha"]); + assert_eq!(handle.manifest().head_seq, 2, "no extra entry"); + + // HEAD resolves now: later branch creations leave it alone. + handle + .publish_push( + None, + make_txn(vec![("refs/heads/beta", "", &tip)]), + HashMap::new(), + ) + .await + .unwrap(); + assert_eq!(head_target(&handle), "refs/heads/alpha"); + // Deleting HEAD's branch leaves it dangling (a delete moves nothing else) … + handle + .publish_push( + None, + make_txn(vec![("refs/heads/alpha", &tip, "")]), + HashMap::new(), + ) + .await + .unwrap(); + assert_eq!(head_target(&handle), "refs/heads/alpha"); + // … until the next publish that creates a branch. + handle + .publish_push( + None, + make_txn(vec![("refs/heads/gamma", "", &tip)]), + HashMap::new(), + ) + .await + .unwrap(); + assert_eq!(head_target(&handle), "refs/heads/gamma"); + + // Replicas replay the healed HEAD from the log. + let other_cache = tempfile::tempdir().unwrap(); + let other = Registry::new(store, Arc::new(make_config(other_cache.path(), 0))); + let replica = other.open(&id).await.unwrap(); + drop(replica.sync_refs().await.unwrap()); + assert_eq!(head_target(&replica), "refs/heads/gamma"); +} + +#[tokio::test] +async fn heal_prefers_the_default_branch_and_respects_an_explicit_head() { + let cache = tempfile::tempdir().unwrap(); + let mut cfg = make_config(cache.path(), 0); + cfg.git.default_branch = "trunk".into(); + let registry = Registry::new(MemoryStore::shared(), Arc::new(cfg)); + // HEAD → the implicit main, which this repository never gets. + let handle = registry + .create_with_default_branch(&repo_id("o", "prefer"), ObjectFormat::Sha1, "main") + .await + .unwrap(); + let work = WorkRepo::new(); + let tip = seed_commit(&handle, &work).await; + handle + .publish_push( + None, + make_txn(vec![ + ("refs/heads/alpha", "", &tip), + ("refs/heads/trunk", "", &tip), + ]), + HashMap::new(), + ) + .await + .unwrap(); + assert_eq!(head_target(&handle), "refs/heads/trunk"); + + // A transaction that names HEAD keeps what it says (imports). + let explicit = registry + .create(&repo_id("o", "explicit"), ObjectFormat::Sha1) + .await + .unwrap(); + let tip = seed_commit(&explicit, &work).await; + let mut txn = make_txn(vec![("refs/heads/alpha", "", &tip)]); + txn.updates.push(RefUpdate { + name: "HEAD".into(), + new_symbolic_target: "refs/heads/unborn".into(), + ..Default::default() + }); + explicit + .publish_push(None, txn, HashMap::new()) + .await + .unwrap(); + assert_eq!(head_target(&explicit), "refs/heads/unborn"); +} + +#[tokio::test] +async fn one_batch_heals_head_once() { + let cache = tempfile::tempdir().unwrap(); + let registry = Registry::new( + MemoryStore::shared(), + Arc::new(make_config(cache.path(), 50)), + ); + let handle = registry + .create(&repo_id("o", "batch"), ObjectFormat::Sha1) + .await + .unwrap(); + let work = WorkRepo::new(); + let tip = seed_commit(&handle, &work).await; + let (a, b) = tokio::join!( + handle.publish_push( + None, + make_txn(vec![("refs/heads/one", "", &tip)]), + HashMap::new() + ), + handle.publish_push( + None, + make_txn(vec![("refs/heads/two", "", &tip)]), + HashMap::new() + ), + ); + let (a, b) = (a.unwrap(), b.unwrap()); + assert!(a.seq > 1 && b.seq > 1); + let log = handle.read_log(1, None).await.unwrap(); + let heals: Vec = log.iter().flat_map(head_updates).collect(); + assert_eq!( + heals.len(), + 1, + "the later entry sees the healed HEAD: {heals:?}" + ); + assert_eq!(head_target(&handle), heals[0]); +} + +#[tokio::test] +async fn publish_head_needs_an_existing_target_on_every_attempt_and_is_idempotent() { + let store = MemoryStore::shared(); + let cache_a = tempfile::tempdir().unwrap(); + let cache_b = tempfile::tempdir().unwrap(); + let a = Registry::new(store.clone(), Arc::new(make_config(cache_a.path(), 0))); + let b = Registry::new(store, Arc::new(make_config(cache_b.path(), 0))); + let id = repo_id("o", "head"); + let handle = a.create(&id, ObjectFormat::Sha1).await.unwrap(); + let work = WorkRepo::new(); + let tip = seed_commit(&handle, &work).await; + handle + .publish_push( + None, + make_txn(vec![ + ("refs/heads/main", "", &tip), + ("refs/heads/dev", "", &tip), + ]), + HashMap::new(), + ) + .await + .unwrap(); + + let missing = handle + .publish_head("refs/heads/nope", HashMap::new()) + .await + .unwrap(); + assert_eq!(missing.seq, 0); + assert!(missing.per_ref[0].1.is_err()); + assert_eq!(head_target(&handle), "refs/heads/main"); + + let moved = handle + .publish_head("refs/heads/dev", HashMap::new()) + .await + .unwrap(); + assert!(moved.seq > 0); + assert!(moved.per_ref.iter().all(|(_, r)| r.is_ok())); + assert_eq!(head_target(&handle), "refs/heads/dev"); + let again = handle + .publish_head("refs/heads/dev", HashMap::new()) + .await + .unwrap(); + assert_eq!(again.seq, 0, "HEAD already there: nothing written"); + assert_eq!(handle.manifest().head_seq, moved.seq); + + // B's copy still has `main`; A deletes it. B's publish revalidates first and refuses. + let replica = b.open(&id).await.unwrap(); + handle + .publish_push( + None, + make_txn(vec![("refs/heads/main", &tip, "")]), + HashMap::new(), + ) + .await + .unwrap(); + let stale = replica + .publish_head("refs/heads/main", HashMap::new()) + .await + .unwrap(); + assert!(stale.per_ref[0].1.is_err(), "{:?}", stale.per_ref); + assert_eq!(head_target(&replica), "refs/heads/dev"); + assert!(matches!( + replica + .publish_head("refs/heads/a..b", HashMap::new()) + .await, + Err(walgit_wal::WalError::Git(_)) + )); +} diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 2aa73020..ffcce60a 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -168,8 +168,12 @@ impl Registry { pub fn new(store: DynStore, cfg: Arc) -> Arc; /// Open existing (materialize local copy lazily). Err(WalError::NotFound) if manifest.pb absent. pub async fn open(&self, id: &RepoId) -> Result, WalError>; - /// CAS-create manifest.pb (PutMode::Create). Err(WalError::AlreadyExists). + /// CAS-create manifest.pb (PutMode::Create). Err(WalError::AlreadyExists). HEAD → git.default_branch. pub async fn create(&self, id: &RepoId, format: ObjectFormat) -> Result, WalError>; + /// Same with HEAD → refs/heads/ (short name; Err(Invalid) otherwise). A target other than + /// walgit_git::IMPLICIT_HEAD is the first log entry, committed by the same manifest Create (D50). + pub async fn create_with_default_branch(&self, id: &RepoId, format: ObjectFormat, branch: &str) + -> Result, WalError>; pub async fn open_or_create(&self, id: &RepoId, format: ObjectFormat) -> Result, WalError>; pub async fn list(&self) -> Result, WalError>; // list "repos/" prefix (delimiter-less scan is ok v1) pub fn store(&self) -> &DynStore; pub fn config(&self) -> &Arc; @@ -199,6 +203,11 @@ impl RepoHandle { -> Result; pub struct PublishResult { pub seq: u64, pub per_ref: Vec<(String, Result<(), RefError>)> } pub async fn publish_ref_update(&self, txn: RefTransaction, meta) -> Result; + /// Every publish: a txn that creates refs/heads/* while HEAD resolves to nothing before and after it + /// also retargets HEAD in its own entry (default branch if created, else the first created by name). + /// HEAD → `target` (full refs/heads/ name) through the same publisher; rejected per ref (`HEAD`) unless + /// the target exists on the attempt's CAS basis; seq 0 when already there (D50). + pub async fn publish_head(&self, target: &str, meta: HashMap) -> Result; /// COMPACT entry: new pack (already local, e.g. from LocalRepo::repack) superseding `supersedes`. pub async fn publish_compact(&self, new_pack: PackInfo, supersedes: Vec, tier: u32) -> Result; diff --git a/docs/POLICY.md b/docs/POLICY.md index dc622bdd..6825c1da 100644 --- a/docs/POLICY.md +++ b/docs/POLICY.md @@ -228,6 +228,12 @@ envelope; they will not change a verdict until this document says they do. - Overlapping-bypass lockout: if two `protect` rules can match the same ref and the same op, and both have non-empty bypass lists whose intersection is empty, load fails. AND would make the intended bot unable to land. +- `HEAD` is not a pushable name. A command for `HEAD` (or anything outside + `refs/`) is `ng … funny refname` under every policy, including none, as in + git's own receive-pack: through the symref it would move HEAD's branch + under a name no rule matches. HEAD's *target* is not a ref move and is not + evaluated: it changes only by the push heal rule (onto a branch the same + push was allowed to create) or the admin route `PUT …/api/head` (D50). ## What does not go in the JSON diff --git a/docs/ROUNDTRIPS.md b/docs/ROUNDTRIPS.md index 447af901..948bbf93 100644 --- a/docs/ROUNDTRIPS.md +++ b/docs/ROUNDTRIPS.md @@ -52,6 +52,9 @@ right shape. This document is the thinking tool; apply it to every protocol chan | Validated remote index admission | warm valid index: 0 store requests; absent/corrupt index: index GET (HEAD first only for old descriptors with unknown size), at most four indexes downloading concurrently | unchanged healthy store depth; corruption now triggers repair instead of admitting bad evidence. Checksums, lock, mmap and cleanup are local work; no LIST. Per-index checksum CPU cost occurs when constructing a new remote inventory, not per object lookup. | `index_cache.rs`, `remote.rs` | | Final replacement evidence | coherent refs-level manifest capture → verified current index mappings → conservation/current-tip scan → existing log claim/CAS | warm indexes add 0 store requests; cold/bad evidence adds index GETs (HEAD only for unknown sizes), four concurrent downloads. O(refs) local capture and streaming index-union scan per CAS attempt; no pack download or LIST. This cost is not zero-read or constant-time seal certification. | `classification.rs`, `closure.rs`, `index_cache.rs` | | Lost CAS response resolution | fresh manifest GET; only if the exact segment descriptor is listed, GET and compare the claimed log bytes | normally +1 GET on this failure path compared with key/sequence-only resolution; no added successful-push requests. Missing/folded evidence remains unknown. Per-attempt nonce and actual frame sizes are computed locally. | `publish.rs::cas_landed` | +| Repository create (D50) | implicit HEAD (`refs/heads/main`): manifest Create; any other HEAD: log slot Create → manifest Create | 1 (unchanged) / 2 — the second only for a non-`main` default branch, once per repository. Readers pay nothing: the HEAD entry replays with the tail they already read | `registry.rs::create_with_default_branch` | +| HEAD heal on push (D50) | unchanged: the retarget is appended to the publishing entry's own transaction on each attempt's CAS basis | 0 extra | `publish.rs::heal_head` | +| HEAD admin route (D50) | refs sync (conditional GET) → log slot PUT → manifest CAS; the target's existence is checked on the CAS basis, a retry re-syncs | 3 rounds; idempotent no-op: 1 (the sync) | `handle.rs::publish_head` | | Settings publish (D24) | refs sync (conditional GET) → log slot PUT → manifest CAS; readers pay nothing extra (settings ride inline on the manifest) | 3 rounds; read: 0 | `publish.rs::publish_settings_impl` | | Lease acquire | 1 GET → 1 CAS put (or 1 Create when absent) | 2 | `coord.rs::try_acquire` | | Publish, local commit (2026-08-23) | unchanged in round trips: after the manifest CAS the ref txns are applied to the local copy **before** the new manifest version is advertised, both under `sync_mutex` (the refs phase of every sync); the reverse order let a reader cache the old refs under the new version, and without the lock a concurrent sync replayed the same entry (two `update-ref`, a lock collision). A landed CAS is answered `ok` whatever the local apply does — the next sync replays (one conditional GET that then returns 200, no extra write). | 0 extra | `publish.rs::process_batch` | diff --git a/walgit.example.toml b/walgit.example.toml index 79584aa9..317c0655 100644 --- a/walgit.example.toml +++ b/walgit.example.toml @@ -184,6 +184,8 @@ upload_pack_engine = "auto" # "auto" = stock git when packs are local/moun allow_filter = true # uploadpack.allowFilter allow_any_sha1_in_want = false # uploadpack.allowAnySHA1InWant object_format = "sha1" # default for new repos: "sha1" | "sha256" +default_branch = "main" # HEAD's branch in repos created here (PUT ?default_branch= overrides); recorded in the + # bucket, and preferred when a push heals a HEAD that resolves to nothing (AGENTS D50) commit_graph = true # maintain a split commit-graph chain per repo (tier-2 layer from the WAL + incremental) history_pack = true # base rebuild also publishes commits+trees as a local "history pack" commit_graph_changed_paths = false # Bloom filters for incremental layers (diffs against parent trees = base pack reads) diff --git a/web/API.md b/web/API.md index 147dee43..e15b391a 100644 --- a/web/API.md +++ b/web/API.md @@ -262,7 +262,17 @@ Ref-level summary: head (`null` when unborn), O(1) ref counts from the ref index, URLs. `404` for an unknown repo. Cache: SWR + `ETag: ""`. `PUT` creates the repository (write permission; `201`/`200`), `DELETE` removes it (admin permission) — the same handlers as `PUT|DELETE /{owner}/{repo}`. -`GET|PUT|DELETE …/policy` is the push policy document (`docs/POLICY.md`). +`PUT ?object_format=sha1|sha256&default_branch=` (both optional; defaults `git.object_format`, +`git.default_branch`): `default_branch` is HEAD's branch as a short name (`main`, `release%2Fnext`), `400` when Git +would not accept it as a branch. `GET|PUT|DELETE …/policy` is the push policy document (`docs/POLICY.md`). + +`PUT /{o}/{r}/api/head` (browser lane `…/api-browser/head`; D50) with `{"branch": ""}` points HEAD — the +default branch clones check out and the UI opens — at an **existing** branch. Admin only. One WAL entry through the +push publisher (CAS like any ref update; the branch is re-checked on every attempt) → `200 {head: {name, sha}, seq}`, +`seq: 0` when HEAD was already there (idempotent, nothing written); `400` bad body or name, `404` unknown +repository, `409` no such branch. HEAD otherwise moves by itself only when a push publishes branches while HEAD +resolves to nothing: it then points at `git.default_branch` if that push creates it, else at the push's first +created branch by name, in the same entry. SDK: `repo.create({defaultBranch})`, `repo.setHead(branch)`. `GET|PUT|DELETE /{o}/{r}/api/settings` (D24, 2026-08-21) is the repository's **settings in the WAL**: a TOML document restricted to `[refs]`, `[packfile_uri]`, `[maintenance]`, `[packs]` and `[upstream]`, merged over the @@ -504,7 +514,7 @@ redelivers). Never cached, never served to the SPA. after a push is acknowledged, the next API call (any node) reflects it. - Writes on the JSON surface need a token with the matching permission: write for `PUT /{o}/{r}/api` (create) and `POST …/ops/{op}`, admin for - `DELETE /{o}/{r}/api`, `PUT|DELETE …/policy` and `PUT|DELETE …/settings` + `DELETE /{o}/{r}/api`, `PUT …/head`, `PUT|DELETE …/policy` and `PUT|DELETE …/settings` (D24: write is push, not admin). Content moves over git (`git-receive-pack`) and LFS, never through JSON. @@ -516,6 +526,8 @@ GET /api/v1/me → 200 {principal,write,anonymo GET /api/v1/owners → 200 [..] ([] when empty) GET /api/v1/owners/nobody/repos → 200 [] GET /o/r/api → 200 {owner,name,full_name,head,branches,tags,clone_url,html_url,api_url}; SWR + ETag "" +PUT /o/r/api?default_branch=a..b → 400 (nothing created) +PUT /o/r/api/head {"branch":"dev"} (admin) → 200 {head:{name:"dev",sha},seq}; again → seq 0; unknown branch → 409; non-admin → 403 GET /o/r/api-browser/refs → same handlers (browser lane) OPTIONS /api/v1/… (Origin ∈ cors_origins) → 204 + Access-Control-Allow-{Origin,Credentials,Methods,Headers} GET /api/v1/… (Origin ∉ cors_origins) → 200, no CORS headers; DELETE/PUT/POST → 403 diff --git a/web/sdk/README.md b/web/sdk/README.md index cd8b727f..ed4bef0c 100644 --- a/web/sdk/README.md +++ b/web/sdk/README.md @@ -47,8 +47,9 @@ repos.owners.repos("acme") → ["monorepo", …] repos.repo("acme/monorepo") → RepoClient (no request) r.get() → { owner, name, full_name, head, branches, tags, clone_url, html_url, api_url } -r.create() → write permission +r.create({ defaultBranch }) → write permission (defaultBranch optional: HEAD's branch) r.delete() → admin permission +r.setHead("dev") → { head: {name, sha}, seq } (admin; an existing branch; seq 0 = unchanged) r.refs() → { head: {name, sha} | null } r.branches({ prefix, q, after, n }) → { refs: [{name, sha}], more } (one page; tags likewise) r.tags({ … }) diff --git a/web/sdk/repos.ts b/web/sdk/repos.ts index 777800e4..72f4dae4 100644 --- a/web/sdk/repos.ts +++ b/web/sdk/repos.ts @@ -599,9 +599,18 @@ export class RepoClient { get(opts?: CallOptions) { return this.client.json(this.p, opts); } - /** Create the repository (write permission). */ - async create(opts?: CallOptions): Promise { - await this.client.json(this.p, opts, { method: "PUT" }); + /** Create the repository (write permission); `defaultBranch` (short name) overrides the host's `git.default_branch`. */ + async create(options: { defaultBranch?: string } = {}, opts?: CallOptions): Promise { + const query = options.defaultBranch ? `?default_branch=${encodeURIComponent(options.defaultBranch)}` : ""; + await this.client.json(`${this.p}${query}`, opts, { method: "PUT" }); + } + /** Point HEAD (the default branch) at an existing branch (admin). `seq: 0` = it was already there. */ + setHead(branch: string, opts?: CallOptions) { + return this.client.json<{ head: RefInfo; seq: number }>(`${this.p}/head`, opts, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ branch }), + }); } /** Delete the repository (admin permission). Irreversible. */ async delete(opts?: CallOptions): Promise {