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
18 changes: 16 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,12 @@ machines whose "disk" is 20 GiB of tmpfs, next to a long tail of small repositor
| `leases/<name>.pb` | CAS lease with TTL heartbeat: `compact`. The only cross-instance mutex. |
| `cache/api/v1/<sha1>.json` | Shared render cache of immutable web API answers. |
| `policy.json` | Per-repo push policy (rule language, not on the WAL). `docs/POLICY.md`. Missing = allow-all. |
| `description.json` | Per-repo human-readable description (`{"description"}`, not on the WAL, never read by git; D50). Missing = none. |
| `fsck.pb` | Last connectivity audit (`FsckReport`), written by the maintainer's `fsck` unit, consumed by `repair` (`docs/INTEGRITY.md`). |
| `events/cursor.json` | Durable acknowledged WAL sequence of the events bridge; advanced only after the webhook acknowledged (D32). |
| `lfs/objects/<aa>/<bb>/<oid>` | LFS objects (sha256-addressed, immutable). Missing ones can be read through from `upstream.lfs` and persisted (`docs/LFS.md`). |
Schema `crates/walgit-proto/proto/walgit/v1/wal.proto`; GCS over gRPC, S3 (AWS SDK) and in-memory stores share
Outside `repos/`: `owners/<owner>/profile.json` (an owner's display name + description, D50), `maintain/<host>.pb`
(maintainer heartbeats). Schema `crates/walgit-proto/proto/walgit/v1/wal.proto`; GCS over gRPC, S3 (AWS SDK) and in-memory stores share
one contract suite (`crates/walgit-store/tests/contract.rs`, incl. compose).

### 2.2 Write path
Expand Down Expand Up @@ -289,7 +291,7 @@ Unrelated constraints remain in force. The current design target and migration g
- **D13** Long work is a task and is narrated (§2.7); no endpoint may block silently.
- **D14** Web toolchain: pnpm + Vite only. The build fails unless the optimized SPA artefacts are present.
- **D15** Repo-scoped API lives at `/{o}/{r}/api/…` (refs, resolve, tree, blob, commits, commit, overview, ops,
tasks, policy, settings) so the repository prefix determines placement. The browser lane is
tasks, policy, description, settings) so the repository prefix determines placement. The browser lane is
`/{o}/{r}/api-browser/…`; `/api/v1` is non-repository discovery, identity and owner listing. No aliases.
- **D16** Push authorization is a per-repo **rule language** at `repos/<o>/<r>/policy.json` (`docs/POLICY.md`).
Envelope `version` + `groups` + `rules`; `protect` = AND. Empty/missing = anyone with write may move any ref.
Expand Down Expand Up @@ -487,6 +489,18 @@ 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): Human-readable metadata is two small control objects, not state.** A repository's
description (`repos/<o>/<r>/description.json`) and an owner's profile (`owners/<o>/profile.json`:
`display_name`, `description`) give opaque ids a readable name. Like `policy.json` (D16) they are outside the
WAL — a label is not repository state, advances nothing, rides no sync and is read by no git path — and are
overwritten last-writer-wins. The profile sits at the bucket root because `repos/` holds repositories only (its
delimited listing finds them) and an owner is not a routing unit (D26). Owners stay implicit: a profile creates
nothing and is listed only while its owner has a repository. Surface: `GET|PUT|DELETE /{o}/{r}/api/description`,
`GET|PUT|DELETE /api[-browser]/v1/owners/{o}`, `?detail=1` on both owner listings (plain shapes unchanged);
read for GET, **admin** for every write, including `?description=` on create. One line of plain text,
bounded; strict on write, lenient on read. No in-process cache (principle IV); the per-item GETs of a detail
listing are the one multiplied cost (`docs/ROUNDTRIPS.md`). Git's own `description` file is not written.

## 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
11 changes: 11 additions & 0 deletions crates/walgit-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,17 @@ enum RepoAction {
/// `owner/name`.
repo: String,
},
/// Print, set or clear the human-readable description (`description.json` in the bucket).
Describe {
/// `owner/name`.
repo: String,
/// Replace the description (one line of plain text, at most 512 characters).
#[arg(long, conflicts_with = "clear")]
set: Option<String>,
/// Remove the description.
#[arg(long)]
clear: bool,
},
/// Per-repo push policy (`policy.json` in the bucket).
Policy {
#[command(subcommand)]
Expand Down
41 changes: 40 additions & 1 deletion crates/walgit-cli/src/repo.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! `walgit repo create|list|info` — repository management.
//! `walgit repo create|list|info|describe` — repository management.

use std::sync::Arc;

Expand Down Expand Up @@ -90,6 +90,7 @@ pub async fn run(action: RepoAction, cfg: &Arc<Config>) -> Result<()> {
);
}
}
RepoAction::Describe { repo, set, clear } => describe(&repo, set, clear, &store).await?,
RepoAction::Policy { action } => policy(action, &store).await?,
RepoAction::Settings { .. } => unreachable!(),
}
Expand Down Expand Up @@ -120,6 +121,44 @@ async fn policy(action: PolicyAction, store: &walgit_store::DynStore) -> Result<
Ok(())
}

/// Straight against the bucket, like `policy`: the host that runs this is trusted with
/// the store. Only an existing repository is labelled (one manifest HEAD), so a typo does
/// not leave a stray object under a prefix nothing lists.
async fn describe(
repo: &str,
set: Option<String>,
clear: bool,
store: &walgit_store::DynStore,
) -> Result<()> {
use walgit_server::metadata;
let id = repo_id(repo)?;
if set.is_some() || clear {
let manifest = format!("{}{}", id.store_prefix(), walgit_proto::keys::MANIFEST);
if store.head(&manifest).await?.is_none() {
bail!("no repository {id}");
}
}
if let Some(text) = set {
let description =
metadata::clean_text("description", &text, metadata::DESCRIPTION_MAX_CHARS)
.map_err(anyhow::Error::msg)?;
let doc = metadata::RepoDescription {
description: Some(description),
};
metadata::save_description(store, &id, &doc).await?;
info!(repo = %id, "description saved");
} else if clear {
metadata::clear_description(store, &id).await?;
info!(repo = %id, "description cleared");
} else {
match metadata::load_description(store, &id).await?.description {
Some(d) => println!("{d}"),
None => println!("(none)"),
}
}
Ok(())
}

fn repo_id(repo: &str) -> Result<walgit_git::RepoId> {
let (owner, name) = parse_repo_id(repo)?;
Ok(walgit_git::RepoId::new(owner, name)?)
Expand Down
7 changes: 7 additions & 0 deletions crates/walgit-git/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,13 @@ impl RepoId {
}
}

/// An owner name alone, by the rule a [`RepoId`] applies to its first part. Owners are
/// implicit (a prefix of repository ids), so this is for surfaces addressed by owner only
/// (the owner profile) that must accept exactly the owners a repository could have.
pub fn validate_owner(owner: &str) -> Result<(), GitError> {
validate_part(owner, "owner")
}

fn validate_part(s: &str, what: &str) -> Result<(), GitError> {
if s.is_empty() || s.len() > 100 {
return Err(GitError::InvalidInput(format!(
Expand Down
18 changes: 18 additions & 0 deletions crates/walgit-proto/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ pub mod keys {
pub fn policy_key(owner: &str, name: &str) -> String {
format!("{}{POLICY}", repo_prefix(owner, name))
}
/// Per-repo human description (JSON). Not on the WAL; overwritten, never on a git path.
pub const DESCRIPTION: &str = "description.json";

pub fn description_key(owner: &str, name: &str) -> String {
format!("{}{DESCRIPTION}", repo_prefix(owner, name))
}
/// Bucket-root prefix of owner profiles (not under `repos/`: that prefix holds
/// repositories only, and its delimited listing is how repositories are found).
pub const OWNERS_DIR: &str = "owners/";
/// `owners/<owner>/profile.json` — the owner's display name and description (JSON).
pub fn owner_profile_key(owner: &str) -> String {
format!("{OWNERS_DIR}{owner}/profile.json")
}

/// `log/<first_seq:016x>.pb`
pub fn log_segment_key(first_seq: u64) -> String {
Expand Down Expand Up @@ -195,6 +208,11 @@ mod tests {
"checkpoints/0000000000000001/checkpoint.pb"
);
assert_eq!(keys::lfs_key("abcdef"), "lfs/objects/ab/cd/abcdef");
assert_eq!(
keys::description_key("acme", "mono"),
"repos/acme/mono/description.json"
);
assert_eq!(keys::owner_profile_key("acme"), "owners/acme/profile.json");
assert!(!keys::lfs_oid_ok("ab"));
assert!(!keys::lfs_oid_ok("abcdef"));
assert!(keys::lfs_oid_ok(&"a".repeat(64)));
Expand Down
48 changes: 45 additions & 3 deletions crates/walgit-server/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,28 @@ 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][&description=…]` — create repo. 201 on
/// new, 409 if it exists. `description` (percent-encoded, `crate::metadata` rules) is
/// validated before anything is created and written once the create succeeded. It needs
/// **admin**, like every other description write: a create is not proof of a *new*
/// repository (`Registry::create` answers `Ok` with the cached handle when this instance
/// already has the repository open), so write permission alone must never be able to
/// relabel an existing one. Without `description`, create stays a write operation.
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 labelled = query
.split('&')
.any(|part| part.starts_with("description="));
let _principal = if labelled {
st.auth.require_admin(headers).await
} else {
st.auth.require_write(headers).await
}
.map_err(auth_err)?;
let format = match query
.split('&')
.find_map(|part| part.strip_prefix("object_format="))
Expand All @@ -30,8 +44,36 @@ pub async fn create(
}
None => ObjectFormat::from(st.cfg.git.object_format),
};
let description = query
.split('&')
.find_map(|part| part.strip_prefix("description="))
.map(|v| {
crate::metadata::clean_text(
"description",
&crate::settings::percent_decode(v),
crate::metadata::DESCRIPTION_MAX_CHARS,
)
.map_err(ApiError::BadRequest)
})
.transpose()?;
match st.registry.create(&route.id, format).await {
Ok(_h) => Ok((StatusCode::CREATED, "created").into_response()),
Ok(_h) => {
if let Some(description) = description {
let doc = crate::metadata::RepoDescription {
description: Some(description),
};
// The repository exists now; say so truthfully if its label did not land.
crate::metadata::save_description(&st.store, &route.id, &doc)
.await
.map_err(|e| {
ApiError::Internal(format!(
"repository created, description not saved ({e}); PUT {}/api/description to retry",
route.id
))
})?;
}
Ok((StatusCode::CREATED, "created").into_response())
}
Err(walgit_wal::WalError::AlreadyExists) => {
Ok((StatusCode::CONFLICT, "already exists").into_response())
}
Expand Down
10 changes: 10 additions & 0 deletions crates/walgit-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ pub mod instance;
pub mod lfs;
pub mod lfs_upstream;
pub mod maintain;
pub mod metadata;
pub mod metrics;
pub mod middleware;
pub mod ops;
Expand Down Expand Up @@ -403,6 +404,15 @@ pub(crate) async fn dispatch_route(
policy::http_put(st, route, &headers, body.take().unwrap()).await
}
(&Method::DELETE, "policy") => policy::http_delete(st, route, &headers).await,
(&Method::GET, "description") => {
metadata::http_get_description(st, route, &headers).await
}
(&Method::PUT, "description") => {
metadata::http_put_description(st, route, &headers, body.take().unwrap()).await
}
(&Method::DELETE, "description") => {
metadata::http_delete_description(st, route, &headers).await
}
(&Method::GET, "settings") => settings::http_get(st, route, &headers).await,
(&Method::GET, "settings/effective") => {
settings::http_effective(st, route, &headers).await
Expand Down
Loading