michal/tit

Browse tree · Show commit · Download archive

Diff

c7d4b27868e7bc058d78daeb

Cargo.lock

Mode 100644100644; object 3d38e38317baabedf5827148

@@ -40,6 +40,15 @@
 ]
 
 [[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
 name = "allocator-api2"
 version = "0.2.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -365,6 +374,7 @@
  "iana-time-zone",
  "js-sys",
  "num-traits",
+ "serde",
  "wasm-bindgen",
  "windows-link",
 ]
@@ -853,6 +863,23 @@
 version = "2.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "feed-rs"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "369995dae0733f1fe5ab0e3f345f6503a5f384179df5d8da333702031a131cf9"
+dependencies = [
+ "chrono",
+ "mediatype",
+ "quick-xml",
+ "regex",
+ "serde",
+ "serde_json",
+ "siphasher",
+ "url",
+ "uuid",
+]
 
 [[package]]
 name = "ff"
@@ -2335,6 +2362,15 @@
 checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
 
 [[package]]
+name = "mediatype"
+version = "0.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7"
+dependencies = [
+ "serde",
+]
+
+[[package]]
 name = "memchr"
 version = "2.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2773,6 +2809,16 @@
 checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
 
 [[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "encoding_rs",
+ "memchr",
+]
+
+[[package]]
 name = "quote"
 version = "1.0.47"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2814,10 +2860,33 @@
 ]
 
 [[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
 name = "regex-automata"
 version = "0.4.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
 
 [[package]]
 name = "rfc6979"
@@ -3474,8 +3543,11 @@
  "askama",
  "axum",
  "clap",
+ "feed-rs",
  "gix",
  "gix-pack",
+ "httpdate",
+ "jiff",
  "pulldown-cmark",
  "rand",
  "rusqlite",
@@ -3661,6 +3733,17 @@
 version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "wasm-bindgen",
+]
 
 [[package]]
 name = "vcpkg"

Cargo.toml

Mode 100644100644; object 9b8feff6154c6e66bc45730b

@@ -17,6 +17,8 @@
 clap = { version = "4.6", default-features = false, features = ["derive", "error-context", "help", "std", "usage"] }
 gix = { version = "0.84", default-features = false, features = ["blame", "parallel", "sha1", "sha256"] }
 gix-pack = { version = "0.71", default-features = false, features = ["generate", "sha1", "sha256", "streaming-input"] }
+httpdate = "1.0.3"
+jiff = { version = "0.2.34", default-features = false, features = ["std"] }
 pulldown-cmark = { version = "0.13.4", default-features = false, features = ["html"] }
 rand = "0.10"
 rusqlite = { version = "0.40", default-features = false, features = ["backup", "bundled"] }
@@ -31,6 +33,7 @@
 url = { version = "2.5", features = ["serde"] }
 
 [dev-dependencies]
+feed-rs = { version = "2.4.0", default-features = false }
 tempfile = "3.27"
 
 [lints.rust]

docs/adr/0007-public-repository-feeds.md

Mode 100644; object 1f4909617b11

@@ -1,0 +1,60 @@
+# Architectural decision record 0007: public repository feeds
+
+Status: Accepted
+
+Date: 2026-07-23
+
+## Context
+
+`tit` must publish public repository events as Atom and RSS. Feed entries must
+not describe a push before its Git refs are reachable. Feed pages need stable
+entry IDs, bounded pagination, and HTTP cache validators.
+
+## Decision
+
+Add an append-only `repository_event` table. Insert the repository creation or
+import event in the transaction that inserts the repository. For an import,
+also insert one event for each initial branch and tag.
+
+Insert a push event and one event for each changed branch or tag in the
+transaction that completes a Git operation intent. Do not insert these events
+for a pending, promoted, or abandoned intent. Use the immutable repository ID
+to connect an event to a repository. Use the event row ID in the public entry
+ID.
+
+Migration 006 adds the event table. It also adds one creation event for each
+repository that already exists. It cannot determine whether an old repository
+was created or imported, so it uses the creation event type.
+
+Publish `/{owner}/{repository}/atom.xml` and
+`/{owner}/{repository}/rss.xml`. Return at most 20 entries. Accept a positive
+`before` event ID and return older events. Add a `next` link when more events
+exist. Do not publish feeds for private or archived repositories.
+
+Use Atom entry IDs in this form:
+
+```text
+urn:tit:event:REPOSITORY_ID:EVENT_ID
+```
+
+Use SHA-256 over the response body for the ETag. Also return Last-Modified.
+Honor If-None-Match before If-Modified-Since. Return 304 without a response
+body when a validator matches.
+
+Generate XML with a small escaping function. Use `feed-rs` version 2.4.0 as an
+independent test parser for both output formats. Use `jiff` version 0.2.34 for
+Atom dates and `httpdate` version 1.0.3 for RSS and HTTP dates.
+
+## Evidence
+
+Storage tests cover imports, initial branches and tags, completed pushes,
+branch creation, and migration from schema version 5. The public-route test
+parses Atom and RSS, checks stable entry IDs, follows pagination, checks GET and
+HEAD, checks conditional requests, and hides feeds for private and archived
+repositories.
+
+## Consequences
+
+A completed event is immutable and has a stable ID. A repository rename changes
+the feed URL but does not change an entry ID. A feed reader can use the next
+link to read old events without an unbounded database query.

scripts/check-m2-7

Mode 100755; object b905f0dbacf2

@@ -1,0 +1,7 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test sqlite creates_renames_archives_and_reads_owned_repositories
+cargo test --locked --release --test sqlite backfills_repository_events_when_version_five_is_migrated
+cargo test --locked --release --test public_routes browses_and_clones_public_repositories_for_both_hash_formats

src/admin.rs

Mode 100644100644; object b97e469359804268bac3cf24

@@ -10,7 +10,9 @@
 use crate::domain::repository::{RepositoryNameError, validate_slug};
 use crate::git::repository::{GitRepository, GitRepositoryError};
 use crate::instance::{InstanceError, InstanceLock, prepare_database, prepare_repository_root};
-use crate::store::{NewRepository, RepositoryRecord, Store, StoreError};
+use crate::store::{
+    NewRepository, NewRepositoryReference, RepositoryOrigin, RepositoryRecord, Store, StoreError,
+};
 
 pub(crate) fn create_repository(
     instance_dir: &Path,
@@ -19,10 +21,16 @@
     object_format: Kind,
 ) -> Result<RepositoryRecord, AdminError> {
     validate_names(owner, slug)?;
-    administer_repository(instance_dir, owner, slug, |path| {
-        GitRepository::create_bare(path, object_format)?;
-        Ok(object_format)
-    })
+    administer_repository(
+        instance_dir,
+        owner,
+        slug,
+        RepositoryOrigin::Created,
+        |path| {
+            GitRepository::create_bare(path, object_format)?;
+            Ok(object_format)
+        },
+    )
 }
 
 pub(crate) fn import_repository(
@@ -36,12 +44,18 @@
         path: source.to_owned(),
         source: source_error,
     })?;
-    administer_repository(instance_dir, owner, slug, |path| {
-        if source.starts_with(path.parent().expect("a managed repository has a parent")) {
-            return Err(AdminError::ManagedImport(source));
-        }
-        GitRepository::copy_bare(&source, path).map_err(Into::into)
-    })
+    administer_repository(
+        instance_dir,
+        owner,
+        slug,
+        RepositoryOrigin::Imported,
+        |path| {
+            if source.starts_with(path.parent().expect("a managed repository has a parent")) {
+                return Err(AdminError::ManagedImport(source));
+            }
+            GitRepository::copy_bare(&source, path).map_err(Into::into)
+        },
+    )
 }
 
 pub(crate) fn rename_repository(
@@ -97,6 +111,7 @@
     instance_dir: &Path,
     owner: &str,
     slug: &str,
+    origin: RepositoryOrigin,
     prepare: impl FnOnce(&Path) -> Result<Kind, AdminError>,
 ) -> Result<RepositoryRecord, AdminError> {
     let _lock = InstanceLock::acquire(instance_dir)?;
@@ -133,12 +148,26 @@
 
     let created_at = timestamp()?;
     let object_format = object_format_name(object_format)?;
+    let git = GitRepository::open(&canonical_path)?;
+    let initial_references = git
+        .references()?
+        .into_iter()
+        .filter(|reference| {
+            reference.name.starts_with(b"refs/heads/") || reference.name.starts_with(b"refs/tags/")
+        })
+        .map(|reference| NewRepositoryReference {
+            name: reference.name,
+            target: reference.target.to_string(),
+        })
+        .collect::<Vec<_>>();
     if let Err(error) = store.create_repository(&NewRepository {
         id: &id,
         owner,
         slug,
         object_format,
         created_at,
+        origin,
+        initial_references: &initial_references,
     }) {
         remove_created_repository(&canonical_path)?;
         return Err(error.into());

src/feed.rs

Mode 100644; object ca31a497f93a

@@ -1,0 +1,234 @@
+use std::fmt::Write;
+use std::time::{Duration, UNIX_EPOCH};
+
+use thiserror::Error;
+
+use crate::store::{RepositoryEventRecord, RepositoryRecord};
+
+pub(crate) const PAGE_SIZE: usize = 20;
+
+pub(crate) enum FeedFormat {
+    Atom,
+    Rss,
+}
+
+pub(crate) struct FeedPage<'a> {
+    pub(crate) repository: &'a RepositoryRecord,
+    pub(crate) base_url: &'a str,
+    pub(crate) feed_url: &'a str,
+    pub(crate) self_url: &'a str,
+    pub(crate) events: &'a [RepositoryEventRecord],
+    pub(crate) next_before: Option<i64>,
+}
+
+impl FeedPage<'_> {
+    pub(crate) fn render(&self, format: FeedFormat) -> Result<String, FeedError> {
+        match format {
+            FeedFormat::Atom => self.atom(),
+            FeedFormat::Rss => self.rss(),
+        }
+    }
+
+    fn atom(&self) -> Result<String, FeedError> {
+        let repository_url = self.repository_url();
+        let updated = self
+            .events
+            .iter()
+            .map(|event| event.created_at)
+            .max()
+            .unwrap_or(self.repository.created_at);
+        let mut output = String::from(
+            "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<feed xmlns=\"http://www.w3.org/2005/Atom\">\n",
+        );
+        element(&mut output, "id", &self.feed_id())?;
+        element(&mut output, "title", &self.feed_title())?;
+        element(&mut output, "updated", &atom_date(updated)?)?;
+        empty_link(&mut output, "self", self.self_url)?;
+        empty_link(&mut output, "alternate", &repository_url)?;
+        if let Some(next) = self.next_url() {
+            empty_link(&mut output, "next", &next)?;
+        }
+        for event in self.events {
+            output.push_str("<entry>\n");
+            element(&mut output, "id", &event_id(&self.repository.id, event.id))?;
+            element(&mut output, "title", &event_title(event))?;
+            element(&mut output, "updated", &atom_date(event.created_at)?)?;
+            empty_link(&mut output, "alternate", &repository_url)?;
+            output.push_str("<author>");
+            element(&mut output, "name", &event.actor)?;
+            output.push_str("</author>\n<content type=\"text\">");
+            escape_xml(&event_description(event), &mut output)?;
+            output.push_str("</content>\n</entry>\n");
+        }
+        output.push_str("</feed>\n");
+        Ok(output)
+    }
+
+    fn rss(&self) -> Result<String, FeedError> {
+        let repository_url = self.repository_url();
+        let updated = self
+            .events
+            .iter()
+            .map(|event| event.created_at)
+            .max()
+            .unwrap_or(self.repository.created_at);
+        let mut output = String::from(
+            "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n<channel>\n",
+        );
+        element(&mut output, "title", &self.feed_title())?;
+        element(&mut output, "link", &repository_url)?;
+        element(
+            &mut output,
+            "description",
+            &format!("Public events for {}", self.feed_title()),
+        )?;
+        element(&mut output, "lastBuildDate", &rss_date(updated)?)?;
+        atom_link(&mut output, "self", self.self_url)?;
+        if let Some(next) = self.next_url() {
+            atom_link(&mut output, "next", &next)?;
+        }
+        for event in self.events {
+            output.push_str("<item>\n");
+            element(&mut output, "title", &event_title(event))?;
+            element(&mut output, "link", &repository_url)?;
+            write!(output, "<guid isPermaLink=\"false\">")?;
+            escape_xml(&event_id(&self.repository.id, event.id), &mut output)?;
+            output.push_str("</guid>\n");
+            element(&mut output, "pubDate", &rss_date(event.created_at)?)?;
+            element(&mut output, "description", &event_description(event))?;
+            output.push_str("</item>\n");
+        }
+        output.push_str("</channel>\n</rss>\n");
+        Ok(output)
+    }
+
+    fn feed_id(&self) -> String {
+        format!("urn:tit:repository:{}:events", self.repository.id)
+    }
+
+    fn feed_title(&self) -> String {
+        format!("{}/{} events", self.repository.owner, self.repository.slug)
+    }
+
+    fn repository_url(&self) -> String {
+        format!(
+            "{}/{}/{}",
+            self.base_url, self.repository.owner, self.repository.slug
+        )
+    }
+
+    fn next_url(&self) -> Option<String> {
+        self.next_before
+            .map(|before| format!("{}?before={before}", self.feed_url))
+    }
+}
+
+fn event_id(repository_id: &str, event_id: i64) -> String {
+    format!("urn:tit:event:{repository_id}:{event_id}")
+}
+
+fn event_title(event: &RepositoryEventRecord) -> String {
+    let reference = event
+        .ref_name
+        .as_deref()
+        .map(display_reference)
+        .unwrap_or_default();
+    match event.kind.as_str() {
+        "repository-created" => "Repository created".to_owned(),
+        "repository-imported" => "Repository imported".to_owned(),
+        "push" => format!("{} pushed", event.actor),
+        "ref-created" => format!("Branch {reference} created"),
+        "ref-updated" => format!("Branch {reference} updated"),
+        "ref-deleted" => format!("Branch {reference} deleted"),
+        "tag-created" => format!("Tag {reference} created"),
+        "tag-updated" => format!("Tag {reference} updated"),
+        "tag-deleted" => format!("Tag {reference} deleted"),
+        _ => "Repository event".to_owned(),
+    }
+}
+
+fn event_description(event: &RepositoryEventRecord) -> String {
+    let mut description = event_title(event);
+    if let Some(old) = &event.old_target {
+        write!(description, " from {old}").expect("a string write cannot fail");
+    }
+    if let Some(new) = &event.new_target {
+        write!(description, " to {new}").expect("a string write cannot fail");
+    }
+    description
+}
+
+fn display_reference(name: &[u8]) -> String {
+    let short = name
+        .strip_prefix(b"refs/heads/")
+        .or_else(|| name.strip_prefix(b"refs/tags/"))
+        .unwrap_or(name);
+    String::from_utf8_lossy(short).into_owned()
+}
+
+fn atom_date(timestamp: i64) -> Result<String, FeedError> {
+    Ok(jiff::Timestamp::from_second(timestamp)
+        .map_err(|_| FeedError::Timestamp)?
+        .to_string())
+}
+
+fn rss_date(timestamp: i64) -> Result<String, FeedError> {
+    let seconds = u64::try_from(timestamp).map_err(|_| FeedError::Timestamp)?;
+    let time = UNIX_EPOCH
+        .checked_add(Duration::from_secs(seconds))
+        .ok_or(FeedError::Timestamp)?;
+    Ok(httpdate::fmt_http_date(time))
+}
+
+fn element(output: &mut String, name: &str, value: &str) -> Result<(), FeedError> {
+    write!(output, "<{name}>")?;
+    escape_xml(value, output)?;
+    writeln!(output, "</{name}>")?;
+    Ok(())
+}
+
+fn empty_link(output: &mut String, relation: &str, href: &str) -> Result<(), FeedError> {
+    write!(output, "<link rel=\"")?;
+    escape_xml(relation, output)?;
+    output.push_str("\" href=\"");
+    escape_xml(href, output)?;
+    output.push_str("\" />\n");
+    Ok(())
+}
+
+fn atom_link(output: &mut String, relation: &str, href: &str) -> Result<(), FeedError> {
+    write!(output, "<atom:link rel=\"")?;
+    escape_xml(relation, output)?;
+    output.push_str("\" href=\"");
+    escape_xml(href, output)?;
+    output.push_str("\" />\n");
+    Ok(())
+}
+
+fn escape_xml(value: &str, output: &mut String) -> Result<(), FeedError> {
+    for character in value.chars() {
+        match character {
+            '&' => output.push_str("&amp;"),
+            '<' => output.push_str("&lt;"),
+            '>' => output.push_str("&gt;"),
+            '"' => output.push_str("&quot;"),
+            '\'' => output.push_str("&apos;"),
+            character
+                if (character.is_control() && !matches!(character, '\t' | '\n' | '\r'))
+                    || matches!(character, '\u{fffe}' | '\u{ffff}') =>
+            {
+                output.push('\u{fffd}');
+            }
+            character => output.push(character),
+        }
+    }
+    Ok(())
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum FeedError {
+    #[error("event timestamp is outside the supported range")]
+    Timestamp,
+    #[error("cannot render the feed")]
+    Format(#[from] std::fmt::Error),
+}

src/http/public.rs

Mode 100644100644; object 1d61929a47777e249a3517bb

@@ -2,11 +2,12 @@
 use std::io::Write;
 use std::path::PathBuf;
 use std::sync::Arc;
+use std::time::{Duration, UNIX_EPOCH};
 
 use askama::Template;
 use axum::Router;
 use axum::body::{Body, Bytes};
-use axum::extract::{DefaultBodyLimit, Extension, OriginalUri, Path as AxumPath, State};
+use axum::extract::{DefaultBodyLimit, Extension, OriginalUri, Path as AxumPath, Query, State};
 use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
 use axum::response::Response;
 use axum::routing::{get, post};
@@ -14,12 +15,14 @@
 use gix::hash::ObjectId;
 use gix::objs::tree::EntryKind;
 use serde::Deserialize;
+use sha2::{Digest, Sha256};
 use thiserror::Error;
 use tokio::sync::{Semaphore, mpsc};
 use tokio_stream::wrappers::ReceiverStream;
 
 use crate::auth::validate_username;
 use crate::domain::repository::validate_slug;
+use crate::feed::{FeedFormat, FeedPage, PAGE_SIZE};
 use crate::git::packetline::MAX_REQUEST_BYTES;
 use crate::git::read::{
     BlameHunk, CommitInfo, DiffFile, ReadCancellation, ReadError, ReadLimits,
@@ -94,6 +97,31 @@
             };
             let service = RepositoryReadService::open(&path, limits)?;
             operation(repository, service)
+        })
+        .await
+        .map_err(|_| RouteError::Internal)?
+    }
+
+    async fn event_page(
+        &self,
+        owner: String,
+        repository: String,
+        before: Option<i64>,
+    ) -> Result<(RepositoryRecord, Vec<crate::store::RepositoryEventRecord>), RouteError> {
+        validate_username(&owner).map_err(|_| RouteError::NotFound)?;
+        validate_slug(&repository).map_err(|_| RouteError::NotFound)?;
+        let permit = self
+            .jobs
+            .clone()
+            .acquire_owned()
+            .await
+            .map_err(|_| RouteError::Unavailable)?;
+        let database = self.database.clone();
+        tokio::task::spawn_blocking(move || {
+            let _permit = permit;
+            Store::open(&database)?
+                .public_repository_events(&owner, &repository, before, PAGE_SIZE + 1)
+                .map_err(Into::into)
         })
         .await
         .map_err(|_| RouteError::Internal)?
@@ -197,6 +225,8 @@
             post(git_upload_pack).layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES)),
         )
         .route("/{owner}/{repository}/refs", get(refs))
+        .route("/{owner}/{repository}/atom.xml", get(atom_feed))
+        .route("/{owner}/{repository}/rss.xml", get(rss_feed))
         .route("/{owner}/{repository}/commit/{commit}", get(commit))
         .route("/{owner}/{repository}/diff/{old}/{new}", get(diff))
         .route("/{owner}/{repository}/tree/{commit}", get(tree_root))
@@ -206,6 +236,84 @@
         .route("/{owner}/{repository}/blame/{commit}/{*path}", get(blame))
         .route("/{owner}/{repository}/archive/{archive}", get(archive))
         .route("/{owner}/{repository}", get(summary))
+}
+
+async fn atom_feed(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    AxumPath(path): AxumPath<RepositoryPath>,
+    Query(query): Query<FeedQuery>,
+    headers: HeaderMap,
+) -> Response {
+    feed_response(state, request_id, path, query, headers, FeedFormat::Atom).await
+}
+
+async fn rss_feed(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    AxumPath(path): AxumPath<RepositoryPath>,
+    Query(query): Query<FeedQuery>,
+    headers: HeaderMap,
+) -> Response {
+    feed_response(state, request_id, path, query, headers, FeedFormat::Rss).await
+}
+
+async fn feed_response(
+    state: WebState,
+    request_id: RequestId,
+    path: RepositoryPath,
+    query: FeedQuery,
+    headers: HeaderMap,
+    format: FeedFormat,
+) -> Response {
+    if matches!(query.before, Some(before) if before <= 0) {
+        return route_error(RouteError::InvalidRequest, &request_id.0);
+    }
+    let Some(web) = state.public else {
+        return route_error(RouteError::NotFound, &request_id.0);
+    };
+    let owner = path.owner;
+    let repository = path.repository;
+    let (record, mut events) = match web
+        .event_page(owner.clone(), repository.clone(), query.before)
+        .await
+    {
+        Ok(page) => page,
+        Err(error) => return route_error(error, &request_id.0),
+    };
+    let has_next = events.len() > PAGE_SIZE;
+    events.truncate(PAGE_SIZE);
+    let next_before = has_next
+        .then(|| events.last().map(|event| event.id))
+        .flatten();
+    let name = match format {
+        FeedFormat::Atom => "atom.xml",
+        FeedFormat::Rss => "rss.xml",
+    };
+    let feed_url = format!("{}/{owner}/{repository}/{name}", web.http_clone_base);
+    let self_url = query.before.map_or_else(
+        || feed_url.clone(),
+        |before| format!("{feed_url}?before={before}"),
+    );
+    let newest = events
+        .iter()
+        .map(|event| event.created_at)
+        .max()
+        .unwrap_or(record.created_at);
+    let body = match (FeedPage {
+        repository: &record,
+        base_url: &web.http_clone_base,
+        feed_url: &feed_url,
+        self_url: &self_url,
+        events: &events,
+        next_before,
+    })
+    .render(format)
+    {
+        Ok(body) => body,
+        Err(_) => return route_error(RouteError::Internal, &request_id.0),
+    };
+    conditional_feed(&headers, name, body, newest)
 }
 
 async fn summary(
@@ -583,6 +691,63 @@
     }
 }
 
+fn conditional_feed(headers: &HeaderMap, name: &str, body: String, timestamp: i64) -> Response {
+    let digest = Sha256::digest(body.as_bytes());
+    let etag = format!("\"{}\"", encode_hex(&digest));
+    let modified = u64::try_from(timestamp)
+        .ok()
+        .and_then(|seconds| UNIX_EPOCH.checked_add(Duration::from_secs(seconds)))
+        .unwrap_or(UNIX_EPOCH);
+    let not_modified = headers
+        .get(header::IF_NONE_MATCH)
+        .and_then(|value| value.to_str().ok())
+        .is_some_and(|value| etag_matches(value, &etag))
+        || (!headers.contains_key(header::IF_NONE_MATCH)
+            && headers
+                .get(header::IF_MODIFIED_SINCE)
+                .and_then(|value| value.to_str().ok())
+                .and_then(|value| httpdate::parse_http_date(value).ok())
+                .is_some_and(|value| modified <= value));
+    let status = if not_modified {
+        StatusCode::NOT_MODIFIED
+    } else {
+        StatusCode::OK
+    };
+    let content_type = if name == "atom.xml" {
+        "application/atom+xml; charset=utf-8"
+    } else {
+        "application/rss+xml; charset=utf-8"
+    };
+    Response::builder()
+        .status(status)
+        .header(header::CONTENT_TYPE, content_type)
+        .header(header::CACHE_CONTROL, "public, max-age=60")
+        .header(header::ETAG, etag)
+        .header(header::LAST_MODIFIED, httpdate::fmt_http_date(modified))
+        .body(if not_modified {
+            Body::empty()
+        } else {
+            Body::from(body)
+        })
+        .expect("the feed response is valid")
+}
+
+fn etag_matches(value: &str, etag: &str) -> bool {
+    value.split(',').any(|candidate| {
+        let candidate = candidate.trim();
+        candidate == "*" || candidate.strip_prefix("W/").unwrap_or(candidate) == etag
+    })
+}
+
+fn encode_hex(bytes: &[u8]) -> String {
+    let mut output = String::with_capacity(bytes.len() * 2);
+    for byte in bytes {
+        output.push(char::from(b"0123456789abcdef"[usize::from(byte >> 4)]));
+        output.push(char::from(b"0123456789abcdef"[usize::from(byte & 0x0f)]));
+    }
+    output
+}
+
 fn route_error(error: RouteError, request_id: &str) -> Response {
     match error {
         RouteError::NotFound => render_error(
@@ -788,6 +953,12 @@
 struct RepositoryPath {
     owner: String,
     repository: String,
+}
+
+#[derive(Default, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct FeedQuery {
+    before: Option<i64>,
 }
 
 #[derive(Debug, Deserialize, Eq, PartialEq)]

src/main.rs

Mode 100644100644; object b7137c4ba92704feabf673b1

@@ -8,6 +8,7 @@
 mod cli;
 mod config;
 mod domain;
+mod feed;
 #[allow(dead_code, reason = "M1C proves Git reads before the CLI serves them")]
 mod git;
 #[allow(

src/store/migrations/006_repository_events.sql

Mode 100644; object 0c7b9eff1da7

@@ -1,0 +1,41 @@
+CREATE TABLE repository_event (
+    id INTEGER PRIMARY KEY,
+    repository_id TEXT NOT NULL
+        REFERENCES repository (id) ON DELETE RESTRICT,
+    source_intent_id TEXT
+        REFERENCES git_operation_intent (id) ON DELETE RESTRICT,
+    source_ordinal INTEGER CHECK (source_ordinal IS NULL OR source_ordinal >= 0),
+    kind TEXT NOT NULL
+        CHECK (kind IN (
+            'repository-created', 'repository-imported', 'push',
+            'ref-created', 'ref-updated', 'ref-deleted',
+            'tag-created', 'tag-updated', 'tag-deleted'
+        )),
+    actor TEXT NOT NULL CHECK (length(actor) BETWEEN 1 AND 256),
+    ref_name BLOB,
+    old_target TEXT,
+    new_target TEXT,
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    UNIQUE (source_intent_id, source_ordinal),
+    CHECK (
+        (source_intent_id IS NULL AND source_ordinal IS NULL)
+        OR (source_intent_id IS NOT NULL AND source_ordinal IS NOT NULL)
+    ),
+    CHECK (
+        (kind IN ('repository-created', 'repository-imported', 'push')
+            AND ref_name IS NULL AND old_target IS NULL AND new_target IS NULL)
+        OR
+        (kind LIKE 'ref-%' OR kind LIKE 'tag-%')
+            AND ref_name IS NOT NULL
+            AND (old_target IS NOT NULL OR new_target IS NOT NULL)
+    )
+) STRICT;
+
+INSERT INTO repository_event (repository_id, kind, actor, created_at)
+SELECT repository.id, 'repository-created', account.username, repository.created_at
+FROM repository
+JOIN account ON account.id = repository.owner_account_id
+ORDER BY repository.created_at, repository.id;
+
+CREATE INDEX repository_event_feed
+ON repository_event (repository_id, id DESC);

src/store/mod.rs

Mode 100644100644; object 31509e1ba88e7a572023ebd5

@@ -4,12 +4,12 @@
 
 use rusqlite::OpenFlags;
 use rusqlite::backup::Backup;
-use rusqlite::{Connection, TransactionBehavior};
+use rusqlite::{Connection, OptionalExtension, TransactionBehavior};
 use thiserror::Error;
 
 const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
 const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
-const SCHEMA_VERSION: i64 = 5;
+const SCHEMA_VERSION: i64 = 6;
 #[allow(
     dead_code,
     reason = "the integration test imports this module without the CLI operation"
@@ -19,12 +19,13 @@
     dead_code,
     reason = "M1A proves migrations before the M2 server calls them"
 )]
-const MIGRATIONS: [&str; 5] = [
+const MIGRATIONS: [&str; 6] = [
     include_str!("migrations/001_initial.sql"),
     include_str!("migrations/002_state.sql"),
     include_str!("migrations/003_git_intents.sql"),
     include_str!("migrations/004_identity.sql"),
     include_str!("migrations/005_repository.sql"),
+    include_str!("migrations/006_repository_events.sql"),
 ];
 
 #[derive(Debug, Error)]
@@ -65,6 +66,14 @@
     RepositoryIdentifierCollision,
     #[error("repository is already archived: {0}/{1}")]
     RepositoryArchived(String, String),
+    #[allow(
+        dead_code,
+        reason = "some integration tests import storage without public event pages"
+    )]
+    #[error("repository event page limit is too large")]
+    EventLimit,
+    #[error("stored Git reference event is malformed")]
+    EventPayload,
 }
 
 pub(crate) struct Store {
@@ -241,11 +250,28 @@
         let transaction = self
             .connection
             .transaction_with_behavior(TransactionBehavior::Immediate)?;
-        let payload: Vec<u8> = transaction.query_row(
-            "SELECT event_payload FROM git_operation_intent
+        let (payload, repository_path, actor, initial_refs, proposed_refs, created_at): (
+            Vec<u8>,
+            String,
+            String,
+            Vec<u8>,
+            Vec<u8>,
+            i64,
+        ) = transaction.query_row(
+            "SELECT event_payload, repository_path, actor, initial_refs, proposed_refs, created_at
+             FROM git_operation_intent
              WHERE id = ?1 AND state = 'promoted'",
             [id],
-            |row| row.get(0),
+            |row| {
+                Ok((
+                    row.get(0)?,
+                    row.get(1)?,
+                    row.get(2)?,
+                    row.get(3)?,
+                    row.get(4)?,
+                    row.get(5)?,
+                ))
+            },
         )?;
         let changed = transaction.execute(
             "UPDATE git_operation_intent SET state = 'completed'
@@ -256,6 +282,15 @@
         transaction.execute(
             "INSERT INTO git_operation_event (intent_id, payload) VALUES (?1, ?2)",
             rusqlite::params![id, payload],
+        )?;
+        insert_push_events(
+            &transaction,
+            id,
+            &repository_path,
+            &actor,
+            &initial_refs,
+            &proposed_refs,
+            created_at,
         )?;
         transaction.commit()?;
         Ok(())
@@ -359,7 +394,42 @@
             ],
         );
         match result {
-            Ok(1) => transaction.commit()?,
+            Ok(1) => {
+                transaction.execute(
+                    "INSERT INTO repository_event
+                     (repository_id, kind, actor, created_at)
+                     VALUES (?1, ?2, ?3, ?4)",
+                    rusqlite::params![
+                        repository.id,
+                        repository.origin.event_kind(),
+                        repository.owner,
+                        repository.created_at,
+                    ],
+                )?;
+                for reference in repository.initial_references {
+                    let kind = if reference.name.starts_with(b"refs/tags/") {
+                        "tag-created"
+                    } else if reference.name.starts_with(b"refs/heads/") {
+                        "ref-created"
+                    } else {
+                        return Err(StoreError::EventPayload);
+                    };
+                    transaction.execute(
+                        "INSERT INTO repository_event
+                         (repository_id, kind, actor, ref_name, new_target, created_at)
+                         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+                        rusqlite::params![
+                            repository.id,
+                            kind,
+                            repository.owner,
+                            reference.name,
+                            reference.target,
+                            repository.created_at,
+                        ],
+                    )?;
+                }
+                transaction.commit()?;
+            }
             Ok(_) => unreachable!("an INSERT changes one row"),
             Err(error) if is_unique_constraint(&error) => {
                 let duplicate_id: bool = transaction.query_row(
@@ -494,6 +564,42 @@
             Err(error) => Err(error.into()),
         }
     }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests import storage without public event pages"
+    )]
+    pub(crate) fn public_repository_events(
+        &self,
+        owner: &str,
+        slug: &str,
+        before: Option<i64>,
+        limit: usize,
+    ) -> Result<(RepositoryRecord, Vec<RepositoryEventRecord>), StoreError> {
+        let repository = self.public_repository(owner, slug)?;
+        let limit = i64::try_from(limit).map_err(|_| StoreError::EventLimit)?;
+        let mut statement = self.connection.prepare(
+            "SELECT id, kind, actor, ref_name, old_target, new_target, created_at
+             FROM repository_event
+             WHERE repository_id = ?1 AND (?2 IS NULL OR id < ?2)
+             ORDER BY id DESC
+             LIMIT ?3",
+        )?;
+        let events = statement
+            .query_map(rusqlite::params![repository.id, before, limit], |row| {
+                Ok(RepositoryEventRecord {
+                    id: row.get(0)?,
+                    kind: row.get(1)?,
+                    actor: row.get(2)?,
+                    ref_name: row.get(3)?,
+                    old_target: row.get(4)?,
+                    new_target: row.get(5)?,
+                    created_at: row.get(6)?,
+                })
+            })?
+            .collect::<Result<Vec<_>, _>>()?;
+        Ok((repository, events))
+    }
 }
 
 pub(crate) struct InitialAdministrator<'a> {
@@ -510,6 +616,32 @@
     pub(crate) slug: &'a str,
     pub(crate) object_format: &'a str,
     pub(crate) created_at: i64,
+    pub(crate) origin: RepositoryOrigin,
+    pub(crate) initial_references: &'a [NewRepositoryReference],
+}
+
+#[derive(Clone, Copy)]
+#[allow(
+    dead_code,
+    reason = "some integration tests create repositories without the import operation"
+)]
+pub(crate) enum RepositoryOrigin {
+    Created,
+    Imported,
+}
+
+impl RepositoryOrigin {
+    fn event_kind(self) -> &'static str {
+        match self {
+            Self::Created => "repository-created",
+            Self::Imported => "repository-imported",
+        }
+    }
+}
+
+pub(crate) struct NewRepositoryReference {
+    pub(crate) name: Vec<u8>,
+    pub(crate) target: String,
 }
 
 #[derive(Debug, Eq, PartialEq)]
@@ -522,6 +654,20 @@
     pub(crate) object_format: String,
     pub(crate) created_at: i64,
     pub(crate) archived_at: Option<i64>,
+}
+
+#[allow(
+    dead_code,
+    reason = "some integration tests import storage without public event pages"
+)]
+pub(crate) struct RepositoryEventRecord {
+    pub(crate) id: i64,
+    pub(crate) kind: String,
+    pub(crate) actor: String,
+    pub(crate) ref_name: Option<Vec<u8>>,
+    pub(crate) old_target: Option<String>,
+    pub(crate) new_target: Option<String>,
+    pub(crate) created_at: i64,
 }
 
 pub(crate) struct GitOperationIntent<'a> {
@@ -543,6 +689,116 @@
     pub(crate) quarantine_path: String,
     pub(crate) state: String,
     pub(crate) pack_name: Option<String>,
+}
+
+fn insert_push_events(
+    transaction: &rusqlite::Transaction<'_>,
+    intent_id: &str,
+    repository_path: &str,
+    actor: &str,
+    initial_bytes: &[u8],
+    proposed_bytes: &[u8],
+    created_at: i64,
+) -> Result<(), StoreError> {
+    let Some(repository_id) = managed_repository_id(repository_path) else {
+        return Ok(());
+    };
+    let exists = transaction
+        .query_row(
+            "SELECT id FROM repository WHERE id = ?1",
+            [repository_id],
+            |row| row.get::<_, String>(0),
+        )
+        .optional()?;
+    if exists.is_none() {
+        return Ok(());
+    }
+
+    let initial = parse_event_refs(initial_bytes)?;
+    let proposed = parse_event_refs(proposed_bytes)?;
+    if initial.len() != proposed.len() {
+        return Err(StoreError::EventPayload);
+    }
+    transaction.execute(
+        "INSERT INTO repository_event
+         (repository_id, source_intent_id, source_ordinal, kind, actor, created_at)
+         VALUES (?1, ?2, 0, 'push', ?3, ?4)",
+        rusqlite::params![repository_id, intent_id, actor, created_at],
+    )?;
+    for (index, ((old, old_name), (new, new_name))) in initial.into_iter().zip(proposed).enumerate()
+    {
+        if old_name != new_name || (is_null_id(&old) && is_null_id(&new)) {
+            return Err(StoreError::EventPayload);
+        }
+        let prefix = if old_name.starts_with(b"refs/tags/") {
+            "tag"
+        } else if old_name.starts_with(b"refs/heads/") {
+            "ref"
+        } else {
+            return Err(StoreError::EventPayload);
+        };
+        let action = if is_null_id(&old) {
+            "created"
+        } else if is_null_id(&new) {
+            "deleted"
+        } else {
+            "updated"
+        };
+        let kind = format!("{prefix}-{action}");
+        let old_target = (!is_null_id(&old)).then_some(old);
+        let new_target = (!is_null_id(&new)).then_some(new);
+        let ordinal = i64::try_from(index + 1).map_err(|_| StoreError::EventPayload)?;
+        transaction.execute(
+            "INSERT INTO repository_event
+             (repository_id, source_intent_id, source_ordinal, kind, actor,
+              ref_name, old_target, new_target, created_at)
+             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
+            rusqlite::params![
+                repository_id,
+                intent_id,
+                ordinal,
+                kind,
+                actor,
+                old_name,
+                old_target,
+                new_target,
+                created_at,
+            ],
+        )?;
+    }
+    Ok(())
+}
+
+fn managed_repository_id(repository_path: &str) -> Option<&str> {
+    let name = Path::new(repository_path).file_name()?.to_str()?;
+    let id = name.strip_suffix(".git")?;
+    (id.len() == 32 && id.bytes().all(|byte| byte.is_ascii_hexdigit())).then_some(id)
+}
+
+fn parse_event_refs(bytes: &[u8]) -> Result<Vec<(String, Vec<u8>)>, StoreError> {
+    let mut references = Vec::new();
+    for line in bytes.split(|byte| *byte == b'\n') {
+        if line.is_empty() {
+            continue;
+        }
+        let Some(space) = line.iter().position(|byte| *byte == b' ') else {
+            return Err(StoreError::EventPayload);
+        };
+        let id = std::str::from_utf8(&line[..space]).map_err(|_| StoreError::EventPayload)?;
+        if !matches!(id.len(), 40 | 64) || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+            return Err(StoreError::EventPayload);
+        }
+        let name = line[space + 1..].to_vec();
+        if name.is_empty() {
+            return Err(StoreError::EventPayload);
+        }
+        references.push((id.to_owned(), name));
+    }
+    Ok(references)
+}
+
+fn is_null_id(id: &str) -> bool {
+    id.bytes().all(|byte| byte == b'0')
 }
 
 fn require_one_intent(id: &str, changed: usize) -> Result<(), StoreError> {

templates/repository.html

Mode 100644100644; object 02a5bb605c73a236daaf5ebf

@@ -6,6 +6,8 @@
     <nav aria-label="Repository">
       <a href="/{{ owner }}/{{ repository }}">Summary</a>
       <a href="/{{ owner }}/{{ repository }}/refs">Refs</a>
+      <a href="/{{ owner }}/{{ repository }}/atom.xml">Atom</a>
+      <a href="/{{ owner }}/{{ repository }}/rss.xml">RSS</a>
 {% if has_head %}
       <a href="/{{ owner }}/{{ repository }}/tree/{{ commit_id }}">Tree</a>
       <a href="/{{ owner }}/{{ repository }}/commit/{{ commit_id }}">Commit</a>

tests/cli.rs

Mode 100644100644; object 1e9b2ef0290291b9aaa1e01d

@@ -12,7 +12,11 @@
 };
 use tempfile::TempDir;
 
-const V5_DATABASE: &str = include_str!("fixtures/sqlite/v5.sql");
+const V6_DATABASE: &str = concat!(
+    include_str!("fixtures/sqlite/v5.sql"),
+    include_str!("../src/store/migrations/006_repository_events.sql"),
+    "PRAGMA user_version = 6;\n",
+);
 
 #[test]
 fn help_and_version_use_standard_output() {
@@ -67,7 +71,7 @@
     let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
         .expect("open the instance database");
     database
-        .execute_batch(V5_DATABASE)
+        .execute_batch(V6_DATABASE)
         .expect("create the current database");
     drop(database);
 
@@ -119,7 +123,7 @@
     let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
         .expect("open the instance database");
     database
-        .execute_batch(V5_DATABASE)
+        .execute_batch(V6_DATABASE)
         .expect("create the current database");
     database
         .pragma_update(None, "foreign_keys", false)

tests/public_routes.rs

Mode 100644100644; object 342a3023b837f8a6474db2c4

@@ -6,6 +6,8 @@
 mod auth;
 #[path = "../src/domain/mod.rs"]
 mod domain;
+#[path = "../src/feed.rs"]
+mod feed;
 #[allow(
     dead_code,
     reason = "the public-route test does not use each shared Git API"
@@ -41,7 +43,7 @@
 use std::process::Command;
 
 use http::{PublicWebConfig, RunningWebServer};
-use store::{InitialAdministrator, NewRepository, Store};
+use store::{InitialAdministrator, NewRepository, RepositoryOrigin, Store};
 use tempfile::TempDir;
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
@@ -77,6 +79,106 @@
         assert!(!summary_text.contains("<img"));
         assert!(!summary_text.contains("tracker.example"));
         assert!(summary_text.contains("&#60;script&#62;alert(3)&#60;/script&#62;"));
+        assert!(summary_text.contains("/alice/example/atom.xml"));
+        assert!(summary_text.contains("/alice/example/rss.xml"));
+
+        let mut feed_entry_ids = Vec::new();
+        for (path, content_type) in [
+            (
+                "/alice/example/atom.xml",
+                "application/atom+xml; charset=utf-8",
+            ),
+            (
+                "/alice/example/rss.xml",
+                "application/rss+xml; charset=utf-8",
+            ),
+        ] {
+            let feed = request(server.address(), "GET", path, &[], &[]);
+            assert_eq!(feed.status, 200);
+            assert_eq!(feed.header("content-type"), content_type);
+            assert_eq!(feed.header("cache-control"), "public, max-age=60");
+            assert!(!feed.header("etag").is_empty());
+            assert!(!feed.header("last-modified").is_empty());
+            let parsed = feed_rs::parser::parse(feed.body.as_slice()).expect("parse the feed");
+            assert_eq!(parsed.entries.len(), 1);
+            assert!(parsed.entries[0].id.starts_with("urn:tit:event:"));
+            feed_entry_ids.push(parsed.entries[0].id.clone());
+            assert!(feed.text().contains("Repository imported"));
+
+            let etag = feed.header("etag");
+            let conditional = request(
+                server.address(),
+                "GET",
+                path,
+                &[("If-None-Match", etag)],
+                &[],
+            );
+            assert_eq!(conditional.status, 304);
+            assert!(conditional.body.is_empty());
+
+            let modified = request(
+                server.address(),
+                "GET",
+                path,
+                &[("If-Modified-Since", feed.header("last-modified"))],
+                &[],
+            );
+            assert_eq!(modified.status, 304);
+            assert!(modified.body.is_empty());
+
+            let head = request(server.address(), "HEAD", path, &[], &[]);
+            assert_eq!(head.status, 200);
+            assert!(head.body.is_empty());
+            assert_eq!(head.header("etag"), feed.header("etag"));
+        }
+        assert_eq!(feed_entry_ids[0], feed_entry_ids[1]);
+
+        let invalid_page = request(
+            server.address(),
+            "GET",
+            "/alice/example/atom.xml?before=0",
+            &[],
+            &[],
+        );
+        assert_eq!(invalid_page.status, 400);
+
+        let database = fixture.instance.path().join("tit.sqlite3");
+        let feed_store = Store::open(&database).expect("open the feed database");
+        for timestamp in 10..35 {
+            let actor = if timestamp == 34 {
+                "x</title><script>alert(5)</script>"
+            } else {
+                "alice"
+            };
+            feed_store
+                .connection()
+                .execute(
+                    "INSERT INTO repository_event
+                     (repository_id, kind, actor, created_at)
+                     VALUES (?1, 'push', ?2, ?3)",
+                    rusqlite::params![fixture.repository_id, actor, timestamp],
+                )
+                .expect("insert a page fixture event");
+        }
+        drop(feed_store);
+        let first_page = request(server.address(), "GET", "/alice/example/atom.xml", &[], &[]);
+        let first_feed =
+            feed_rs::parser::parse(first_page.body.as_slice()).expect("parse the first page");
+        assert_eq!(first_feed.entries.len(), 20);
+        assert!(!first_page.text().contains("<script>"));
+        assert!(first_page.text().contains("x&lt;/title&gt;&lt;script&gt;"));
+        let next = first_page
+            .text()
+            .split("rel=\"next\" href=\"")
+            .nth(1)
+            .and_then(|value| value.split('\"').next())
+            .expect("find the next-page URL")
+            .replace("https://tit.example", "");
+        let second_page = request(server.address(), "GET", &next, &[], &[]);
+        let second_feed =
+            feed_rs::parser::parse(second_page.body.as_slice()).expect("parse the second page");
+        assert_eq!(second_feed.entries.len(), 6);
+        assert!(!second_page.text().contains("rel=\"next\""));
 
         let empty = request(server.address(), "GET", "/alice/empty", &[], &[]);
         assert_eq!(empty.status, 200);
@@ -359,6 +461,8 @@
     for route in [
         "/alice/example".to_owned(),
         format!("/alice/example/raw/{head}/README.md"),
+        "/alice/example/atom.xml".to_owned(),
+        "/alice/example/rss.xml".to_owned(),
         "/alice/example/info/refs?service=git-upload-pack".to_owned(),
     ] {
         let response = request(address, "GET", &route, &[], &[]);
@@ -368,6 +472,7 @@
 
 struct Fixture {
     instance: TempDir,
+    repository_id: String,
     head: String,
     parent: String,
 }
@@ -389,6 +494,11 @@
         run(Command::new("git")
             .args(["init", "-q", "-b", "main", "--object-format", format])
             .arg(&worktree));
+        run(Command::new("git").arg("-C").arg(&worktree).args([
+            "config",
+            "commit.gpgsign",
+            "false",
+        ]));
         fs::write(
             worktree.join("README.md"),
             b"# tit fixture\n\n**safe** and `<safe>`\n\n[guide](docs/guide.md) [bad](javascript:alert(1))\n\n![tracker](https://tracker.example/pixel)\n\n<script>alert(2)</script>\n",
@@ -450,6 +560,8 @@
                 slug: "example",
                 object_format: format,
                 created_at: 2,
+                origin: RepositoryOrigin::Imported,
+                initial_references: &[],
             })
             .expect("create the repository record");
         store
@@ -459,12 +571,15 @@
                 slug: "empty",
                 object_format: format,
                 created_at: 2,
+                origin: RepositoryOrigin::Created,
+                initial_references: &[],
             })
             .expect("create the empty repository record");
         drop(store);
 
         Self {
             instance,
+            repository_id: id.to_owned(),
             head,
             parent,
         }

tests/sqlite.rs

Mode 100644100644; object 353eb3086014ec95cda5c1c7

@@ -9,13 +9,17 @@
 use std::{env, ffi::OsString, fs};
 
 use rusqlite::{Connection, ErrorCode, TransactionBehavior, params};
-use store::{GitOperationIntent, InitialAdministrator, NewRepository, Store, StoreError};
+use store::{
+    GitOperationIntent, InitialAdministrator, NewRepository, NewRepositoryReference,
+    RepositoryOrigin, Store, StoreError,
+};
 use tempfile::TempDir;
 
 const V1_FIXTURE: &str = include_str!("fixtures/sqlite/v1.sql");
 const V2_FIXTURE: &str = include_str!("fixtures/sqlite/v2.sql");
 const V3_FIXTURE: &str = include_str!("fixtures/sqlite/v3.sql");
 const V4_FIXTURE: &str = include_str!("fixtures/sqlite/v4.sql");
+const V5_FIXTURE: &str = include_str!("fixtures/sqlite/v5.sql");
 
 fn database(directory: &TempDir, name: &str) -> std::path::PathBuf {
     directory.path().join(name)
@@ -133,7 +137,7 @@
     let directory = TempDir::new().expect("create a temporary directory");
     let store = Store::open(&database(&directory, "store.sqlite")).expect("open the store");
 
-    assert_eq!(store.schema_version().expect("read the schema version"), 5);
+    assert_eq!(store.schema_version().expect("read the schema version"), 6);
     assert_eq!(
         store
             .connection()
@@ -304,12 +308,24 @@
             created_at: 10,
         })
         .expect("create an account");
+    let initial_references = [
+        NewRepositoryReference {
+            name: b"refs/heads/main".to_vec(),
+            target: "1".repeat(64),
+        },
+        NewRepositoryReference {
+            name: b"refs/tags/v1".to_vec(),
+            target: "2".repeat(64),
+        },
+    ];
     let repository = NewRepository {
         id: "00112233445566778899aabbccddeeff",
         owner: "alice",
         slug: "project",
         object_format: "sha256",
         created_at: 20,
+        origin: RepositoryOrigin::Imported,
+        initial_references: &initial_references,
     };
     store
         .create_repository(&repository)
@@ -325,6 +341,51 @@
     assert_eq!(created.object_format, "sha256");
     assert_eq!(created.created_at, 20);
     assert_eq!(created.archived_at, None);
+    let (_, imported_events) = store
+        .public_repository_events("alice", "project", None, 10)
+        .expect("read imported repository events");
+    assert_eq!(imported_events.len(), 3);
+    assert_eq!(imported_events[0].kind, "tag-created");
+    assert_eq!(imported_events[1].kind, "ref-created");
+    assert_eq!(imported_events[2].kind, "repository-imported");
+
+    let initial = format!("{} refs/heads/main\n", "0".repeat(64));
+    let proposed = format!("{} refs/heads/main\n", "a".repeat(64));
+    let push = GitOperationIntent {
+        id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+        repository_path: "/srv/tit/repositories/00112233445566778899aabbccddeeff.git",
+        actor: "alice",
+        initial_refs: initial.as_bytes(),
+        proposed_refs: proposed.as_bytes(),
+        event_payload: proposed.as_bytes(),
+        quarantine_path: "/srv/tit/quarantine/push",
+        created_at: 21,
+    };
+    store.begin_git_intent(&push).expect("begin a managed push");
+    let (_, pending_events) = store
+        .public_repository_events("alice", "project", None, 10)
+        .expect("read events while a push is pending");
+    assert_eq!(pending_events.len(), 3);
+    store
+        .mark_git_objects_promoted(push.id, None)
+        .expect("promote a managed push");
+    let (_, promoted_events) = store
+        .public_repository_events("alice", "project", None, 10)
+        .expect("read events while a push is promoted");
+    assert_eq!(promoted_events.len(), 3);
+    store
+        .complete_git_intent(push.id)
+        .expect("complete a managed push");
+    let (_, pushed_events) = store
+        .public_repository_events("alice", "project", None, 10)
+        .expect("read pushed repository events");
+    assert_eq!(pushed_events.len(), 5);
+    assert_eq!(pushed_events[0].kind, "ref-created");
+    assert_eq!(
+        pushed_events[0].new_target.as_deref(),
+        Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
+    );
+    assert_eq!(pushed_events[1].kind, "push");
 
     assert!(matches!(
         store.create_repository(&repository),
@@ -345,6 +406,8 @@
         slug: "project",
         object_format: "sha1",
         created_at: 20,
+        origin: RepositoryOrigin::Created,
+        initial_references: &[],
     };
     assert!(matches!(
         store.create_repository(&missing_owner),
@@ -708,13 +771,14 @@
         (V2_FIXTURE, 2),
         (V3_FIXTURE, 3),
         (V4_FIXTURE, 4),
+        (V5_FIXTURE, 5),
     ] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "tit.sqlite3");
         create_fixture(&path, fixture);
 
         let store = Store::open(&path).expect("migrate the fixture");
-        assert_eq!(store.schema_version().expect("read the schema version"), 5);
+        assert_eq!(store.schema_version().expect("read the schema version"), 6);
         store.integrity_check().expect("check migrated integrity");
         let state: String = store
             .connection()
@@ -742,8 +806,45 @@
 }
 
 #[test]
+fn backfills_repository_events_when_version_five_is_migrated() {
+    let directory = TempDir::new().expect("create a temporary directory");
+    let path = database(&directory, "tit.sqlite3");
+    create_fixture(&path, V5_FIXTURE);
+    let connection = Connection::open(&path).expect("open the version-five fixture");
+    connection
+        .execute(
+            "INSERT INTO account
+             (id, username, is_administrator, state, created_at)
+             VALUES (1, 'alice', 1, 'active', 1)",
+            [],
+        )
+        .expect("insert a historical account");
+    connection
+        .execute(
+            "INSERT INTO repository
+             (id, owner_account_id, slug, visibility, state, object_format, created_at)
+             VALUES ('00112233445566778899aabbccddeeff', 1, 'project', 'public',
+                     'active', 'sha1', 2)",
+            [],
+        )
+        .expect("insert a historical repository");
+    connection
+        .pragma_update(None, "user_version", 5)
+        .expect("set the historical schema version");
+    drop(connection);
+
+    let store = Store::open(&path).expect("migrate the version-five fixture");
+    let (_, events) = store
+        .public_repository_events("alice", "project", None, 10)
+        .expect("read the backfilled event");
+    assert_eq!(events.len(), 1);
+    assert_eq!(events[0].kind, "repository-created");
+    assert_eq!(events[0].created_at, 2);
+}
+
+#[test]
 fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
-    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 5)] {
+    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 6)] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "fixture.sqlite");
         create_fixture(&path, V1_FIXTURE);

tests/web_shell.rs

Mode 100644100644; object 103c842f0b8aabf37bf22e15

@@ -7,6 +7,8 @@
 )]
 #[path = "../src/domain/mod.rs"]
 mod domain;
+#[path = "../src/feed.rs"]
+mod feed;
 #[allow(dead_code, reason = "the shell test does not use each shared Git API")]
 #[path = "../src/git/mod.rs"]
 mod git;