Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/walgit-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
},
/// List all repositories.
List,
Expand Down
7 changes: 6 additions & 1 deletion crates/walgit-cli/src/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ pub async fn run(action: RepoAction, cfg: &Arc<Config>) -> Result<()> {
RepoAction::Create {
repo,
object_format,
default_branch,
} => {
let (owner, name) = parse_repo_id(&repo)?;
let id = walgit_git::RepoId::new(owner, name)?;
Expand All @@ -33,10 +34,14 @@ pub async fn run(action: RepoAction, cfg: &Arc<Config>) -> 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");
}
Expand Down
25 changes: 25 additions & 0 deletions crates/walgit-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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() {
Expand Down
40 changes: 40 additions & 0 deletions crates/walgit-config/src/refs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,25 @@ fn valid_ref(name: &str) -> bool {
.all(|p| !p.is_empty() && !p.starts_with('.') && !p.ends_with(".lock"))
}

/// `refs/heads/<name>` 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<String> {
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 {
Expand Down Expand Up @@ -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)]
Expand Down
15 changes: 12 additions & 3 deletions crates/walgit-git/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ fn ge<E: std::error::Error + Send + Sync + 'static>(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(
Expand Down Expand Up @@ -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
Expand Down
75 changes: 68 additions & 7 deletions crates/walgit-server/src/admin.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -9,18 +10,27 @@ 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=<name>]` — 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,
headers: &HeaderMap,
query: &str,
) -> Result<Response, ApiError> {
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) => {
Expand All @@ -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())
Expand All @@ -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": "<name>"}` — 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<Response, ApiError> {
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\": \"<name>\"}}: {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<Response, ApiError> {
let _ = st.auth.require_read(headers).await.map_err(auth_err)?;
Expand Down Expand Up @@ -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}")),
}
}
3 changes: 3 additions & 0 deletions crates/walgit-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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") => {
Expand Down
33 changes: 33 additions & 0 deletions crates/walgit-server/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,14 @@ fn deny_reason(
u: &RefUpdate,
is_force: &impl Fn(&RefUpdate) -> bool,
) -> Option<String> {
// A pushed command names a ref under `refs/`, never `HEAD` (git's own receive-pack refuses
// it as a "funny refname"). `HEAD <oid>` 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;
Expand Down Expand Up @@ -871,6 +879,31 @@ mod tests {
assert!(ev.per_ref[0].1.is_ok());
}

/// `HEAD <oid>` 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();
Expand Down
2 changes: 1 addition & 1 deletion crates/walgit-server/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading