chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

Commit authors by account; OAuth logins scoped to what knotless does

OAuth logins no longer ask for transition:generic. The loopback client's
scope names each dev.knotless record it writes, blobs of packs, and the
service calls it makes to gateways and moderation services, built from
the same constants those use, plus the space scope as before. Tests log
in with OAuth and do each kind of write, a delegation, a push and a
commit through a gateway, and a report, and find a record of another
kind refused.

Commits show as their authors' and co-authors' accounts. An account
claims the addresses its commits are authored as in a
dev.knotless.emails record, each hashed with its DID so the record names
none. Pages believe a claim only from a repository's owner and
collaborators, and take a commit knotless made as <did>@knotless.invalid
as that account's. A commit answer names its author and the co-authors
its Co-authored-by trailers give, each with account and picture where
one matches; the pages stack their pictures and link their names.

A first push claims the address git authors commits as there, if the
account has made no claim, and says so; knotless email add and remove
change it after. Pictures and claims are kept ten minutes. The shots
harness takes SHOTS_PORT, for a second checkout beside a first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chad Miller and Claude Opus 5.5 committed on Oct 21, 2026 parent 89c1740 commit a8d782c
Showing 128 changed files with 785 additions and 174 deletions
CLAUDE.md+2−1
@@ -21,7 +21,8 @@ work is done.
no PDS. Look at the PNGs it writes under
`harness/shots/baseline/<platform>/`. After a change to what a page's
answer holds, `just shots-up` in the background once, then `just
shots-record`.
shots-record`. If another checkout's shots hold the ports (2593 to
2595), set `SHOTS_PORT` to another base for all of these.
- In Svelte markup, keep a run of inline text one expression or one line:
a block or component beside it splits the text into nodes, which shapes
differently from the same text whole and moves pixels.
README.md+10−0
@@ -180,6 +180,16 @@ KNOTLESS_ADMIN_PASSWORD=… knotless admin reports
KNOTLESS_ADMIN_PASSWORD=… knotless admin takedown troll.test
```
Pages show a commit as its author's account, with its Bluesky picture,
when the address it was authored as is one the repository's owner or a
collaborator claims. Your first push claims the address git authors your
commits as, and says so; claims are kept hashed, so they name no address:
```sh
knotless email add me@work.example
knotless email remove me@old.example
```
## For agents
`knotless-mcp` is an MCP server over stdio. Logged in as the account it acts
crates/knotless-serve/src/web.rs+153−24
@@ -27,6 +27,7 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt::Write;
use std::sync::{Arc, LazyLock, Mutex};
use std::time::{Duration, Instant};
use ts_rs::TS;
mod feed;
@@ -189,7 +190,39 @@ async fn handle(app: &App, did: &str) -> String {
shown
}
static AVATARS: LazyLock<Mutex<HashMap<String, Option<String>>>> = LazyLock::new(Mutex::default);
/// How long what an account says of itself is kept before it is looked up
/// again: its picture, and the addresses it commits as.
const KEPT_FOR: Duration = Duration::from_secs(600);
/// Values looked up over the network, by key, each kept for a while.
struct Kept<V> {
values: Mutex<HashMap<String, (Instant, V)>>,
ttl: Duration,
}
impl<V: Clone> Kept<V> {
fn new(ttl: Duration) -> Kept<V> {
Kept {
values: Mutex::default(),
ttl,
}
}
/// The value kept for `key`, unless it is missing or too old.
fn get(&self, key: &str) -> Option<V> {
let values = self.values.lock().unwrap();
let (at, value) = values.get(key)?;
(at.elapsed() < self.ttl).then(|| value.clone())
}
fn put(&self, key: &str, value: V) {
let kept = (Instant::now(), value);
self.values.lock().unwrap().insert(key.to_string(), kept);
}
}
static AVATARS: LazyLock<Kept<Option<String>>> = LazyLock::new(|| Kept::new(KEPT_FOR));
static CLAIMS: LazyLock<Kept<Vec<String>>> = LazyLock::new(|| Kept::new(KEPT_FOR));
/// Where Bluesky's CDN serves profile pictures, by DID and blob CID.
const AVATAR_CDN: &str = "https://cdn.bsky.app/img/avatar/plain";
@@ -209,11 +242,11 @@ fn avatar_url(did: &str, about: &serde_json::Value) -> Option<String> {
Some(format!("{AVATAR_CDN}/{did}/{cid}@jpeg"))
}
/// `did`'s profile picture, if it has one: looked up once, and again
/// whenever its profile page is shown.
/// `did`'s profile picture, if it has one: looked up now and then, and
/// again whenever its profile page is shown.
async fn avatar(app: &App, did: &str) -> Option<String> {
if let Some(known) = AVATARS.lock().unwrap().get(did) {
return known.clone();
if let Some(known) = AVATARS.get(did) {
return known;
}
let at = RepoUrl {
authority: did.to_string(),
@@ -222,10 +255,18 @@ async fn avatar(app: &App, did: &str) -> Option<String> {
let remote = Remote::connect(&app.ctx, &at).await.ok();
let about = futures::future::OptionFuture::from(remote.as_ref().map(bsky_profile)).await;
let found = avatar_url(did, &about.unwrap_or_default());
AVATARS
.lock()
.unwrap()
.insert(did.to_string(), found.clone());
AVATARS.put(did, found.clone());
found
}
/// The addresses `did` claims to commit as, hashed (`knotless::emails`).
async fn claims(app: &App, did: &str) -> Vec<String> {
if let Some(known) = CLAIMS.get(did) {
return known;
}
let found = knotless::emails::claimed(&app.ctx, did).await;
let found = found.unwrap_or_default();
CLAIMS.put(did, found.clone());
found
}
@@ -278,21 +319,90 @@ async fn repo_link(app: &App, repo: &RepoRef) -> Option<Link> {
pub struct Commit {
sha: String,
parents: Vec<String>,
author: String,
author: Author,
/// Who its message's `Co-authored-by` trailers name.
coauthors: Vec<Author>,
date: String,
message: String,
}
impl From<read::Commit> for Commit {
fn from(c: read::Commit) -> Commit {
Commit {
/// Who wrote a commit or helped to: by the name git gives, and by account,
/// with its picture, when that is one of the repository's own people whose
/// address it is (`knotless::emails`).
#[derive(Serialize, TS, Clone, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(export)]
pub struct Author {
name: String,
/// Its handle.
account: Option<String>,
avatar: Option<String>,
}
/// The repository's people, by DID, with the addresses they claim, hashed.
type People = [(String, Vec<String>)];
/// `name` at `email`, by account if `email` is one of `people`'s.
async fn author(app: &App, people: &People, name: String, email: &str) -> Author {
let by = people
.iter()
.find(|(did, hashes)| knotless::emails::is(did, email, hashes));
let (account, avatar) = match by {
Some((did, _)) => (Some(handle(app, did).await), avatar(app, did).await),
None => (None, None),
};
Author {
name,
account,
avatar,
}
}
/// The co-authors a commit message's closing paragraph names, as
/// `Co-authored-by: Name <email>` trailers: names and addresses.
fn coauthors(message: &str) -> Vec<(String, String)> {
let trailers = message
.trim_end()
.rsplit_once("\n\n")
.map_or("", |(_, t)| t);
trailers.lines().filter_map(coauthor).collect()
}
fn coauthor(line: &str) -> Option<(String, String)> {
let (key, value) = line.split_once(':')?;
let named = key.trim().eq_ignore_ascii_case("co-authored-by");
let (name, email) = value.split_once('<').filter(|_| named)?;
let email = email.trim_end().strip_suffix('>')?;
Some((name.trim().to_string(), email.trim().to_string()))
}
/// `commits`, as the pages show them, each with the accounts that wrote it,
/// where those are the repository's owner or collaborators.
async fn shown(app: &App, repo: &Repo, commits: Vec<read::Commit>) -> Vec<Commit> {
let record = repo.record(app).unwrap_or_default();
let dids =
std::iter::once(repo.did.clone()).chain(record.collaborators.into_iter().map(|c| c.did));
let people = futures::future::join_all(dids.map(|did| async {
let hashes = claims(app, &did).await;
(did, hashes)
}))
.await;
let mut out = Vec::new();
for c in commits {
let mut helped = Vec::new();
for (name, email) in coauthors(&c.message) {
helped.push(author(app, &people, name, &email).await);
}
out.push(Commit {
sha: c.sha,
parents: c.parents,
author: c.author.name,
author: author(app, &people, c.author.name, &c.author.email).await,
coauthors: helped,
date: c.author.date,
message: c.message,
}
});
}
out
}
/// What every page about a repository shows above itself.
@@ -542,7 +652,7 @@ async fn profile(State(app): State<Arc<App>>, Path(authority): Path<String>) ->
let text = |field: &str| about[field].as_str().map(str::to_string);
let name = text("displayName").filter(|n| !n.trim().is_empty());
let avatar = avatar_url(&did, &about);
AVATARS.lock().unwrap().insert(did.clone(), avatar.clone());
AVATARS.put(&did, avatar.clone());
let mut found = remote.list_repos().await.map_err(AppError::upstream)?;
let mut private = Vec::new();
if app.private
@@ -709,7 +819,7 @@ async fn listing(app: &App, repo: &Repo, rev: &str, path: &str) -> Result<Respon
rev: rev.to_string(),
path: path.to_string(),
empty: false,
last: last.into_iter().next().map(Commit::from),
last: shown(app, repo, last).await.pop(),
entries,
readme,
},
@@ -907,7 +1017,7 @@ async fn commits(
path: path.to_string(),
page,
more: log.len() == COMMITS_PER_PAGE,
commits: log.into_iter().map(Commit::from).collect(),
commits: shown(&app, &repo, log).await,
rev,
},
))
@@ -1020,11 +1130,8 @@ async fn commit(
) -> Result<Response> {
let repo = Repo::open(&app, &authority, &name).await?;
let rev = read::rev(Some(&rev)).map_err(not_found)?;
let c = repo
.objects()?
.log(&rev, "", 0, 1)
.map_err(not_found)?
.remove(0);
let log = repo.objects()?.log(&rev, "", 0, 1).map_err(not_found)?;
let c = shown(&app, &repo, log).await.remove(0);
let patch = repo
.git
.text(
@@ -1048,7 +1155,7 @@ async fn commit(
cache_for(&rev),
Change {
repo: repo.head(&app).await,
commit: c.into(),
commit: c,
files,
},
))
@@ -1378,6 +1485,28 @@ async fn item_card_png(
mod tests {
use super::*;
#[test]
fn coauthor_trailers() {
let message = "Fix it\n\nCo-authored-by: Claude <noreply@anthropic.com>\nSigned-off-by: A <a@x>\nco-authored-by: B < b@x > \nCo-authored-by: nobody\n";
let named = |n: &str, e: &str| (n.to_string(), e.to_string());
assert_eq!(
coauthors(message),
[named("Claude", "noreply@anthropic.com"), named("B", "b@x")]
);
assert!(coauthors("Co-authored-by: A <a@x>").is_empty());
assert!(coauthors("Subject\n\nCo-authored-by: A <a@x").is_empty());
}
#[test]
fn kept_values_go_stale() {
let stale = Kept::new(Duration::ZERO);
stale.put("k", 1);
assert_eq!(stale.get("k"), None);
let fresh = Kept::new(KEPT_FOR);
fresh.put("k", 1);
assert_eq!((fresh.get("k"), fresh.get("j")), (Some(1), None));
}
#[test]
fn readme_links() {
let base = "/a/r/blob/main";
crates/knotless-serve/tests/forge.rs+4−1
@@ -788,7 +788,10 @@ fn moderating() {
ok(&owner, &["unblock", &troll.account.handle, &url]);
listed(&reporter, &url, trolling);
// Reports go to the appview's moderation service.
// Reports go to the appview's moderation service, from an OAuth login
// too, whose scope names the call.
let handle = reporter.account.handle.clone();
assert_eq!(reporter.cli(&["login", &handle], b"").0, 0);
let sent = ok(
&reporter,
&["report", trolling, "spam", "-m", "the same thing again"],
crates/knotless-serve/tests/gateway.rs+40−0
@@ -102,6 +102,46 @@ fn post(url: &str, token: Option<&str>, body: Vec<u8>, json: bool) -> (u16, Stri
(res.status().as_u16(), res.text().unwrap())
}
/// `person`, logged in with OAuth rather than a password: scoped to what
/// knotless asks for.
fn oauth_person(account: Account) -> Sandbox {
let mut s = web(Sandbox::with(account));
s.set("KNOTLESS_PASSWORD", "");
s.set("KNOTLESS_BROWSER", &browser(&s.account.clone(), ""));
let handle = s.account.handle.clone();
assert_eq!(s.cli(&["login", &handle], b"").0, 0);
s
}
/// What an OAuth login calls a gateway for, its scope allows: delegating
/// to it, and pushing and committing through it.
#[test]
fn oauth_logins_reach_a_gateway() {
let gw = gateway();
let owner = oauth_person(account());
let collab = oauth_person(account());
let url = owner.url("scoped");
let work = owner.work("w", 2);
owner.git(&work, &["push", "-q", &url, "main"]);
let (code, out) = owner.cli(&["delegate", &owner.account.handle, &gw.did], b"");
assert_eq!(code, 0, "{out}");
let shared = owner.cli(&["share", &url, &collab.account.handle, "--write"], b"");
assert_eq!(shared.0, 0, "{}", shared.1);
let clone = collab.path("clone");
collab.git(collab.dir.path(), &["clone", "-q", &url, "clone"]);
let sha = collab.commit_file(&clone, "theirs", "from a collaborator\n");
collab.git(&clone, &["push", "-q", "origin", "main"]);
let (_, refs) = owner.cli(&["refs", &url], b"");
assert!(refs.contains(&format!("{sha}\trefs/heads/main")), "{refs}");
let committed = collab.cli(
&[
"commit", &url, "-b", "main", "-m", "Scoped", "--put", "x", "-",
],
b"x\n",
);
assert_eq!(committed.0, 0, "{}", committed.1);
}
/// Pushes `refspec` from `dir` to its origin, as git would: the error, if
/// the push is refused.
fn refused(s: &Sandbox, dir: &std::path::Path, refspec: &str) -> Option<String> {
crates/knotless-serve/tests/web.rs+39−0
@@ -358,6 +358,45 @@ fn browsing_a_repository() {
),
(Some("a"), Some(4), Some("}"))
);
// A commit shows as its author's account when its address is one the
// repository's own people claim: the first push claimed git's. One
// knotless made names its account; one by any other address is no
// one's. So with co-authors, whom its trailers name.
assert_eq!(commit["commit"]["author"]["account"], h.as_str());
assert_eq!(top["last"]["author"]["account"], h.as_str());
let made = |message: &str, author: &[&str]| {
let args = [
&[
"commit", &url, "-b", "made", "-m", message, "--put", "made", "-",
],
author,
]
.concat();
let (code, sha) = s.cli(&args, message.as_bytes());
assert_eq!(code, 0, "{sha}");
let at = format!("{repo}/commit/{}", sha.trim());
wait_until(|| site.status(&format!("/_data{at}")).0 == 200);
site.data(&at)["commit"].clone()
};
let ours = made("Made", &[]);
assert_eq!(ours["author"]["account"], h.as_str());
assert_eq!(ours["coauthors"], serde_json::json!([]));
let together = "Together\n\nCo-authored-by: Claude <noreply@anthropic.com>\nCo-authored-by: Me <t@example.com>";
let theirs = made(together, &["--author", "Someone", "someone@example.org"]);
let (author, helped) = (&theirs["author"], &theirs["coauthors"]);
assert_eq!(
(&author["name"], &author["account"], &author["avatar"]),
(&Value::from("Someone"), &Value::Null, &Value::Null)
);
assert_eq!(
(&helped[0]["name"], &helped[0]["account"]),
(&Value::from("Claude"), &Value::Null)
);
assert_eq!(
(&helped[1]["name"], &helped[1]["account"]),
(&Value::from("Me"), &Value::from(h.as_str()))
);
let first = s.git(&work, &["rev-list", "--max-parents=0", "HEAD"]);
assert_eq!(
site.data(&format!("{repo}/commit/{first}"))["commit"]["parents"],
crates/knotless/src/cli.rs+17−0
@@ -2,6 +2,7 @@
use crate::commit::{self, Change, Spec};
use crate::compact;
use crate::emails;
use crate::env::{Ctx, Env};
use crate::forge;
use crate::git::Git;
@@ -100,6 +101,9 @@ const USAGE: &str = "usage: knotless <command>
unhide <url> <at-uri>
block <handle> [<url>] leave out what an account writes about
unblock <handle> [<url>] your repositories, or about one
email add <address> show commits by an address as yours,
email remove <address> on repositories you own or push to;
your first push adds the one git uses
report <handle, url or at-uri> <reason> [-m <text>]
report to the moderation service; reasons:
spam violation misleading sexual rude other
@@ -473,6 +477,19 @@ async fn command(
let did = moderation::block(ctx, who, repo.as_ref(), cmd == "block").await?;
writeln!(out, "{cmd}ed {did}")?;
}
"email" => {
let on = match args.next("add or remove")? {
"add" => true,
"remove" => false,
other => bail!("unknown email command {other}"),
};
let address = args.next("<address>")?;
let me = session::me(ctx).await?;
let changed = emails::set(ctx, &me, address, on).await?;
let done = if on { "added" } else { "removed" };
let said = if changed { done } else { "unchanged" };
writeln!(out, "{address} {said}")?;
}
"report" => {
let target = args.next("<handle, url or at-uri>")?;
let reason = args.next("<reason>")?;
crates/knotless/src/commit.rs+2−2
@@ -76,7 +76,7 @@ pub fn start(snap: &Snapshot, spec: &Spec) -> Option<String> {
fn signed(spec: &Spec, me: &Session) -> Spec {
let mut spec = spec.clone();
spec.author
.get_or_insert_with(|| (me.handle.clone(), format!("{}@knotless.invalid", me.did)));
.get_or_insert_with(|| (me.handle.clone(), crate::emails::own(&me.did)));
spec
}
@@ -244,7 +244,7 @@ pub async fn build(
let (name, email) = spec
.author
.clone()
.unwrap_or_else(|| ("knotless".into(), format!("{did}@knotless.invalid")));
.unwrap_or_else(|| ("knotless".into(), crate::emails::own(did)));
git.envs.extend([
("GIT_AUTHOR_NAME".into(), name.clone()),
("GIT_AUTHOR_EMAIL".into(), email.clone()),
crates/knotless/src/emails.rsnew+125−0
@@ -0,0 +1,125 @@
//! The email addresses an account's commits are authored as, claimed in
//! its own repo so that pages can show those commits as its. Each is kept
//! as a hash salted with the account's DID: the record names no address,
//! and one address claimed by two accounts hashes apart in each. Pages
//! believe a claim only from a repository's owner and collaborators.
use crate::env::Ctx;
use crate::git::Git;
use crate::records::{self, EmailsRecord, nsid};
use crate::remote::{self, Remote};
use crate::session::Session;
use crate::store;
use crate::url::RepoUrl;
use anyhow::Result;
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
/// The key of an account's one claim.
pub const RKEY: &str = "self";
/// The domain of the address knotless commits as for an account that
/// names no author: the account's DID is its local part.
const OWN: &str = "@knotless.invalid";
/// `email`, as `did` claims it.
pub fn hash(did: &str, email: &str) -> String {
let digest = Sha256::digest(format!("{did}\n{}", email.trim().to_lowercase()));
digest.iter().map(|b| format!("{b:02x}")).collect()
}
/// The address knotless commits as for `did`, given no author.
pub fn own(did: &str) -> String {
format!("{did}{OWN}")
}
/// Whether `email` is `did`'s: the address knotless commits as for it, or
/// one whose hash it claims.
pub fn is(did: &str, email: &str, hashes: &[String]) -> bool {
email == own(did) || hashes.contains(&hash(did, email))
}
async fn account(ctx: &Ctx, did: &str) -> Result<Remote> {
Remote::connect(ctx, &RepoUrl::parse(&format!("knotless://{did}/_"))?).await
}
/// `did`'s claim, if it has made one.
async fn claim(account: &Remote) -> Result<Option<EmailsRecord>> {
let found = account.get_record(nsid::EMAILS, RKEY).await?;
Ok(found.map(|r| r.value))
}
/// The hashes `did` claims.
pub async fn claimed(ctx: &Ctx, did: &str) -> Result<Vec<String>> {
let claim = claim(&account(ctx, did).await?).await?;
Ok(claim.unwrap_or_default().hashes)
}
/// Claims `email` as `me`'s, or takes the claim back: whether that
/// changed anything.
pub async fn set(ctx: &Ctx, me: &Session, email: &str, on: bool) -> Result<bool> {
let mine = account(ctx, &me.did).await?;
let found = claim(&mine).await?;
let write = if found.is_some() {
remote::update
} else {
remote::create
};
let mut hashes = found.unwrap_or_default().hashes;
let hashed = hash(&me.did, email);
if hashes.contains(&hashed) == on {
return Ok(false);
}
hashes.retain(|h| h != &hashed);
hashes.extend(on.then_some(hashed));
let record = EmailsRecord {
hashes,
updated_at: records::now(),
};
let value = records::typed(nsid::EMAILS, &record);
mine.apply(vec![write(nsid::EMAILS, RKEY, value)], &me.auth())
.await?;
Ok(true)
}
/// Claims the address git authors commits as here for `me`, if `me` has made no
/// claim yet: the address, when it did. After that the claim is theirs to
/// change, with `knotless email`. Each account is looked up once a machine.
pub async fn claim_first(ctx: &Ctx, git: &Git, me: &Session) -> Result<Option<String>> {
let path = ctx.env.cache_dir.join("emails.json");
let mut seen: BTreeSet<String> = store::read(&path).unwrap_or_default();
if seen.contains(&me.did) {
return Ok(None);
}
// As git would author a commit here: from its environment or config.
let ident = git.text(&["var", "GIT_AUTHOR_IDENT"], None).await;
let ident = ident.unwrap_or_default();
let email = ident
.split_once('<')
.and_then(|(_, rest)| rest.split_once('>'));
let email = email.map_or("", |(email, _)| email.trim()).to_string();
let first = !email.is_empty() && claim(&account(ctx, &me.did).await?).await?.is_none();
if first {
set(ctx, me, &email, true).await?;
}
seen.insert(me.did.clone());
store::write(&path, &seen, false)?;
Ok(first.then_some(email))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hashes_and_own_addresses() {
let h = hash("did:plc:a", " Me@Example.com ");
assert_eq!(h, hash("did:plc:a", "me@example.com"));
assert_ne!(h, hash("did:plc:b", "me@example.com"));
assert_eq!(h.len(), 64);
assert!(is("did:plc:a", "me@example.com", &[h]));
assert!(!is("did:plc:b", "me@example.com", &[]));
assert!(is("did:plc:a", "did:plc:a@knotless.invalid", &[]));
assert!(!is("did:plc:b", "did:plc:a@knotless.invalid", &[]));
}
}
crates/knotless/src/helper.rs+25−2
@@ -2,13 +2,14 @@
//! reader and writer, against a [`Backend`].
use crate::compact;
use crate::emails;
use crate::env::{CompactMode, Ctx};
use crate::fetch;
use crate::gateway;
use crate::git::Git;
use crate::push::{self, Report, Update};
use crate::remote::{Remote, Snapshot};
use crate::session;
use crate::session::{self, Session};
use crate::url::RepoUrl;
use anyhow::{Result, bail};
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWrite, AsyncWriteExt};
@@ -202,6 +203,24 @@ impl Knotless {
}
}
impl Knotless {
/// After a push that landed, claims the address git authors commits as here, the
/// first time `me` pushes from this machine, and says so. The push has
/// landed either way, so a claim that fails is left for the next.
async fn claim_email(&self, me: &Session, report: &Report) {
if !report.iter().any(|(_, r)| r.is_ok()) {
return;
}
if let Ok(Some(email)) = emails::claim_first(&self.ctx, &self.git, me).await {
eprintln!(
"knotless: commits by {email} now show as {}'s; \
`knotless email remove {email}` takes that back",
me.handle
);
}
}
}
impl Backend for Knotless {
async fn list(&mut self) -> Result<Listing> {
self.snap = None;
@@ -242,7 +261,10 @@ impl Backend for Knotless {
&& me.did != self.remote.did()
{
self.snap = None;
return gateway::push(&self.ctx, &self.remote, &snap, &self.git, &me, &updates).await;
let report =
gateway::push(&self.ctx, &self.remote, &snap, &self.git, &me, &updates).await?;
self.claim_email(&me, &report).await;
return Ok(report);
}
let auth = session::ensure(&self.ctx, &self.remote.identity).await?;
let report = push::push(
@@ -258,6 +280,7 @@ impl Backend for Knotless {
if report.iter().any(|(_, r)| r.is_ok()) {
self.after_push(&snap, &auth).await?;
}
self.claim_email(&auth, &report).await;
Ok(report)
}
}
crates/knotless/src/lib.rs+1−0
@@ -16,6 +16,7 @@ pub mod cli;
pub mod commit;
pub mod compact;
pub mod crypto;
pub mod emails;
pub mod env;
pub mod fetch;
pub mod forge;
crates/knotless/src/oauth.rs+30−10
@@ -8,9 +8,11 @@ use crate::env::Ctx;
use crate::identity::{self, Identity};
use crate::keys;
use crate::mirror::Lock;
use crate::records::{PACK_MIME, nsid};
use crate::session::{self, OAuth, Session};
use crate::store;
use crate::xrpc::Xrpc;
use crate::{gateway, moderation};
use anyhow::{Context, Result, bail};
use base64::Engine;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
@@ -21,19 +23,35 @@ use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashMap};
use std::sync::Mutex;
use std::sync::{LazyLock, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
const CALLBACK: &str = "/callback";
/// What knotless asks for: what an app password allows, and the spaces
/// private repositories live in, which only a `space` scope reaches. A
/// PDS without spaces drops the scope it does not know. The scope names
/// every space type: naming [`crate::space::SPACE_TYPE`] alone takes its
/// lexicon, which is not published yet.
pub const SCOPE: &str = "atproto transition:generic \
space:*?authority=*&collection=*&manage=create&manage=update&manage=delete";
/// What knotless asks for, and nothing more: to write the records it keeps
/// in an account, to upload the blobs that hold its packs, to call gateways
/// and moderation services as the account, and the spaces private
/// repositories live in. A PDS without spaces drops the scope it does not
/// know. The space scope names every space type: naming
/// [`crate::space::SPACE_TYPE`] alone takes its lexicon, which is not
/// published yet.
pub static SCOPE: LazyLock<String> = LazyLock::new(|| {
let mut scope = vec!["atproto".to_string()];
let written = nsid::ALL.iter().chain(&nsid::FORGE).chain([&nsid::EMAILS]);
scope.extend(written.map(|collection| format!("repo:{collection}")));
scope.push(format!("blob:{PACK_MIME}"));
let called = [
gateway::PUSH,
gateway::COMMIT,
gateway::DELEGATE,
gateway::AWAIT_DELEGATION,
moderation::REPORT,
];
scope.extend(called.map(|lxm| format!("rpc:{lxm}?aud=*")));
scope.push("space:*?authority=*&collection=*&manage=create&manage=update&manage=delete".into());
scope.join(" ")
});
/// The loopback client, which names its scope and redirect in its ID. The
/// redirect leaves out the port, so any port the listener gets matches.
@@ -42,7 +60,7 @@ pub fn client_id() -> String {
"http://localhost/",
&[
("redirect_uri", format!("http://127.0.0.1{CALLBACK}")),
("scope", SCOPE.to_string()),
("scope", SCOPE.clone()),
],
)
.expect("a static URL");
@@ -451,7 +469,7 @@ pub async fn start(ctx: &Ctx, identifier: &str) -> Result<Pending> {
id,
identifier,
&redirect_uri,
SCOPE,
&SCOPE,
)
.await?;
Ok(Pending {
@@ -625,5 +643,7 @@ mod tests {
"http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=atproto+"
));
assert!(SCOPE.contains(" space:*?authority=*&"));
assert!(SCOPE.contains(" repo:dev.knotless.pull "));
assert!(!SCOPE.contains("transition:"));
}
}
crates/knotless/src/records.rs+12−0
@@ -26,6 +26,8 @@ pub mod nsid {
/// what its owner hides of it and whom they keep out. A block names no
/// repository when it keeps someone out of all of them.
pub const FORGE: [&str; 6] = [PULL, ISSUE, COMMENT, STATE, HIDE, BLOCK];
/// The email addresses an account's commits are authored as.
pub const EMAILS: &str = "dev.knotless.emails";
}
pub const PACK_MIME: &str = "application/x-git-packed-objects";
@@ -173,6 +175,16 @@ pub struct RedirectRecord {
pub created_at: String,
}
/// The email addresses an account's commits are authored as, keyed
/// `self`: each a hash of the address salted with the account's DID
/// ([`crate::emails::hash`]), so the record names no address.
#[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EmailsRecord {
pub hashes: Vec<String>,
pub updated_at: String,
}
/// An account that may push to a repository. An object rather than a bare
/// DID, so what a collaborator may do can be said beside it later.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
crates/knotless/tests/cli.rs+61−0
@@ -26,6 +26,67 @@ fn sessions() {
assert_eq!(s.cli(&["login", &handle], b"").0, 0);
}
/// The first push claims the address git authors commits as; after that
/// the claim changes only when its account says.
#[test]
fn claiming_email_addresses() {
let s = Sandbox::new();
let (handle, password) = (&s.account.handle, &s.account.password);
assert_eq!(
s.cli(&["login", handle, "--password-stdin"], password.as_bytes())
.0,
0
);
let work = s.work("w", 1);
let (ok, _, err) = s.git_raw(&work, &["push", &s.url("claimed"), "main"]);
assert!(ok, "{err}");
assert!(
err.contains("commits by t@example.com now show as"),
"{err}"
);
let ctx = knotless::env::Ctx::new(s.env());
let claimed = || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(knotless::emails::claimed(&ctx, &s.account.did))
.unwrap()
};
let did = s.account.did.as_str();
assert_eq!(claimed(), [knotless::emails::hash(did, "t@example.com")]);
let email = |args: &[&str]| s.cli(&[&["email"], args].concat(), b"");
assert_eq!(
email(&["add", "t@example.com"]),
(0, "t@example.com unchanged".into())
);
assert_eq!(
email(&["add", "Other@Example.com"]),
(0, "Other@Example.com added".into())
);
assert_eq!(
email(&["remove", "t@example.com"]),
(0, "t@example.com removed".into())
);
assert_eq!(
email(&["remove", "t@example.com"]),
(0, "t@example.com unchanged".into())
);
assert_eq!(
claimed(),
[knotless::emails::hash(did, "other@example.com")]
);
assert_eq!(email(&["forget", "x"]).0, 1);
// Taken back, an address stays so: a later push, even from another
// machine, claims nothing.
let (_, _, err) = s.git_raw(&work, &["push", &s.url("again"), "main"]);
assert!(!err.contains("now show as"), "{err}");
let mut elsewhere = Sandbox::with(s.account.clone());
elsewhere.set("KNOTLESS_PASSWORD", &s.account.password);
let there = elsewhere.work("w", 1);
let (_, _, err) = elsewhere.git_raw(&there, &["push", &elsewhere.url("there"), "main"]);
assert!(!err.contains("now show as"), "{err}");
assert_eq!(claimed().len(), 1);
}
#[test]
fn repository_lifecycle() {
let s = Sandbox::new();
crates/knotless/tests/oauth.rs+41−0
@@ -90,6 +90,47 @@ fn logs_in_pushes_refreshes_and_logs_out() {
assert_eq!(s.cli(&["logout", &handle], b""), (0, "logged out".into()));
}
/// An OAuth login is scoped to what knotless writes: each kind of record
/// it keeps goes through, and a record of any other kind is refused.
#[test]
fn an_oauth_login_writes_only_what_knotless_keeps() {
let account = account();
let mut s = oauth_sandbox(account.clone(), &browser(&account, ""));
s.set("KNOTLESS_INLINE_MAX", "0");
let handle = s.account.handle.clone();
assert_eq!(s.cli(&["login", &handle], b"").0, 0);
let ok = |args: &[&str]| {
let (code, out) = s.cli(args, b"");
assert_eq!(code, 0, "{args:?}: {out}");
out.trim().to_string()
};
let url = s.url("scoped");
let work = s.work("w", 2);
s.git(&work, &["push", "-q", &url, "main"]);
s.git(&work, &["push", "-q", &url, "main:topic"]);
let issue = ok(&["issue", &url, "Scoped"]);
ok(&["comment", &url, &issue, "Seen"]);
ok(&["close", &url, &issue]);
ok(&["hide", &url, &issue]);
ok(&["pull", &url, "main", &url, "topic", "Topic"]);
let other = common::account();
ok(&["block", &other.handle]);
ok(&["rename", &url, "scoped2"]);
ok(&["delete", &s.url("scoped2"), "--yes"]);
let me = stored(&s);
let ctx = Ctx::new(s.env());
let mine = knotless::url::RepoUrl::parse(&format!("knotless://{}/_", me.did)).unwrap();
let refused = rt().block_on(async {
let remote = knotless::remote::Remote::connect(&ctx, &mine).await.unwrap();
let post = json!({"$type": "app.bsky.feed.post", "text": "hi", "createdAt": "2026-01-01T00:00:00Z"});
let write = knotless::remote::create("app.bsky.feed.post", "3kscoped", post);
remote.apply(vec![write], &me.auth()).await
});
let err = format!("{:#}", refused.unwrap_err());
assert!(err.contains("app.bsky.feed.post"), "{err}");
}
#[test]
fn refused_and_crossed_logins() {
let account = account();
docs/design.md+32−4
@@ -37,6 +37,8 @@ dev.knotless.comment <tid> repo, subject, body, verdict
dev.knotless.state <tid> repo, subject, state?, commit?, base?
dev.knotless.hide <tid> repo, subject
dev.knotless.block <tid> subject, repo?
dev.knotless.emails self hashes, updatedAt
```
`h(ref)` is the first 26 characters of the lowercase base32 SHA-256 of the
@@ -48,6 +50,20 @@ The first five live in the owner's repo. The pull, issue, comment and
state records live in each author's repo, and hides and blocks in the owner's; they
are described under [Pulls and issues](#pulls-and-issues).
An account's `emails` record names the addresses its commits are authored
as, so pages can show a commit as its author's account, with its Bluesky
picture. Each is the hex SHA-256 of the DID, a newline and the address
trimmed and lowercased: salted with the DID, so the record names no address
and one address hashes apart in each account that claims it. Nothing proves
an address is the claimant's, so a page believes a claim only from the
repository's owner and collaborators: someone else claiming your address
gains nothing on repositories they have no part in. A commit knotless makes
for an account that names no author is authored as `<did>@knotless.invalid`,
which needs no claim. The first push from a machine claims the address git
authors commits as there, if the account has made no claim yet, and says
so; after that `knotless email add` and `remove` change it, and a claim
taken back stays taken back.
### Identity
A repository is its owner's DID and its `key`. The name is for people: it
@@ -170,9 +186,16 @@ for want of the server's nonce is sent again with it. Refresh tokens are
used once, so processes refresh under a lock file, and one that finds the
session already refreshed takes it. Logging out revokes the tokens.
The scope is `transition:generic`, what an app password allows, plus a
`space` scope for private repositories, which `transition:generic` does
not reach. It names every space type: naming `dev.knotless.space` alone
The scope asks for what knotless does and nothing more, rather than
`transition:generic`'s run of the whole account: `repo:` for each
`dev.knotless.*` record it writes, `blob:` for packs, `rpc:` for the
service tokens it gets to call gateways (`pushRefs`, `createCommit`,
`putDelegation`, `getDelegation`) and moderation services
(`createReport`), at any audience, since gateways are anyone's, and a
`space` scope for private repositories. It is built from the same
constants the records and calls use, so a new record kind is asked for
as it is added; a session from before asks again at its next login. The
space scope names every space type: naming `dev.knotless.space` alone
makes the authorization server resolve its lexicon, which is not
published. A PDS without spaces drops the scope it does not know.
@@ -434,7 +457,12 @@ scripts, allowed by hash in a `default-src 'none'` content security policy:
An account's page, at `/<handle>`, lists its public repositories from its
own PDS rather than the index, so it is right without an appview, and shows
the display name and description of its Bluesky profile record if it has
one. Private repositories are in spaces and never listed there.
one. Private repositories are in spaces and never listed there. Wherever an
account is shown, its profile record's picture is shown with it, as
Bluesky's CDN serves it (`cdn.bsky.app/img/avatar/plain/<did>/<cid>`); so is
a commit's author, when its address is one the repository's people claim
(see [Records](#records)). Pictures and claims are kept ten minutes, and an
account's picture is looked up again whenever its page is shown.
A page asked for by a repository's old name redirects to the same page
under its new name. The redirect is temporary, as the old name can be
harness/shots/baseline/darwin/commit-desktop-dark.sha256+1−1
@@ -1 +1 @@
6eda188fc6374fb68008295a6a6a99bdc3d1671cde04ad64886ab21227458a25
\ No newline at end of file
440d36c4612ee49fe665e550c643b3466236c39c5ca6e90dc6ee666d83217eea
\ No newline at end of file
harness/shots/baseline/darwin/commit-desktop-light.sha256+1−1
@@ -1 +1 @@
6eda188fc6374fb68008295a6a6a99bdc3d1671cde04ad64886ab21227458a25
\ No newline at end of file
440d36c4612ee49fe665e550c643b3466236c39c5ca6e90dc6ee666d83217eea
\ No newline at end of file
harness/shots/baseline/darwin/commit-phone-dark.sha256+1−1
@@ -1 +1 @@
6eda188fc6374fb68008295a6a6a99bdc3d1671cde04ad64886ab21227458a25
\ No newline at end of file
440d36c4612ee49fe665e550c643b3466236c39c5ca6e90dc6ee666d83217eea
\ No newline at end of file
harness/shots/baseline/darwin/commit-phone-light.sha256+1−1
@@ -1 +1 @@
6eda188fc6374fb68008295a6a6a99bdc3d1671cde04ad64886ab21227458a25
\ No newline at end of file
440d36c4612ee49fe665e550c643b3466236c39c5ca6e90dc6ee666d83217eea
\ No newline at end of file
harness/shots/baseline/darwin/commits-desktop-dark.sha256+1−1
@@ -1 +1 @@
9742a8d377e1e906a96d80a793e18f2e801d32bf985965b4e361ab4be19b4d40
\ No newline at end of file
5502e33926209193d4c84807c35c5ae74ddca54ae18e708529e679c71e8ff7d0
\ No newline at end of file
harness/shots/baseline/darwin/commits-desktop-light.sha256+1−1
@@ -1 +1 @@
9742a8d377e1e906a96d80a793e18f2e801d32bf985965b4e361ab4be19b4d40
\ No newline at end of file
5502e33926209193d4c84807c35c5ae74ddca54ae18e708529e679c71e8ff7d0
\ No newline at end of file
harness/shots/baseline/darwin/commits-phone-dark.sha256+1−1
@@ -1 +1 @@
9742a8d377e1e906a96d80a793e18f2e801d32bf985965b4e361ab4be19b4d40
\ No newline at end of file
5502e33926209193d4c84807c35c5ae74ddca54ae18e708529e679c71e8ff7d0
\ No newline at end of file
harness/shots/baseline/darwin/commits-phone-light.sha256+1−1
@@ -1 +1 @@
9742a8d377e1e906a96d80a793e18f2e801d32bf985965b4e361ab4be19b4d40
\ No newline at end of file
5502e33926209193d4c84807c35c5ae74ddca54ae18e708529e679c71e8ff7d0
\ No newline at end of file
harness/shots/baseline/darwin/directory-desktop-dark.sha256+1−1
@@ -1 +1 @@
a9eca4eebe82f7a8c6d7376dee0ac6ddd6d18c5a332ad58bae2acde8207e693d
\ No newline at end of file
76de795bae3c4de753aff3ac6a6cb6de85d66c6f6da3e0ee62f9c027b1bfa31f
\ No newline at end of file
harness/shots/baseline/darwin/directory-desktop-light.sha256+1−1
@@ -1 +1 @@
a9eca4eebe82f7a8c6d7376dee0ac6ddd6d18c5a332ad58bae2acde8207e693d
\ No newline at end of file
76de795bae3c4de753aff3ac6a6cb6de85d66c6f6da3e0ee62f9c027b1bfa31f
\ No newline at end of file
harness/shots/baseline/darwin/directory-phone-dark.sha256+1−1
@@ -1 +1 @@
a9eca4eebe82f7a8c6d7376dee0ac6ddd6d18c5a332ad58bae2acde8207e693d
\ No newline at end of file
76de795bae3c4de753aff3ac6a6cb6de85d66c6f6da3e0ee62f9c027b1bfa31f
\ No newline at end of file
harness/shots/baseline/darwin/directory-phone-light.sha256+1−1
@@ -1 +1 @@
a9eca4eebe82f7a8c6d7376dee0ac6ddd6d18c5a332ad58bae2acde8207e693d
\ No newline at end of file
76de795bae3c4de753aff3ac6a6cb6de85d66c6f6da3e0ee62f9c027b1bfa31f
\ No newline at end of file
harness/shots/baseline/darwin/empty-desktop-dark.sha256+1−1
@@ -1 +1 @@
c0f3aa55fe615760f584d5424753883dc69d61bbf4ac7ba1df50aa61fe9daf42
\ No newline at end of file
d8b8a140035c13123992b6c28840d15f48be83e5a379e7adf7de813b827aead9
\ No newline at end of file
harness/shots/baseline/darwin/empty-desktop-light.sha256+1−1
@@ -1 +1 @@
c0f3aa55fe615760f584d5424753883dc69d61bbf4ac7ba1df50aa61fe9daf42
\ No newline at end of file
d8b8a140035c13123992b6c28840d15f48be83e5a379e7adf7de813b827aead9
\ No newline at end of file
harness/shots/baseline/darwin/empty-phone-dark.sha256+1−1
@@ -1 +1 @@
c0f3aa55fe615760f584d5424753883dc69d61bbf4ac7ba1df50aa61fe9daf42
\ No newline at end of file
d8b8a140035c13123992b6c28840d15f48be83e5a379e7adf7de813b827aead9
\ No newline at end of file
harness/shots/baseline/darwin/empty-phone-light.sha256+1−1
@@ -1 +1 @@
c0f3aa55fe615760f584d5424753883dc69d61bbf4ac7ba1df50aa61fe9daf42
\ No newline at end of file
d8b8a140035c13123992b6c28840d15f48be83e5a379e7adf7de813b827aead9
\ No newline at end of file
harness/shots/baseline/darwin/file-desktop-dark.sha256+1−1
@@ -1 +1 @@
e63323407bcc445dc8dcc8188c721c84932db56142d5e73380c5dd173efc8549
\ No newline at end of file
5b88bc816b9d090af35cb671f8f88190ff5a505d3880877d304c289cd2defc7e
\ No newline at end of file
harness/shots/baseline/darwin/file-desktop-light.sha256+1−1
@@ -1 +1 @@
e63323407bcc445dc8dcc8188c721c84932db56142d5e73380c5dd173efc8549
\ No newline at end of file
5b88bc816b9d090af35cb671f8f88190ff5a505d3880877d304c289cd2defc7e
\ No newline at end of file
harness/shots/baseline/darwin/file-phone-dark.sha256+1−1
@@ -1 +1 @@
e63323407bcc445dc8dcc8188c721c84932db56142d5e73380c5dd173efc8549
\ No newline at end of file
5b88bc816b9d090af35cb671f8f88190ff5a505d3880877d304c289cd2defc7e
\ No newline at end of file
harness/shots/baseline/darwin/file-phone-light.sha256+1−1
@@ -1 +1 @@
e63323407bcc445dc8dcc8188c721c84932db56142d5e73380c5dd173efc8549
\ No newline at end of file
5b88bc816b9d090af35cb671f8f88190ff5a505d3880877d304c289cd2defc7e
\ No newline at end of file
harness/shots/baseline/darwin/fork-desktop-dark.sha256+1−1
@@ -1 +1 @@
454a3ac6ac89d5c0e4c3a473a6f39584415fd3e5fce112de348e429ae74386ee
\ No newline at end of file
20e2f0b07532db960ee58283385c0cc657c34f6b14544a6e0fdb3493a97811ae
\ No newline at end of file
harness/shots/baseline/darwin/fork-desktop-light.sha256+1−1
@@ -1 +1 @@
454a3ac6ac89d5c0e4c3a473a6f39584415fd3e5fce112de348e429ae74386ee
\ No newline at end of file
20e2f0b07532db960ee58283385c0cc657c34f6b14544a6e0fdb3493a97811ae
\ No newline at end of file
harness/shots/baseline/darwin/fork-phone-dark.sha256+1−1
@@ -1 +1 @@
454a3ac6ac89d5c0e4c3a473a6f39584415fd3e5fce112de348e429ae74386ee
\ No newline at end of file
20e2f0b07532db960ee58283385c0cc657c34f6b14544a6e0fdb3493a97811ae
\ No newline at end of file
harness/shots/baseline/darwin/fork-phone-light.sha256+1−1
@@ -1 +1 @@
454a3ac6ac89d5c0e4c3a473a6f39584415fd3e5fce112de348e429ae74386ee
\ No newline at end of file
20e2f0b07532db960ee58283385c0cc657c34f6b14544a6e0fdb3493a97811ae
\ No newline at end of file
harness/shots/baseline/darwin/home-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/home-desktop-dark.sha256+1−1
@@ -1 +1 @@
0c35dd41fa06540b3bb995ad331ffc66906deb329dc6e1665a2d0df3bd05215a
\ No newline at end of file
e80e3eb2bfc534b484a709b66990b957e2f9a559f916780d011bf09e7238ef7c
\ No newline at end of file
harness/shots/baseline/darwin/home-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/home-desktop-light.sha256+1−1
@@ -1 +1 @@
0c35dd41fa06540b3bb995ad331ffc66906deb329dc6e1665a2d0df3bd05215a
\ No newline at end of file
e80e3eb2bfc534b484a709b66990b957e2f9a559f916780d011bf09e7238ef7c
\ No newline at end of file
harness/shots/baseline/darwin/home-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/home-phone-dark.sha256+1−1
@@ -1 +1 @@
0c35dd41fa06540b3bb995ad331ffc66906deb329dc6e1665a2d0df3bd05215a
\ No newline at end of file
e80e3eb2bfc534b484a709b66990b957e2f9a559f916780d011bf09e7238ef7c
\ No newline at end of file
harness/shots/baseline/darwin/home-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/home-phone-light.sha256+1−1
@@ -1 +1 @@
0c35dd41fa06540b3bb995ad331ffc66906deb329dc6e1665a2d0df3bd05215a
\ No newline at end of file
e80e3eb2bfc534b484a709b66990b957e2f9a559f916780d011bf09e7238ef7c
\ No newline at end of file
harness/shots/baseline/darwin/image-desktop-dark.sha256+1−1
@@ -1 +1 @@
540ef71f2d8e4cdb719207c67705dd93844e005eb7e2379c877cc5e782d0e3bb
\ No newline at end of file
78c1143e163d3d72abcdccc20ee389e7015862d7cf77913a47e6177ef0c4eb2c
\ No newline at end of file
harness/shots/baseline/darwin/image-desktop-light.sha256+1−1
@@ -1 +1 @@
540ef71f2d8e4cdb719207c67705dd93844e005eb7e2379c877cc5e782d0e3bb
\ No newline at end of file
78c1143e163d3d72abcdccc20ee389e7015862d7cf77913a47e6177ef0c4eb2c
\ No newline at end of file
harness/shots/baseline/darwin/image-phone-dark.sha256+1−1
@@ -1 +1 @@
540ef71f2d8e4cdb719207c67705dd93844e005eb7e2379c877cc5e782d0e3bb
\ No newline at end of file
78c1143e163d3d72abcdccc20ee389e7015862d7cf77913a47e6177ef0c4eb2c
\ No newline at end of file
harness/shots/baseline/darwin/image-phone-light.sha256+1−1
@@ -1 +1 @@
540ef71f2d8e4cdb719207c67705dd93844e005eb7e2379c877cc5e782d0e3bb
\ No newline at end of file
78c1143e163d3d72abcdccc20ee389e7015862d7cf77913a47e6177ef0c4eb2c
\ No newline at end of file
harness/shots/baseline/darwin/issue-closed-desktop-dark.sha256+1−1
@@ -1 +1 @@
266f1be2c7e361d746bd53c06d25198e1981c4c9049bee91cbe7f35f9a279144
\ No newline at end of file
ab8077fb74813ffa91614239f783e5532e3eec95f04450f28fdcd0a4eda5f10c
\ No newline at end of file
harness/shots/baseline/darwin/issue-closed-desktop-light.sha256+1−1
@@ -1 +1 @@
266f1be2c7e361d746bd53c06d25198e1981c4c9049bee91cbe7f35f9a279144
\ No newline at end of file
ab8077fb74813ffa91614239f783e5532e3eec95f04450f28fdcd0a4eda5f10c
\ No newline at end of file
harness/shots/baseline/darwin/issue-closed-phone-dark.sha256+1−1
@@ -1 +1 @@
266f1be2c7e361d746bd53c06d25198e1981c4c9049bee91cbe7f35f9a279144
\ No newline at end of file
ab8077fb74813ffa91614239f783e5532e3eec95f04450f28fdcd0a4eda5f10c
\ No newline at end of file
harness/shots/baseline/darwin/issue-closed-phone-light.sha256+1−1
@@ -1 +1 @@
266f1be2c7e361d746bd53c06d25198e1981c4c9049bee91cbe7f35f9a279144
\ No newline at end of file
ab8077fb74813ffa91614239f783e5532e3eec95f04450f28fdcd0a4eda5f10c
\ No newline at end of file
harness/shots/baseline/darwin/issue-desktop-dark.sha256+1−1
@@ -1 +1 @@
ffc55fd7ddca55861317699953ac8d847af9ff75e1691f36fa7e9ec58d4b6a79
\ No newline at end of file
fad6af24f892148d5e8718de19a0db4673102d804468e6d4dc0c622dac2b2bbd
\ No newline at end of file
harness/shots/baseline/darwin/issue-desktop-light.sha256+1−1
@@ -1 +1 @@
ffc55fd7ddca55861317699953ac8d847af9ff75e1691f36fa7e9ec58d4b6a79
\ No newline at end of file
fad6af24f892148d5e8718de19a0db4673102d804468e6d4dc0c622dac2b2bbd
\ No newline at end of file
harness/shots/baseline/darwin/issue-phone-dark.sha256+1−1
@@ -1 +1 @@
ffc55fd7ddca55861317699953ac8d847af9ff75e1691f36fa7e9ec58d4b6a79
\ No newline at end of file
fad6af24f892148d5e8718de19a0db4673102d804468e6d4dc0c622dac2b2bbd
\ No newline at end of file
harness/shots/baseline/darwin/issue-phone-light.sha256+1−1
@@ -1 +1 @@
ffc55fd7ddca55861317699953ac8d847af9ff75e1691f36fa7e9ec58d4b6a79
\ No newline at end of file
fad6af24f892148d5e8718de19a0db4673102d804468e6d4dc0c622dac2b2bbd
\ No newline at end of file
harness/shots/baseline/darwin/issues-closed-desktop-dark.sha256+1−1
@@ -1 +1 @@
7e423c5bf140a962bd0fc7fd5484238eca88b61fd24f6e27492e9090b1641c4a
\ No newline at end of file
15fbfd6776806f45b5208e93a435a1180020eac05e7ad7181158e30b120dc309
\ No newline at end of file
harness/shots/baseline/darwin/issues-closed-desktop-light.sha256+1−1
@@ -1 +1 @@
7e423c5bf140a962bd0fc7fd5484238eca88b61fd24f6e27492e9090b1641c4a
\ No newline at end of file
15fbfd6776806f45b5208e93a435a1180020eac05e7ad7181158e30b120dc309
\ No newline at end of file
harness/shots/baseline/darwin/issues-closed-phone-dark.sha256+1−1
@@ -1 +1 @@
7e423c5bf140a962bd0fc7fd5484238eca88b61fd24f6e27492e9090b1641c4a
\ No newline at end of file
15fbfd6776806f45b5208e93a435a1180020eac05e7ad7181158e30b120dc309
\ No newline at end of file
harness/shots/baseline/darwin/issues-closed-phone-light.sha256+1−1
@@ -1 +1 @@
7e423c5bf140a962bd0fc7fd5484238eca88b61fd24f6e27492e9090b1641c4a
\ No newline at end of file
15fbfd6776806f45b5208e93a435a1180020eac05e7ad7181158e30b120dc309
\ No newline at end of file
harness/shots/baseline/darwin/issues-desktop-dark.sha256+1−1
@@ -1 +1 @@
3c48a96bf539899e8c3f296ac56d42feba22eb9fb861d285af2195add73fd822
\ No newline at end of file
ac89bfb4b94df95c6d05cdca5ff01722d4f55fb324c9c3251ca4831dc97dc580
\ No newline at end of file
harness/shots/baseline/darwin/issues-desktop-light.sha256+1−1
@@ -1 +1 @@
3c48a96bf539899e8c3f296ac56d42feba22eb9fb861d285af2195add73fd822
\ No newline at end of file
ac89bfb4b94df95c6d05cdca5ff01722d4f55fb324c9c3251ca4831dc97dc580
\ No newline at end of file
harness/shots/baseline/darwin/issues-phone-dark.sha256+1−1
@@ -1 +1 @@
3c48a96bf539899e8c3f296ac56d42feba22eb9fb861d285af2195add73fd822
\ No newline at end of file
ac89bfb4b94df95c6d05cdca5ff01722d4f55fb324c9c3251ca4831dc97dc580
\ No newline at end of file
harness/shots/baseline/darwin/issues-phone-light.sha256+1−1
@@ -1 +1 @@
3c48a96bf539899e8c3f296ac56d42feba22eb9fb861d285af2195add73fd822
\ No newline at end of file
ac89bfb4b94df95c6d05cdca5ff01722d4f55fb324c9c3251ca4831dc97dc580
\ No newline at end of file
harness/shots/baseline/darwin/markdown-desktop-dark.sha256+1−1
@@ -1 +1 @@
89d5305d05bb328d4d5d77d45ca21f35c6a6f1f231bc6e2c13f5ce2565f2831e
\ No newline at end of file
157975af8f8b3da770baed0128d1c7994779d273e4bd7b015c63a792226efa24
\ No newline at end of file
harness/shots/baseline/darwin/markdown-desktop-light.sha256+1−1
@@ -1 +1 @@
89d5305d05bb328d4d5d77d45ca21f35c6a6f1f231bc6e2c13f5ce2565f2831e
\ No newline at end of file
157975af8f8b3da770baed0128d1c7994779d273e4bd7b015c63a792226efa24
\ No newline at end of file
harness/shots/baseline/darwin/markdown-phone-dark.sha256+1−1
@@ -1 +1 @@
89d5305d05bb328d4d5d77d45ca21f35c6a6f1f231bc6e2c13f5ce2565f2831e
\ No newline at end of file
157975af8f8b3da770baed0128d1c7994779d273e4bd7b015c63a792226efa24
\ No newline at end of file
harness/shots/baseline/darwin/markdown-phone-light.sha256+1−1
@@ -1 +1 @@
89d5305d05bb328d4d5d77d45ca21f35c6a6f1f231bc6e2c13f5ce2565f2831e
\ No newline at end of file
157975af8f8b3da770baed0128d1c7994779d273e4bd7b015c63a792226efa24
\ No newline at end of file
harness/shots/baseline/darwin/missing-desktop-dark.sha256+1−1
@@ -1 +1 @@
b94a493d9a26d4e9c78b9c8af6c3bb3faf36494faee70a18994d007c7409d026
\ No newline at end of file
0f19dcb5e8a805fad3a8515ce61fa5714e6a37c4b1d3b22903d845c7c0caf778
\ No newline at end of file
harness/shots/baseline/darwin/missing-desktop-light.sha256+1−1
@@ -1 +1 @@
b94a493d9a26d4e9c78b9c8af6c3bb3faf36494faee70a18994d007c7409d026
\ No newline at end of file
0f19dcb5e8a805fad3a8515ce61fa5714e6a37c4b1d3b22903d845c7c0caf778
\ No newline at end of file
harness/shots/baseline/darwin/missing-phone-dark.sha256+1−1
@@ -1 +1 @@
b94a493d9a26d4e9c78b9c8af6c3bb3faf36494faee70a18994d007c7409d026
\ No newline at end of file
0f19dcb5e8a805fad3a8515ce61fa5714e6a37c4b1d3b22903d845c7c0caf778
\ No newline at end of file
harness/shots/baseline/darwin/missing-phone-light.sha256+1−1
@@ -1 +1 @@
b94a493d9a26d4e9c78b9c8af6c3bb3faf36494faee70a18994d007c7409d026
\ No newline at end of file
0f19dcb5e8a805fad3a8515ce61fa5714e6a37c4b1d3b22903d845c7c0caf778
\ No newline at end of file
harness/shots/baseline/darwin/profile-desktop-dark.sha256+1−1
@@ -1 +1 @@
38cc53314d8da534ef8e5ad4d8a316ae53f7ce2006405e23e00f2d83a6c56a88
\ No newline at end of file
e4d992f4a2ee2d862a651bd4f7cbe8256a15272e3765fe80efad48f1cce3be5b
\ No newline at end of file
harness/shots/baseline/darwin/profile-desktop-light.sha256+1−1
@@ -1 +1 @@
38cc53314d8da534ef8e5ad4d8a316ae53f7ce2006405e23e00f2d83a6c56a88
\ No newline at end of file
e4d992f4a2ee2d862a651bd4f7cbe8256a15272e3765fe80efad48f1cce3be5b
\ No newline at end of file
harness/shots/baseline/darwin/profile-fork-desktop-dark.sha256+1−1
@@ -1 +1 @@
0fb9caf11bc940d61cfd19173171b589029620d976ca6ae41c24584503e1bab2
\ No newline at end of file
8732040b28836ac64c7c81be96521c40d37edb77c6a9862a132efd1cc6c19271
\ No newline at end of file
harness/shots/baseline/darwin/profile-fork-desktop-light.sha256+1−1
@@ -1 +1 @@
0fb9caf11bc940d61cfd19173171b589029620d976ca6ae41c24584503e1bab2
\ No newline at end of file
8732040b28836ac64c7c81be96521c40d37edb77c6a9862a132efd1cc6c19271
\ No newline at end of file
harness/shots/baseline/darwin/profile-fork-phone-dark.sha256+1−1
@@ -1 +1 @@
0fb9caf11bc940d61cfd19173171b589029620d976ca6ae41c24584503e1bab2
\ No newline at end of file
8732040b28836ac64c7c81be96521c40d37edb77c6a9862a132efd1cc6c19271
\ No newline at end of file
harness/shots/baseline/darwin/profile-fork-phone-light.sha256+1−1
@@ -1 +1 @@
0fb9caf11bc940d61cfd19173171b589029620d976ca6ae41c24584503e1bab2
\ No newline at end of file
8732040b28836ac64c7c81be96521c40d37edb77c6a9862a132efd1cc6c19271
\ No newline at end of file
harness/shots/baseline/darwin/profile-phone-dark.sha256+1−1
@@ -1 +1 @@
38cc53314d8da534ef8e5ad4d8a316ae53f7ce2006405e23e00f2d83a6c56a88
\ No newline at end of file
e4d992f4a2ee2d862a651bd4f7cbe8256a15272e3765fe80efad48f1cce3be5b
\ No newline at end of file
harness/shots/baseline/darwin/profile-phone-light.sha256+1−1
@@ -1 +1 @@
38cc53314d8da534ef8e5ad4d8a316ae53f7ce2006405e23e00f2d83a6c56a88
\ No newline at end of file
e4d992f4a2ee2d862a651bd4f7cbe8256a15272e3765fe80efad48f1cce3be5b
\ No newline at end of file
harness/shots/baseline/darwin/pull-desktop-dark.sha256+1−1
@@ -1 +1 @@
f42db2de5001567f90e7a76cd230ad3c57b0def3a411d9eb82edded846a2b905
\ No newline at end of file
3004f8688b3d2a3e42f32e320625c3b75964a09a85eed137f049e054254b6f98
\ No newline at end of file
harness/shots/baseline/darwin/pull-desktop-light.sha256+1−1
@@ -1 +1 @@
f42db2de5001567f90e7a76cd230ad3c57b0def3a411d9eb82edded846a2b905
\ No newline at end of file
3004f8688b3d2a3e42f32e320625c3b75964a09a85eed137f049e054254b6f98
\ No newline at end of file
harness/shots/baseline/darwin/pull-open-desktop-dark.sha256+1−1
@@ -1 +1 @@
4496dbce8d0124698b6ea02e8e26b3a36c5a34123ac540430dd7fb56f03a7587
\ No newline at end of file
89758792ae2285bb2a8e74a2740cb28bdfcbf123f6a5a3c4e726ea7d4d89c020
\ No newline at end of file
harness/shots/baseline/darwin/pull-open-desktop-light.sha256+1−1
@@ -1 +1 @@
4496dbce8d0124698b6ea02e8e26b3a36c5a34123ac540430dd7fb56f03a7587
\ No newline at end of file
89758792ae2285bb2a8e74a2740cb28bdfcbf123f6a5a3c4e726ea7d4d89c020
\ No newline at end of file
harness/shots/baseline/darwin/pull-open-phone-dark.sha256+1−1
@@ -1 +1 @@
4496dbce8d0124698b6ea02e8e26b3a36c5a34123ac540430dd7fb56f03a7587
\ No newline at end of file
89758792ae2285bb2a8e74a2740cb28bdfcbf123f6a5a3c4e726ea7d4d89c020
\ No newline at end of file
harness/shots/baseline/darwin/pull-open-phone-light.sha256+1−1
@@ -1 +1 @@
4496dbce8d0124698b6ea02e8e26b3a36c5a34123ac540430dd7fb56f03a7587
\ No newline at end of file
89758792ae2285bb2a8e74a2740cb28bdfcbf123f6a5a3c4e726ea7d4d89c020
\ No newline at end of file
harness/shots/baseline/darwin/pull-phone-dark.sha256+1−1
@@ -1 +1 @@
f42db2de5001567f90e7a76cd230ad3c57b0def3a411d9eb82edded846a2b905
\ No newline at end of file
3004f8688b3d2a3e42f32e320625c3b75964a09a85eed137f049e054254b6f98
\ No newline at end of file
harness/shots/baseline/darwin/pull-phone-light.sha256+1−1
@@ -1 +1 @@
f42db2de5001567f90e7a76cd230ad3c57b0def3a411d9eb82edded846a2b905
\ No newline at end of file
3004f8688b3d2a3e42f32e320625c3b75964a09a85eed137f049e054254b6f98
\ No newline at end of file
harness/shots/baseline/darwin/pulls-closed-desktop-dark.sha256+1−1
@@ -1 +1 @@
594d0c215cfbdb2ce0bb3bb15e46994e4f1a907bf2cf69fd0c7c58fee4bd6224
\ No newline at end of file
427d24e007b76b37421821612b8ae2097c006fac8b2976fa16513ea4f40fca79
\ No newline at end of file
harness/shots/baseline/darwin/pulls-closed-desktop-light.sha256+1−1
@@ -1 +1 @@
594d0c215cfbdb2ce0bb3bb15e46994e4f1a907bf2cf69fd0c7c58fee4bd6224
\ No newline at end of file
427d24e007b76b37421821612b8ae2097c006fac8b2976fa16513ea4f40fca79
\ No newline at end of file
harness/shots/baseline/darwin/pulls-closed-phone-dark.sha256+1−1
@@ -1 +1 @@
594d0c215cfbdb2ce0bb3bb15e46994e4f1a907bf2cf69fd0c7c58fee4bd6224
\ No newline at end of file
427d24e007b76b37421821612b8ae2097c006fac8b2976fa16513ea4f40fca79
\ No newline at end of file
harness/shots/baseline/darwin/pulls-closed-phone-light.sha256+1−1
@@ -1 +1 @@
594d0c215cfbdb2ce0bb3bb15e46994e4f1a907bf2cf69fd0c7c58fee4bd6224
\ No newline at end of file
427d24e007b76b37421821612b8ae2097c006fac8b2976fa16513ea4f40fca79
\ No newline at end of file
harness/shots/baseline/darwin/pulls-desktop-dark.sha256+1−1
@@ -1 +1 @@
4baffd2ec650a2337a64a8cd2d3e78f8f864df49550691eb3b4f29168e569830
\ No newline at end of file
6abe189560a91f2b9110070e476f4f5a2fcb0b3bb56ad764800c567d41372b41
\ No newline at end of file
harness/shots/baseline/darwin/pulls-desktop-light.sha256+1−1
@@ -1 +1 @@
4baffd2ec650a2337a64a8cd2d3e78f8f864df49550691eb3b4f29168e569830
\ No newline at end of file
6abe189560a91f2b9110070e476f4f5a2fcb0b3bb56ad764800c567d41372b41
\ No newline at end of file
harness/shots/baseline/darwin/pulls-phone-dark.sha256+1−1
@@ -1 +1 @@
4baffd2ec650a2337a64a8cd2d3e78f8f864df49550691eb3b4f29168e569830
\ No newline at end of file
6abe189560a91f2b9110070e476f4f5a2fcb0b3bb56ad764800c567d41372b41
\ No newline at end of file
harness/shots/baseline/darwin/pulls-phone-light.sha256+1−1
@@ -1 +1 @@
4baffd2ec650a2337a64a8cd2d3e78f8f864df49550691eb3b4f29168e569830
\ No newline at end of file
6abe189560a91f2b9110070e476f4f5a2fcb0b3bb56ad764800c567d41372b41
\ No newline at end of file
harness/shots/baseline/darwin/repository-desktop-dark.sha256+1−1
@@ -1 +1 @@
d4315598544786e854e69f917f851f650eec82dcc4ef6545f30e2393790277c0
\ No newline at end of file
6ad60f6db7f971516c50904155ea84b92be4b9ebc89dc3466998250308a904dc
\ No newline at end of file
harness/shots/baseline/darwin/repository-desktop-light.sha256+1−1
@@ -1 +1 @@
d4315598544786e854e69f917f851f650eec82dcc4ef6545f30e2393790277c0
\ No newline at end of file
6ad60f6db7f971516c50904155ea84b92be4b9ebc89dc3466998250308a904dc
\ No newline at end of file
harness/shots/baseline/darwin/repository-phone-dark.sha256+1−1
@@ -1 +1 @@
d4315598544786e854e69f917f851f650eec82dcc4ef6545f30e2393790277c0
\ No newline at end of file
6ad60f6db7f971516c50904155ea84b92be4b9ebc89dc3466998250308a904dc
\ No newline at end of file
harness/shots/baseline/darwin/repository-phone-light.sha256+1−1
@@ -1 +1 @@
d4315598544786e854e69f917f851f650eec82dcc4ef6545f30e2393790277c0
\ No newline at end of file
6ad60f6db7f971516c50904155ea84b92be4b9ebc89dc3466998250308a904dc
\ No newline at end of file
harness/shots/baseline/darwin/search-desktop-dark.sha256+1−1
@@ -1 +1 @@
17f414256569c617a3bd80aa0f8c53962bd7d3ebe0bfb620755a5cc03e12f871
\ No newline at end of file
b2743ac7f9fb0df24771023fe60a47e280f25ac44f623c86f3ea44ba904bef42
\ No newline at end of file
harness/shots/baseline/darwin/search-desktop-light.sha256+1−1
@@ -1 +1 @@
17f414256569c617a3bd80aa0f8c53962bd7d3ebe0bfb620755a5cc03e12f871
\ No newline at end of file
b2743ac7f9fb0df24771023fe60a47e280f25ac44f623c86f3ea44ba904bef42
\ No newline at end of file
harness/shots/baseline/darwin/search-phone-dark.sha256+1−1
@@ -1 +1 @@
17f414256569c617a3bd80aa0f8c53962bd7d3ebe0bfb620755a5cc03e12f871
\ No newline at end of file
b2743ac7f9fb0df24771023fe60a47e280f25ac44f623c86f3ea44ba904bef42
\ No newline at end of file
harness/shots/baseline/darwin/search-phone-light.sha256+1−1
@@ -1 +1 @@
17f414256569c617a3bd80aa0f8c53962bd7d3ebe0bfb620755a5cc03e12f871
\ No newline at end of file
b2743ac7f9fb0df24771023fe60a47e280f25ac44f623c86f3ea44ba904bef42
\ No newline at end of file
harness/shots/baseline/darwin/start-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/start-desktop-dark.sha256+1−1
@@ -1 +1 @@
f318ce8d6bdb789fc8fe8364e77dd869fc9cde18e2ab1d37f833583acad5d899
\ No newline at end of file
6c1634323b284d1d6856a66a957a63491a95826a67c9f9f4c54557b7b09e2b66
\ No newline at end of file
harness/shots/baseline/darwin/start-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/start-desktop-light.sha256+1−1
@@ -1 +1 @@
f318ce8d6bdb789fc8fe8364e77dd869fc9cde18e2ab1d37f833583acad5d899
\ No newline at end of file
6c1634323b284d1d6856a66a957a63491a95826a67c9f9f4c54557b7b09e2b66
\ No newline at end of file
harness/shots/baseline/darwin/start-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/start-phone-dark.sha256+1−1
@@ -1 +1 @@
f318ce8d6bdb789fc8fe8364e77dd869fc9cde18e2ab1d37f833583acad5d899
\ No newline at end of file
6c1634323b284d1d6856a66a957a63491a95826a67c9f9f4c54557b7b09e2b66
\ No newline at end of file
harness/shots/baseline/darwin/start-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/start-phone-light.sha256+1−1
@@ -1 +1 @@
f318ce8d6bdb789fc8fe8364e77dd869fc9cde18e2ab1d37f833583acad5d899
\ No newline at end of file
6c1634323b284d1d6856a66a957a63491a95826a67c9f9f4c54557b7b09e2b66
\ No newline at end of file
harness/shots/digest.mjs+3−3
@@ -1,12 +1,12 @@
// What a page's screenshot follows from: its HTML, which carries the
// styles and scripts too, less what differs between worlds: DIDs, and the
// record keys in links to pulls and issues.
// styles and scripts too, less what differs between worlds: DIDs, the
// record keys in links to pulls and issues, and the port it is served on.
import { createHash } from 'node:crypto'
export const digest = (html) =>
createHash('sha256')
.update(html.replace(/did:plc:[a-z2-7]{24}/g, 'did').replace(/\/[2-7a-z]{13}(?=["/?#])/g, '/rkey'))
.update(html.replace(/did:plc:[a-z2-7]{24}/g, 'did').replace(/127\.0\.0\.1:\d+/g, 'host').replace(/\/[2-7a-z]{13}(?=["/?#])/g, '/rkey'))
.digest('hex')
/// The path of the social card a page names, which carries a hash of what
harness/shots/fixtures.json+24−24
@@ -14,12 +14,12 @@
"commit": "/alice.test/tally/commit/97727c2c19d5d8b3849b2ed9ad47130cee9798f5",
"pulls": "/alice.test/tally/pulls",
"pulls-closed": "/alice.test/tally/pulls?state=closed",
"pull": "/alice.test/tally/pull/did:plc:22yzhoyrfxuuax3knxdw3t72/3myg7ltvdaqlc",
"pull-open": "/alice.test/tally/pull/did:plc:22yzhoyrfxuuax3knxdw3t72/3myg7luqbsoys",
"pull": "/alice.test/tally/pull/did:plc:jxhkyipyrdvki5qrhox3ipdg/3myggykid2nrg",
"pull-open": "/alice.test/tally/pull/did:plc:jxhkyipyrdvki5qrhox3ipdg/3myggylhg5l6v",
"issues": "/alice.test/tally/issues",
"issues-closed": "/alice.test/tally/issues?state=closed",
"issue": "/alice.test/tally/issue/did:plc:22yzhoyrfxuuax3knxdw3t72/3myg7luqyfmyu",
"issue-closed": "/alice.test/tally/issue/did:plc:22yzhoyrfxuuax3knxdw3t72/3myg7lusdxeyx",
"issue": "/alice.test/tally/issue/did:plc:jxhkyipyrdvki5qrhox3ipdg/3myggyli5er6w",
"issue-closed": "/alice.test/tally/issue/did:plc:jxhkyipyrdvki5qrhox3ipdg/3myggylju6b6y",
"fork": "/bob.test/tally",
"empty": "/alice.test/scratch",
"missing": "/alice.test/tally/blob/main/nowhere"
@@ -31,7 +31,7 @@
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"Alice Moreau\",\"bio\":\"Small tools for text. Writes tally.\",\"avatar\":null,\"about\":\"Small tools for text. Writes tally.\",\"repos\":[{\"owner\":\"alice.test\",\"avatar\":null,\"name\":\"scratch\",\"description\":null,\"forkOf\":null,\"createdAt\":\"2026-10-09T13:42:00.000Z\",\"private\":false},{\"owner\":\"alice.test\",\"avatar\":null,\"name\":\"notes\",\"description\":\"Scratch notes 📝\",\"forkOf\":null,\"createdAt\":\"2026-10-09T11:51:00.000Z\",\"private\":false},{\"owner\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"createdAt\":\"2026-10-09T10:00:00.000Z\",\"private\":false}]}"
"body": "{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"Alice Moreau\",\"bio\":\"Small tools for text. Writes tally.\",\"avatar\":null,\"about\":\"Small tools for text. Writes tally.\",\"repos\":[{\"owner\":\"alice.test\",\"avatar\":null,\"name\":\"scratch\",\"description\":null,\"forkOf\":null,\"createdAt\":\"2026-10-09T13:42:00.000Z\",\"private\":false},{\"owner\":\"alice.test\",\"avatar\":null,\"name\":\"notes\",\"description\":\"Scratch notes 📝\",\"forkOf\":null,\"createdAt\":\"2026-10-09T11:51:00.000Z\",\"private\":false},{\"owner\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"createdAt\":\"2026-10-09T10:00:00.000Z\",\"private\":false}]}"
},
"/_data/alice.test/scratch": {
"status": 200,
@@ -47,7 +47,7 @@
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"rev\":\"main\",\"path\":\"\",\"empty\":false,\"last\":{\"sha\":\"686a36205b82620af07dd99997c570f04f461df9\",\"parents\":[\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\"],\"author\":\"Bob Tanaka\",\"date\":\"2026-10-09T09:31:00+00:00\",\"message\":\"Count no lines in empty input\"},\"entries\":[{\"name\":\"assets\",\"kind\":\"tree\",\"size\":null},{\"name\":\"docs\",\"kind\":\"tree\",\"size\":null},{\"name\":\"src\",\"kind\":\"tree\",\"size\":null},{\"name\":\".gitignore\",\"kind\":\"blob\",\"size\":8},{\"name\":\"Cargo.toml\",\"kind\":\"blob\",\"size\":122},{\"name\":\"README.md\",\"kind\":\"blob\",\"size\":388}],\"readme\":{\"name\":\"README.md\",\"html\":\"<h1>tally</h1>\\n<p>Counts lines, words and bytes, like <code>wc</code>, with output that lines up.</p>\\n<p><img src=\\\"/alice.test/tally/raw/main/assets/logo.svg\\\" alt=\\\"tally\\\" /></p>\\n<h2>Usage</h2>\\n<pre><code class=\\\"language-sh\\\">cargo install --path .\\ntally notes.txt\\necho \\\"one two\\\" | tally\\n</code></pre>\\n<h2>Output</h2>\\n<table><thead><tr><th>Column</th><th>Meaning</th></tr></thead><tbody>\\n<tr><td>1</td><td>lines</td></tr>\\n<tr><td>2</td><td>words, split on whitespace</td></tr>\\n<tr><td>3</td><td>bytes</td></tr>\\n</tbody></table>\\n<p>See <a href=\\\"/alice.test/tally/blob/main/docs/design.md\\\">the design notes</a> for why it reads the whole file.</p>\\n<h2>License</h2>\\n<p>MIT</p>\\n\",\"text\":null}}"
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"rev\":\"main\",\"path\":\"\",\"empty\":false,\"last\":{\"sha\":\"686a36205b82620af07dd99997c570f04f461df9\",\"parents\":[\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\"],\"author\":{\"name\":\"Bob Tanaka\",\"account\":null,\"avatar\":null},\"coauthors\":[],\"date\":\"2026-10-09T09:31:00+00:00\",\"message\":\"Count no lines in empty input\"},\"entries\":[{\"name\":\"assets\",\"kind\":\"tree\",\"size\":null},{\"name\":\"docs\",\"kind\":\"tree\",\"size\":null},{\"name\":\"src\",\"kind\":\"tree\",\"size\":null},{\"name\":\".gitignore\",\"kind\":\"blob\",\"size\":8},{\"name\":\"Cargo.toml\",\"kind\":\"blob\",\"size\":122},{\"name\":\"README.md\",\"kind\":\"blob\",\"size\":388}],\"readme\":{\"name\":\"README.md\",\"html\":\"<h1>tally</h1>\\n<p>Counts lines, words and bytes, like <code>wc</code>, with output that lines up.</p>\\n<p><img src=\\\"/alice.test/tally/raw/main/assets/logo.svg\\\" alt=\\\"tally\\\" /></p>\\n<h2>Usage</h2>\\n<pre><code class=\\\"language-sh\\\">cargo install --path .\\ntally notes.txt\\necho \\\"one two\\\" | tally\\n</code></pre>\\n<h2>Output</h2>\\n<table><thead><tr><th>Column</th><th>Meaning</th></tr></thead><tbody>\\n<tr><td>1</td><td>lines</td></tr>\\n<tr><td>2</td><td>words, split on whitespace</td></tr>\\n<tr><td>3</td><td>bytes</td></tr>\\n</tbody></table>\\n<p>See <a href=\\\"/alice.test/tally/blob/main/docs/design.md\\\">the design notes</a> for why it reads the whole file.</p>\\n<h2>License</h2>\\n<p>MIT</p>\\n\",\"text\":null}}"
},
"/_data/alice.test/tally/blob/main/README.md": {
"status": 200,
@@ -87,7 +87,7 @@
"content-type": "application/json",
"cache-control": "public, max-age=31536000, immutable"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"commit\":{\"sha\":\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\",\"parents\":[\"4f6b9a1168c6534f64c836b4c3a11e23e74637ad\"],\"author\":\"Alice Moreau\",\"date\":\"2026-10-08T11:47:00+00:00\",\"message\":\"Split parsing into its own module\"},\"files\":[{\"old\":\"docs/design.md\",\"new\":\"docs/DESIGN.md\",\"status\":\"renamed\",\"added\":0,\"removed\":0,\"note\":\"Moved without changes.\",\"ranges\":null,\"rows\":[]},{\"old\":\"src/lib.rs\",\"new\":\"src/lib.rs\",\"status\":\"modified\",\"added\":9,\"removed\":2,\"note\":null,\"ranges\":\"comment:0,58,14,22,58,30;string:529,18,153,6;keyword:60,3,69,3,1,6,41,3,38,3,1,3,65,3,6,2,20,3,6,2,177,3,120,2;number:384,1,203,1,9,1,9,2,105,1;function:262,3,14,7,27,5,28,5,65,16,3,5,35,5,43,5,30,10,73,29,13,10;type:104,5,2,7,2,9,14,6,54,3,5,6,24,6,24,6,295,6;attr:95,9,5,2,7,2,9,2,495,7;xins:339,17,112,17;xdel:308,11,92,30\",\"rows\":[{\"kind\":\"h\",\"line\":false,\"old\":null,\"new\":null,\"text\":\"@@ -1,5 +1,7 @@\"},{\"kind\":\"\",\"line\":true,\"old\":1,\"new\":1,\"text\":\"//! Counting, kept apart from reading so it can be tested.\"},{\"kind\":\"\",\"line\":true,\"old\":2,\"new\":2,\"text\":\"\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":3,\"text\":\"mod parse;\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":4,\"text\":\"\"},{\"kind\":\"\",\"line\":true,\"old\":3,\"new\":5,\"text\":\"/// What a text holds.\"},{\"kind\":\"\",\"line\":true,\"old\":4,\"new\":6,\"text\":\"#[derive(Debug, Default, PartialEq)]\"},{\"kind\":\"\",\"line\":true,\"old\":5,\"new\":7,\"text\":\"pub struct Counts {\"},{\"kind\":\"h\",\"line\":false,\"old\":null,\"new\":null,\"text\":\"@@ -11,9 +13,9 @@ pub struct Counts {\"},{\"kind\":\"\",\"line\":true,\"old\":11,\"new\":13,\"text\":\"/// Counts `text` in one pass.\"},{\"kind\":\"\",\"line\":true,\"old\":12,\"new\":14,\"text\":\"pub fn count(text: &str) -> Counts {\"},{\"kind\":\"\",\"line\":true,\"old\":13,\"new\":15,\"text\":\" let mut counts = Counts { bytes: text.len(), ..Counts::default() };\"},{\"kind\":\"d\",\"line\":true,\"old\":14,\"new\":null,\"text\":\" for line in text.lines() {\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":16,\"text\":\" for line in parse::lines(text) {\"},{\"kind\":\"\",\"line\":true,\"old\":15,\"new\":17,\"text\":\" counts.lines += 1;\"},{\"kind\":\"d\",\"line\":true,\"old\":16,\"new\":null,\"text\":\" counts.words += line.split_whitespace().count();\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":18,\"text\":\" counts.words += parse::words(line);\"},{\"kind\":\"\",\"line\":true,\"old\":17,\"new\":19,\"text\":\" }\"},{\"kind\":\"\",\"line\":true,\"old\":18,\"new\":20,\"text\":\" counts\"},{\"kind\":\"\",\"line\":true,\"old\":19,\"new\":21,\"text\":\"}\"},{\"kind\":\"h\",\"line\":false,\"old\":null,\"new\":null,\"text\":\"@@ -27,4 +29,9 @@ mod tests {\"},{\"kind\":\"\",\"line\":true,\"old\":27,\"new\":29,\"text\":\" let c = count(\\\"one two\\\\nthree\\\\n\\\");\"},{\"kind\":\"\",\"line\":true,\"old\":28,\"new\":30,\"text\":\" assert_eq!(c, Counts { lines: 2, words: 3, bytes: 14 });\"},{\"kind\":\"\",\"line\":true,\"old\":29,\"new\":31,\"text\":\" }\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":32,\"text\":\"\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":33,\"text\":\" #[test]\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":34,\"text\":\" fn a_last_line_without_a_newline() {\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":35,\"text\":\" assert_eq!(count(\\\"a\\\\nb\\\").lines, 2);\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":36,\"text\":\" }\"},{\"kind\":\"\",\"line\":true,\"old\":30,\"new\":37,\"text\":\"}\"}]},{\"old\":\"src/parse.rs\",\"new\":\"src/parse.rs\",\"status\":\"added\",\"added\":11,\"removed\":0,\"note\":null,\"ranges\":\"comment:0,48,2,57,76,68;keyword:108,3,1,2,138,3,1,2,22,4;function:115,5,33,16,3,5,82,5,54,5;type:128,3,5,5,131,3,10,8,1,4,4,3\",\"rows\":[{\"kind\":\"h\",\"line\":false,\"old\":null,\"new\":null,\"text\":\"@@ -0,0 +1,11 @@\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":1,\"text\":\"//! Splitting text into the things tally counts.\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":2,\"text\":\"\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":3,\"text\":\"/// The words of `line`: runs of anything but whitespace.\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":4,\"text\":\"pub fn words(line: &str) -> usize {\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":5,\"text\":\" line.split_whitespace().count()\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":6,\"text\":\"}\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":7,\"text\":\"\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":8,\"text\":\"/// The lines of `text`. A last line without a newline still counts.\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":9,\"text\":\"pub fn lines(text: &str) -> impl Iterator<Item = &str> {\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":10,\"text\":\" text.lines()\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":11,\"text\":\"}\"}]}]}"
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"commit\":{\"sha\":\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\",\"parents\":[\"4f6b9a1168c6534f64c836b4c3a11e23e74637ad\"],\"author\":{\"name\":\"Alice Moreau\",\"account\":\"alice.test\",\"avatar\":null},\"coauthors\":[],\"date\":\"2026-10-08T11:47:00+00:00\",\"message\":\"Split parsing into its own module\"},\"files\":[{\"old\":\"docs/design.md\",\"new\":\"docs/DESIGN.md\",\"status\":\"renamed\",\"added\":0,\"removed\":0,\"note\":\"Moved without changes.\",\"ranges\":null,\"rows\":[]},{\"old\":\"src/lib.rs\",\"new\":\"src/lib.rs\",\"status\":\"modified\",\"added\":9,\"removed\":2,\"note\":null,\"ranges\":\"comment:0,58,14,22,58,30;string:529,18,153,6;keyword:60,3,69,3,1,6,41,3,38,3,1,3,65,3,6,2,20,3,6,2,177,3,120,2;number:384,1,203,1,9,1,9,2,105,1;function:262,3,14,7,27,5,28,5,65,16,3,5,35,5,43,5,30,10,73,29,13,10;type:104,5,2,7,2,9,14,6,54,3,5,6,24,6,24,6,295,6;attr:95,9,5,2,7,2,9,2,495,7;xins:339,17,112,17;xdel:308,11,92,30\",\"rows\":[{\"kind\":\"h\",\"line\":false,\"old\":null,\"new\":null,\"text\":\"@@ -1,5 +1,7 @@\"},{\"kind\":\"\",\"line\":true,\"old\":1,\"new\":1,\"text\":\"//! Counting, kept apart from reading so it can be tested.\"},{\"kind\":\"\",\"line\":true,\"old\":2,\"new\":2,\"text\":\"\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":3,\"text\":\"mod parse;\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":4,\"text\":\"\"},{\"kind\":\"\",\"line\":true,\"old\":3,\"new\":5,\"text\":\"/// What a text holds.\"},{\"kind\":\"\",\"line\":true,\"old\":4,\"new\":6,\"text\":\"#[derive(Debug, Default, PartialEq)]\"},{\"kind\":\"\",\"line\":true,\"old\":5,\"new\":7,\"text\":\"pub struct Counts {\"},{\"kind\":\"h\",\"line\":false,\"old\":null,\"new\":null,\"text\":\"@@ -11,9 +13,9 @@ pub struct Counts {\"},{\"kind\":\"\",\"line\":true,\"old\":11,\"new\":13,\"text\":\"/// Counts `text` in one pass.\"},{\"kind\":\"\",\"line\":true,\"old\":12,\"new\":14,\"text\":\"pub fn count(text: &str) -> Counts {\"},{\"kind\":\"\",\"line\":true,\"old\":13,\"new\":15,\"text\":\" let mut counts = Counts { bytes: text.len(), ..Counts::default() };\"},{\"kind\":\"d\",\"line\":true,\"old\":14,\"new\":null,\"text\":\" for line in text.lines() {\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":16,\"text\":\" for line in parse::lines(text) {\"},{\"kind\":\"\",\"line\":true,\"old\":15,\"new\":17,\"text\":\" counts.lines += 1;\"},{\"kind\":\"d\",\"line\":true,\"old\":16,\"new\":null,\"text\":\" counts.words += line.split_whitespace().count();\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":18,\"text\":\" counts.words += parse::words(line);\"},{\"kind\":\"\",\"line\":true,\"old\":17,\"new\":19,\"text\":\" }\"},{\"kind\":\"\",\"line\":true,\"old\":18,\"new\":20,\"text\":\" counts\"},{\"kind\":\"\",\"line\":true,\"old\":19,\"new\":21,\"text\":\"}\"},{\"kind\":\"h\",\"line\":false,\"old\":null,\"new\":null,\"text\":\"@@ -27,4 +29,9 @@ mod tests {\"},{\"kind\":\"\",\"line\":true,\"old\":27,\"new\":29,\"text\":\" let c = count(\\\"one two\\\\nthree\\\\n\\\");\"},{\"kind\":\"\",\"line\":true,\"old\":28,\"new\":30,\"text\":\" assert_eq!(c, Counts { lines: 2, words: 3, bytes: 14 });\"},{\"kind\":\"\",\"line\":true,\"old\":29,\"new\":31,\"text\":\" }\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":32,\"text\":\"\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":33,\"text\":\" #[test]\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":34,\"text\":\" fn a_last_line_without_a_newline() {\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":35,\"text\":\" assert_eq!(count(\\\"a\\\\nb\\\").lines, 2);\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":36,\"text\":\" }\"},{\"kind\":\"\",\"line\":true,\"old\":30,\"new\":37,\"text\":\"}\"}]},{\"old\":\"src/parse.rs\",\"new\":\"src/parse.rs\",\"status\":\"added\",\"added\":11,\"removed\":0,\"note\":null,\"ranges\":\"comment:0,48,2,57,76,68;keyword:108,3,1,2,138,3,1,2,22,4;function:115,5,33,16,3,5,82,5,54,5;type:128,3,5,5,131,3,10,8,1,4,4,3\",\"rows\":[{\"kind\":\"h\",\"line\":false,\"old\":null,\"new\":null,\"text\":\"@@ -0,0 +1,11 @@\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":1,\"text\":\"//! Splitting text into the things tally counts.\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":2,\"text\":\"\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":3,\"text\":\"/// The words of `line`: runs of anything but whitespace.\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":4,\"text\":\"pub fn words(line: &str) -> usize {\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":5,\"text\":\" line.split_whitespace().count()\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":6,\"text\":\"}\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":7,\"text\":\"\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":8,\"text\":\"/// The lines of `text`. A last line without a newline still counts.\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":9,\"text\":\"pub fn lines(text: &str) -> impl Iterator<Item = &str> {\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":10,\"text\":\" text.lines()\"},{\"kind\":\"a\",\"line\":true,\"old\":null,\"new\":11,\"text\":\"}\"}]}]}"
},
"/_data/alice.test/tally/commits/main": {
"status": 200,
@@ -95,23 +95,23 @@
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"rev\":\"main\",\"path\":\"\",\"page\":0,\"commits\":[{\"sha\":\"686a36205b82620af07dd99997c570f04f461df9\",\"parents\":[\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\"],\"author\":\"Bob Tanaka\",\"date\":\"2026-10-09T09:31:00+00:00\",\"message\":\"Count no lines in empty input\"},{\"sha\":\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\",\"parents\":[\"4f6b9a1168c6534f64c836b4c3a11e23e74637ad\"],\"author\":\"Alice Moreau\",\"date\":\"2026-10-08T11:47:00+00:00\",\"message\":\"Split parsing into its own module\"},{\"sha\":\"4f6b9a1168c6534f64c836b4c3a11e23e74637ad\",\"parents\":[\"d1f55d39d12c4d6ba6ab25b1e9c76d15fb2cbaca\"],\"author\":\"Alice Moreau\",\"date\":\"2026-10-06T10:02:00+00:00\",\"message\":\"Document usage and output\"},{\"sha\":\"d1f55d39d12c4d6ba6ab25b1e9c76d15fb2cbaca\",\"parents\":[\"7818a5bc1bef9184ad313cf79175da3d87d4b9cf\"],\"author\":\"Alice Moreau\",\"date\":\"2026-10-05T17:21:00+00:00\",\"message\":\"Add a logo\"},{\"sha\":\"7818a5bc1bef9184ad313cf79175da3d87d4b9cf\",\"parents\":[\"a7e467c6f9acd7b1be0603afdec0b8cfdcf3cf49\"],\"author\":\"Alice Moreau\",\"date\":\"2026-10-03T08:05:00+00:00\",\"message\":\"Read from stdin when no file is given\\n\\nSo tally works at the end of a pipe, as wc does. The name shown\\nfor stdin is \\\"-\\\".\"},{\"sha\":\"a7e467c6f9acd7b1be0603afdec0b8cfdcf3cf49\",\"parents\":[\"cac02c464843db4d55fecfa69d327ef7b12702c0\"],\"author\":\"Alice Moreau\",\"date\":\"2026-10-02T14:40:00+00:00\",\"message\":\"Count words, lines and bytes\"},{\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\",\"parents\":[],\"author\":\"Alice Moreau\",\"date\":\"2026-10-01T09:12:00+00:00\",\"message\":\"Start tally\"}],\"more\":false}"
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"rev\":\"main\",\"path\":\"\",\"page\":0,\"commits\":[{\"sha\":\"686a36205b82620af07dd99997c570f04f461df9\",\"parents\":[\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\"],\"author\":{\"name\":\"Bob Tanaka\",\"account\":null,\"avatar\":null},\"coauthors\":[],\"date\":\"2026-10-09T09:31:00+00:00\",\"message\":\"Count no lines in empty input\"},{\"sha\":\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\",\"parents\":[\"4f6b9a1168c6534f64c836b4c3a11e23e74637ad\"],\"author\":{\"name\":\"Alice Moreau\",\"account\":\"alice.test\",\"avatar\":null},\"coauthors\":[],\"date\":\"2026-10-08T11:47:00+00:00\",\"message\":\"Split parsing into its own module\"},{\"sha\":\"4f6b9a1168c6534f64c836b4c3a11e23e74637ad\",\"parents\":[\"d1f55d39d12c4d6ba6ab25b1e9c76d15fb2cbaca\"],\"author\":{\"name\":\"Alice Moreau\",\"account\":\"alice.test\",\"avatar\":null},\"coauthors\":[],\"date\":\"2026-10-06T10:02:00+00:00\",\"message\":\"Document usage and output\"},{\"sha\":\"d1f55d39d12c4d6ba6ab25b1e9c76d15fb2cbaca\",\"parents\":[\"7818a5bc1bef9184ad313cf79175da3d87d4b9cf\"],\"author\":{\"name\":\"Alice Moreau\",\"account\":\"alice.test\",\"avatar\":null},\"coauthors\":[],\"date\":\"2026-10-05T17:21:00+00:00\",\"message\":\"Add a logo\"},{\"sha\":\"7818a5bc1bef9184ad313cf79175da3d87d4b9cf\",\"parents\":[\"a7e467c6f9acd7b1be0603afdec0b8cfdcf3cf49\"],\"author\":{\"name\":\"Alice Moreau\",\"account\":\"alice.test\",\"avatar\":null},\"coauthors\":[],\"date\":\"2026-10-03T08:05:00+00:00\",\"message\":\"Read from stdin when no file is given\\n\\nSo tally works at the end of a pipe, as wc does. The name shown\\nfor stdin is \\\"-\\\".\"},{\"sha\":\"a7e467c6f9acd7b1be0603afdec0b8cfdcf3cf49\",\"parents\":[\"cac02c464843db4d55fecfa69d327ef7b12702c0\"],\"author\":{\"name\":\"Alice Moreau\",\"account\":\"alice.test\",\"avatar\":null},\"coauthors\":[],\"date\":\"2026-10-02T14:40:00+00:00\",\"message\":\"Count words, lines and bytes\"},{\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\",\"parents\":[],\"author\":{\"name\":\"Alice Moreau\",\"account\":\"alice.test\",\"avatar\":null},\"coauthors\":[],\"date\":\"2026-10-01T09:12:00+00:00\",\"message\":\"Start tally\"}],\"more\":false}"
},
"/_data/alice.test/tally/issue/did:plc:22yzhoyrfxuuax3knxdw3t72/3myg7luqyfmyu": {
"/_data/alice.test/tally/issue/did:plc:jxhkyipyrdvki5qrhox3ipdg/3myggyli5er6w": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"kind\":\"issue\",\"title\":\"Fails on files that are not UTF-8\",\"state\":\"open\",\"author\":\"bob.test\",\"avatar\":null,\"createdAt\":\"2026-10-09T23:34:00.000Z\",\"base\":null,\"from\":null,\"branch\":null,\"body\":\"<p>Running <code>tally image.png</code> panics:</p>\\n<pre><code>thread 'main' panicked at src/main.rs:8:56:\\nstream did not contain valid UTF-8\\n</code></pre>\\n<p>wc counts bytes for any file.</p>\\n\",\"comments\":[{\"author\":\"alice.test\",\"avatar\":null,\"createdAt\":\"2026-10-10T00:11:00.000Z\",\"html\":\"<p>Confirmed. Reading bytes and counting words lossily would fix it.</p>\\n\",\"verdict\":null}],\"merged\":null,\"about\":\"Running `tally image.png` panics: ``` thread 'main' panicked at src/main.rs:8:56: stream did not contain valid UTF-8 ``` wc counts bytes for any file.\",\"card\":\"/alice.test/tally/issue/did:plc:22yzhoyrfxuuax3knxdw3t72/3myg7luqyfmyu/card.png?v=160a3e2db8db2127\"}"
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"kind\":\"issue\",\"title\":\"Fails on files that are not UTF-8\",\"state\":\"open\",\"author\":\"bob.test\",\"avatar\":null,\"createdAt\":\"2026-10-09T23:34:00.000Z\",\"base\":null,\"from\":null,\"branch\":null,\"body\":\"<p>Running <code>tally image.png</code> panics:</p>\\n<pre><code>thread 'main' panicked at src/main.rs:8:56:\\nstream did not contain valid UTF-8\\n</code></pre>\\n<p>wc counts bytes for any file.</p>\\n\",\"comments\":[{\"author\":\"alice.test\",\"avatar\":null,\"createdAt\":\"2026-10-10T00:11:00.000Z\",\"html\":\"<p>Confirmed. Reading bytes and counting words lossily would fix it.</p>\\n\",\"verdict\":null}],\"merged\":null,\"about\":\"Running `tally image.png` panics: ``` thread 'main' panicked at src/main.rs:8:56: stream did not contain valid UTF-8 ``` wc counts bytes for any file.\",\"card\":\"/alice.test/tally/issue/did:plc:jxhkyipyrdvki5qrhox3ipdg/3myggyli5er6w/card.png?v=160a3e2db8db2127\"}"
},
"/_data/alice.test/tally/issue/did:plc:22yzhoyrfxuuax3knxdw3t72/3myg7lusdxeyx": {
"/_data/alice.test/tally/issue/did:plc:jxhkyipyrdvki5qrhox3ipdg/3myggylju6b6y": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"kind\":\"issue\",\"title\":\"Typo in the README ✏️\",\"state\":\"closed\",\"author\":\"bob.test\",\"avatar\":null,\"createdAt\":\"2026-10-10T00:48:00.000Z\",\"base\":null,\"from\":null,\"branch\":null,\"body\":null,\"comments\":[],\"merged\":null,\"about\":\"An issue on alice.test/tally by bob.test.\",\"card\":\"/alice.test/tally/issue/did:plc:22yzhoyrfxuuax3knxdw3t72/3myg7lusdxeyx/card.png?v=72fe47df61de13f9\"}"
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"kind\":\"issue\",\"title\":\"Typo in the README ✏️\",\"state\":\"closed\",\"author\":\"bob.test\",\"avatar\":null,\"createdAt\":\"2026-10-10T00:48:00.000Z\",\"base\":null,\"from\":null,\"branch\":null,\"body\":null,\"comments\":[],\"merged\":null,\"about\":\"An issue on alice.test/tally by bob.test.\",\"card\":\"/alice.test/tally/issue/did:plc:jxhkyipyrdvki5qrhox3ipdg/3myggylju6b6y/card.png?v=72fe47df61de13f9\"}"
},
"/_data/alice.test/tally/issues": {
"status": 200,
@@ -119,7 +119,7 @@
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"kind\":\"issue\",\"open\":true,\"opened\":1,\"closed\":1,\"items\":[{\"title\":\"Fails on files that are not UTF-8\",\"state\":\"open\",\"href\":\"/alice.test/tally/issue/did:plc:22yzhoyrfxuuax3knxdw3t72/3myg7luqyfmyu\",\"author\":\"bob.test\",\"createdAt\":\"2026-10-09T23:34:00.000Z\",\"comments\":1}]}"
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"kind\":\"issue\",\"open\":true,\"opened\":1,\"closed\":1,\"items\":[{\"title\":\"Fails on files that are not UTF-8\",\"state\":\"open\",\"href\":\"/alice.test/tally/issue/did:plc:jxhkyipyrdvki5qrhox3ipdg/3myggyli5er6w\",\"author\":\"bob.test\",\"createdAt\":\"2026-10-09T23:34:00.000Z\",\"comments\":1}]}"
},
"/_data/alice.test/tally/issues?state=closed": {
"status": 200,
@@ -127,23 +127,23 @@
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"kind\":\"issue\",\"open\":false,\"opened\":1,\"closed\":1,\"items\":[{\"title\":\"Typo in the README ✏️\",\"state\":\"closed\",\"href\":\"/alice.test/tally/issue/did:plc:22yzhoyrfxuuax3knxdw3t72/3myg7lusdxeyx\",\"author\":\"bob.test\",\"createdAt\":\"2026-10-10T00:48:00.000Z\",\"comments\":0}]}"
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"kind\":\"issue\",\"open\":false,\"opened\":1,\"closed\":1,\"items\":[{\"title\":\"Typo in the README ✏️\",\"state\":\"closed\",\"href\":\"/alice.test/tally/issue/did:plc:jxhkyipyrdvki5qrhox3ipdg/3myggylju6b6y\",\"author\":\"bob.test\",\"createdAt\":\"2026-10-10T00:48:00.000Z\",\"comments\":0}]}"
},
"/_data/alice.test/tally/pull/did:plc:22yzhoyrfxuuax3knxdw3t72/3myg7ltvdaqlc": {
"/_data/alice.test/tally/pull/did:plc:jxhkyipyrdvki5qrhox3ipdg/3myggykid2nrg": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"kind\":\"pull\",\"title\":\"Count no lines in empty input\",\"state\":\"merged\",\"author\":\"bob.test\",\"avatar\":null,\"createdAt\":\"2026-10-09T18:38:00.000Z\",\"base\":\"main\",\"from\":\"bob.test/tally\",\"branch\":\"empty-input\",\"body\":\"<p><code>tally</code> printed one line for an empty file. <code>lines</code> now yields nothing when the text is empty, and a test covers it.</p>\\n\",\"comments\":[{\"author\":\"alice.test\",\"avatar\":null,\"createdAt\":\"2026-10-09T19:15:00.000Z\",\"html\":\"<p>Could <code>lines</code> check for emptiness once, rather than in the filter?</p>\\n\",\"verdict\":\"changes\"},{\"author\":\"bob.test\",\"avatar\":null,\"createdAt\":\"2026-10-09T19:52:00.000Z\",\"html\":\"<p>It only runs per line, so the cost is one comparison each. Happy to change it if you prefer.</p>\\n\",\"verdict\":null},{\"author\":\"alice.test\",\"avatar\":null,\"createdAt\":\"2026-10-09T20:29:00.000Z\",\"html\":\"<p>Fair enough. Thanks!</p>\\n\",\"verdict\":\"approve\"}],\"merged\":\"686a36205b82620af07dd99997c570f04f461df9\",\"about\":\"`tally` printed one line for an empty file. `lines` now yields nothing when the text is empty, and a test covers it.\",\"card\":\"/alice.test/tally/pull/did:plc:22yzhoyrfxuuax3knxdw3t72/3myg7ltvdaqlc/card.png?v=7f555a00efc7106b\"}"
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"kind\":\"pull\",\"title\":\"Count no lines in empty input\",\"state\":\"merged\",\"author\":\"bob.test\",\"avatar\":null,\"createdAt\":\"2026-10-09T18:38:00.000Z\",\"base\":\"main\",\"from\":\"bob.test/tally\",\"branch\":\"empty-input\",\"body\":\"<p><code>tally</code> printed one line for an empty file. <code>lines</code> now yields nothing when the text is empty, and a test covers it.</p>\\n\",\"comments\":[{\"author\":\"alice.test\",\"avatar\":null,\"createdAt\":\"2026-10-09T19:15:00.000Z\",\"html\":\"<p>Could <code>lines</code> check for emptiness once, rather than in the filter?</p>\\n\",\"verdict\":\"changes\"},{\"author\":\"bob.test\",\"avatar\":null,\"createdAt\":\"2026-10-09T19:52:00.000Z\",\"html\":\"<p>It only runs per line, so the cost is one comparison each. Happy to change it if you prefer.</p>\\n\",\"verdict\":null},{\"author\":\"alice.test\",\"avatar\":null,\"createdAt\":\"2026-10-09T20:29:00.000Z\",\"html\":\"<p>Fair enough. Thanks!</p>\\n\",\"verdict\":\"approve\"}],\"merged\":\"686a36205b82620af07dd99997c570f04f461df9\",\"about\":\"`tally` printed one line for an empty file. `lines` now yields nothing when the text is empty, and a test covers it.\",\"card\":\"/alice.test/tally/pull/did:plc:jxhkyipyrdvki5qrhox3ipdg/3myggykid2nrg/card.png?v=7f555a00efc7106b\"}"
},
"/_data/alice.test/tally/pull/did:plc:22yzhoyrfxuuax3knxdw3t72/3myg7luqbsoys": {
"/_data/alice.test/tally/pull/did:plc:jxhkyipyrdvki5qrhox3ipdg/3myggylhg5l6v": {
"status": 200,
"headers": {
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"kind\":\"pull\",\"title\":\"Print counts as JSON with --json\",\"state\":\"open\",\"author\":\"bob.test\",\"avatar\":null,\"createdAt\":\"2026-10-09T22:57:00.000Z\",\"base\":\"main\",\"from\":\"bob.test/tally\",\"branch\":\"json\",\"body\":\"<p>For scripts. The object has <code>lines</code>, <code>words</code>, <code>bytes</code> and <code>name</code>.</p>\\n\",\"comments\":[],\"merged\":null,\"about\":\"For scripts. The object has `lines`, `words`, `bytes` and `name`.\",\"card\":\"/alice.test/tally/pull/did:plc:22yzhoyrfxuuax3knxdw3t72/3myg7luqbsoys/card.png?v=029fd2cd417c8b94\"}"
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"kind\":\"pull\",\"title\":\"Print counts as JSON with --json\",\"state\":\"open\",\"author\":\"bob.test\",\"avatar\":null,\"createdAt\":\"2026-10-09T22:57:00.000Z\",\"base\":\"main\",\"from\":\"bob.test/tally\",\"branch\":\"json\",\"body\":\"<p>For scripts. The object has <code>lines</code>, <code>words</code>, <code>bytes</code> and <code>name</code>.</p>\\n\",\"comments\":[],\"merged\":null,\"about\":\"For scripts. The object has `lines`, `words`, `bytes` and `name`.\",\"card\":\"/alice.test/tally/pull/did:plc:jxhkyipyrdvki5qrhox3ipdg/3myggylhg5l6v/card.png?v=029fd2cd417c8b94\"}"
},
"/_data/alice.test/tally/pulls": {
"status": 200,
@@ -151,7 +151,7 @@
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"kind\":\"pull\",\"open\":true,\"opened\":1,\"closed\":1,\"items\":[{\"title\":\"Print counts as JSON with --json\",\"state\":\"open\",\"href\":\"/alice.test/tally/pull/did:plc:22yzhoyrfxuuax3knxdw3t72/3myg7luqbsoys\",\"author\":\"bob.test\",\"createdAt\":\"2026-10-09T22:57:00.000Z\",\"comments\":0}]}"
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"kind\":\"pull\",\"open\":true,\"opened\":1,\"closed\":1,\"items\":[{\"title\":\"Print counts as JSON with --json\",\"state\":\"open\",\"href\":\"/alice.test/tally/pull/did:plc:jxhkyipyrdvki5qrhox3ipdg/3myggylhg5l6v\",\"author\":\"bob.test\",\"createdAt\":\"2026-10-09T22:57:00.000Z\",\"comments\":0}]}"
},
"/_data/alice.test/tally/pulls?state=closed": {
"status": 200,
@@ -159,7 +159,7 @@
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"kind\":\"pull\",\"open\":false,\"opened\":1,\"closed\":1,\"items\":[{\"title\":\"Count no lines in empty input\",\"state\":\"merged\",\"href\":\"/alice.test/tally/pull/did:plc:22yzhoyrfxuuax3knxdw3t72/3myg7ltvdaqlc\",\"author\":\"bob.test\",\"createdAt\":\"2026-10-09T18:38:00.000Z\",\"comments\":3}]}"
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"kind\":\"pull\",\"open\":false,\"opened\":1,\"closed\":1,\"items\":[{\"title\":\"Count no lines in empty input\",\"state\":\"merged\",\"href\":\"/alice.test/tally/pull/did:plc:jxhkyipyrdvki5qrhox3ipdg/3myggykid2nrg\",\"author\":\"bob.test\",\"createdAt\":\"2026-10-09T18:38:00.000Z\",\"comments\":3}]}"
},
"/_data/alice.test/tally/tree/main/src": {
"status": 200,
@@ -167,7 +167,7 @@
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"rev\":\"main\",\"path\":\"src\",\"empty\":false,\"last\":{\"sha\":\"686a36205b82620af07dd99997c570f04f461df9\",\"parents\":[\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\"],\"author\":\"Bob Tanaka\",\"date\":\"2026-10-09T09:31:00+00:00\",\"message\":\"Count no lines in empty input\"},\"entries\":[{\"name\":\"lib.rs\",\"kind\":\"blob\",\"size\":783},{\"name\":\"main.rs\",\"kind\":\"blob\",\"size\":484},{\"name\":\"parse.rs\",\"kind\":\"blob\",\"size\":518}],\"readme\":null}"
"body": "{\"repo\":{\"authority\":\"alice.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":null,\"head\":\"main\",\"pulls\":1,\"issues\":1,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/alice.test/tally/card.png?v=715ad689e9f7ec63\"},\"rev\":\"main\",\"path\":\"src\",\"empty\":false,\"last\":{\"sha\":\"686a36205b82620af07dd99997c570f04f461df9\",\"parents\":[\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\"],\"author\":{\"name\":\"Bob Tanaka\",\"account\":null,\"avatar\":null},\"coauthors\":[],\"date\":\"2026-10-09T09:31:00+00:00\",\"message\":\"Count no lines in empty input\"},\"entries\":[{\"name\":\"lib.rs\",\"kind\":\"blob\",\"size\":783},{\"name\":\"main.rs\",\"kind\":\"blob\",\"size\":484},{\"name\":\"parse.rs\",\"kind\":\"blob\",\"size\":518}],\"readme\":null}"
},
"/_data/bob.test": {
"status": 200,
@@ -175,7 +175,7 @@
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"did\":\"did:plc:22yzhoyrfxuuax3knxdw3t72\",\"handle\":\"bob.test\",\"name\":null,\"bio\":null,\"avatar\":null,\"about\":null,\"repos\":[{\"owner\":\"bob.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":{\"shown\":\"alice.test/tally\",\"href\":\"/alice.test/tally\"},\"createdAt\":\"2026-10-09T14:19:00.000Z\",\"private\":false}]}"
"body": "{\"did\":\"did:plc:jxhkyipyrdvki5qrhox3ipdg\",\"handle\":\"bob.test\",\"name\":null,\"bio\":null,\"avatar\":null,\"about\":null,\"repos\":[{\"owner\":\"bob.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":{\"shown\":\"alice.test/tally\",\"href\":\"/alice.test/tally\"},\"createdAt\":\"2026-10-09T14:19:00.000Z\",\"private\":false}]}"
},
"/_data/bob.test/tally": {
"status": 200,
@@ -183,7 +183,7 @@
"content-type": "application/json",
"cache-control": "no-cache"
},
"body": "{\"repo\":{\"authority\":\"bob.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":{\"shown\":\"alice.test/tally\",\"href\":\"/alice.test/tally\"},\"head\":\"main\",\"pulls\":0,\"issues\":0,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/bob.test/tally/card.png?v=e791445b9678abe3\"},\"rev\":\"main\",\"path\":\"\",\"empty\":false,\"last\":{\"sha\":\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\",\"parents\":[\"4f6b9a1168c6534f64c836b4c3a11e23e74637ad\"],\"author\":\"Alice Moreau\",\"date\":\"2026-10-08T11:47:00+00:00\",\"message\":\"Split parsing into its own module\"},\"entries\":[{\"name\":\"assets\",\"kind\":\"tree\",\"size\":null},{\"name\":\"docs\",\"kind\":\"tree\",\"size\":null},{\"name\":\"src\",\"kind\":\"tree\",\"size\":null},{\"name\":\".gitignore\",\"kind\":\"blob\",\"size\":8},{\"name\":\"Cargo.toml\",\"kind\":\"blob\",\"size\":122},{\"name\":\"README.md\",\"kind\":\"blob\",\"size\":388}],\"readme\":{\"name\":\"README.md\",\"html\":\"<h1>tally</h1>\\n<p>Counts lines, words and bytes, like <code>wc</code>, with output that lines up.</p>\\n<p><img src=\\\"/bob.test/tally/raw/main/assets/logo.svg\\\" alt=\\\"tally\\\" /></p>\\n<h2>Usage</h2>\\n<pre><code class=\\\"language-sh\\\">cargo install --path .\\ntally notes.txt\\necho \\\"one two\\\" | tally\\n</code></pre>\\n<h2>Output</h2>\\n<table><thead><tr><th>Column</th><th>Meaning</th></tr></thead><tbody>\\n<tr><td>1</td><td>lines</td></tr>\\n<tr><td>2</td><td>words, split on whitespace</td></tr>\\n<tr><td>3</td><td>bytes</td></tr>\\n</tbody></table>\\n<p>See <a href=\\\"/bob.test/tally/blob/main/docs/design.md\\\">the design notes</a> for why it reads the whole file.</p>\\n<h2>License</h2>\\n<p>MIT</p>\\n\",\"text\":null}}"
"body": "{\"repo\":{\"authority\":\"bob.test\",\"avatar\":null,\"name\":\"tally\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":{\"shown\":\"alice.test/tally\",\"href\":\"/alice.test/tally\"},\"head\":\"main\",\"pulls\":0,\"issues\":0,\"about\":\"Counts lines, words and bytes, with output that lines up\",\"card\":\"/bob.test/tally/card.png?v=e791445b9678abe3\"},\"rev\":\"main\",\"path\":\"\",\"empty\":false,\"last\":{\"sha\":\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\",\"parents\":[\"4f6b9a1168c6534f64c836b4c3a11e23e74637ad\"],\"author\":{\"name\":\"Alice Moreau\",\"account\":null,\"avatar\":null},\"coauthors\":[],\"date\":\"2026-10-08T11:47:00+00:00\",\"message\":\"Split parsing into its own module\"},\"entries\":[{\"name\":\"assets\",\"kind\":\"tree\",\"size\":null},{\"name\":\"docs\",\"kind\":\"tree\",\"size\":null},{\"name\":\"src\",\"kind\":\"tree\",\"size\":null},{\"name\":\".gitignore\",\"kind\":\"blob\",\"size\":8},{\"name\":\"Cargo.toml\",\"kind\":\"blob\",\"size\":122},{\"name\":\"README.md\",\"kind\":\"blob\",\"size\":388}],\"readme\":{\"name\":\"README.md\",\"html\":\"<h1>tally</h1>\\n<p>Counts lines, words and bytes, like <code>wc</code>, with output that lines up.</p>\\n<p><img src=\\\"/bob.test/tally/raw/main/assets/logo.svg\\\" alt=\\\"tally\\\" /></p>\\n<h2>Usage</h2>\\n<pre><code class=\\\"language-sh\\\">cargo install --path .\\ntally notes.txt\\necho \\\"one two\\\" | tally\\n</code></pre>\\n<h2>Output</h2>\\n<table><thead><tr><th>Column</th><th>Meaning</th></tr></thead><tbody>\\n<tr><td>1</td><td>lines</td></tr>\\n<tr><td>2</td><td>words, split on whitespace</td></tr>\\n<tr><td>3</td><td>bytes</td></tr>\\n</tbody></table>\\n<p>See <a href=\\\"/bob.test/tally/blob/main/docs/design.md\\\">the design notes</a> for why it reads the whole file.</p>\\n<h2>License</h2>\\n<p>MIT</p>\\n\",\"text\":null}}"
},
"/_data/search?q=t": {
"status": 200,
@@ -216,7 +216,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"events\":[{\"createdAt\":\"2026-10-10T01:25:00.000Z\",\"actor\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#changed\",\"subject\":\"at://did:plc:22yzhoyrfxuuax3knxdw3t72/dev.knotless.issue/3myg7lusdxeyx\",\"title\":\"Typo in the README ✏️\",\"state\":\"closed\"}},{\"createdAt\":\"2026-10-10T00:48:00.000Z\",\"actor\":{\"did\":\"did:plc:22yzhoyrfxuuax3knxdw3t72\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#opened\",\"subject\":\"at://did:plc:22yzhoyrfxuuax3knxdw3t72/dev.knotless.issue/3myg7lusdxeyx\",\"title\":\"Typo in the README ✏️\"}},{\"createdAt\":\"2026-10-10T00:11:00.000Z\",\"actor\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#commented\",\"subject\":\"at://did:plc:22yzhoyrfxuuax3knxdw3t72/dev.knotless.issue/3myg7luqyfmyu\",\"title\":\"Fails on files that are not UTF-8\",\"text\":\"Confirmed. Reading bytes and counting words lossily would fix it.\"}},{\"createdAt\":\"2026-10-09T23:34:00.000Z\",\"actor\":{\"did\":\"did:plc:22yzhoyrfxuuax3knxdw3t72\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#opened\",\"subject\":\"at://did:plc:22yzhoyrfxuuax3knxdw3t72/dev.knotless.issue/3myg7luqyfmyu\",\"title\":\"Fails on files that are not UTF-8\"}},{\"createdAt\":\"2026-10-09T22:57:00.000Z\",\"actor\":{\"did\":\"did:plc:22yzhoyrfxuuax3knxdw3t72\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#opened\",\"subject\":\"at://did:plc:22yzhoyrfxuuax3knxdw3t72/dev.knotless.pull/3myg7luqbsoys\",\"title\":\"Print counts as JSON with --json\"}},{\"createdAt\":\"2026-10-09T22:20:00.000Z\",\"actor\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#changed\",\"subject\":\"at://did:plc:22yzhoyrfxuuax3knxdw3t72/dev.knotless.pull/3myg7ltvdaqlc\",\"title\":\"Count no lines in empty input\",\"state\":\"merged\"}},{\"createdAt\":\"2026-10-09T21:06:00.000Z\",\"actor\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#pushed\",\"ref\":\"refs/heads/main\",\"created\":false,\"count\":1,\"commits\":[{\"sha\":\"686a36205b82620af07dd99997c570f04f461df9\",\"message\":\"Count no lines in empty input\"}]}},{\"createdAt\":\"2026-10-09T20:29:00.000Z\",\"actor\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#commented\",\"subject\":\"at://did:plc:22yzhoyrfxuuax3knxdw3t72/dev.knotless.pull/3myg7ltvdaqlc\",\"title\":\"Count no lines in empty input\",\"text\":\"Fair enough. Thanks!\",\"verdict\":\"approve\"}},{\"createdAt\":\"2026-10-09T19:52:00.000Z\",\"actor\":{\"did\":\"did:plc:22yzhoyrfxuuax3knxdw3t72\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#commented\",\"subject\":\"at://did:plc:22yzhoyrfxuuax3knxdw3t72/dev.knotless.pull/3myg7ltvdaqlc\",\"title\":\"Count no lines in empty input\",\"text\":\"It only runs per line, so the cost is one comparison each. Happy to change it if you prefer.\"}},{\"createdAt\":\"2026-10-09T19:15:00.000Z\",\"actor\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#commented\",\"subject\":\"at://did:plc:22yzhoyrfxuuax3knxdw3t72/dev.knotless.pull/3myg7ltvdaqlc\",\"title\":\"Count no lines in empty input\",\"text\":\"Could `lines` check for emptiness once, rather than in the filter?\",\"verdict\":\"changes\"}},{\"createdAt\":\"2026-10-09T18:38:00.000Z\",\"actor\":{\"did\":\"did:plc:22yzhoyrfxuuax3knxdw3t72\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#opened\",\"subject\":\"at://did:plc:22yzhoyrfxuuax3knxdw3t72/dev.knotless.pull/3myg7ltvdaqlc\",\"title\":\"Count no lines in empty input\"}},{\"createdAt\":\"2026-10-09T17:24:00.000Z\",\"actor\":{\"did\":\"did:plc:22yzhoyrfxuuax3knxdw3t72\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:22yzhoyrfxuuax3knxdw3t72\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#pushed\",\"ref\":\"refs/heads/json\",\"created\":true,\"count\":1,\"commits\":[{\"sha\":\"2063dabcda7417d5831f3b6bfd8ffb18da82fb8e\",\"message\":\"Print counts as JSON with --json\"}]}},{\"createdAt\":\"2026-10-09T16:10:00.000Z\",\"actor\":{\"did\":\"did:plc:22yzhoyrfxuuax3knxdw3t72\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:22yzhoyrfxuuax3knxdw3t72\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#pushed\",\"ref\":\"refs/heads/empty-input\",\"created\":true,\"count\":1,\"commits\":[{\"sha\":\"686a36205b82620af07dd99997c570f04f461df9\",\"message\":\"Count no lines in empty input\"}]}},{\"createdAt\":\"2026-10-09T14:19:00.000Z\",\"actor\":{\"did\":\"did:plc:22yzhoyrfxuuax3knxdw3t72\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:22yzhoyrfxuuax3knxdw3t72\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#created\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"tally\"}}},{\"createdAt\":\"2026-10-09T13:42:00.000Z\",\"actor\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"scratch\"},\"action\":{\"$type\":\"dev.knotless.getFeed#created\"}},{\"createdAt\":\"2026-10-09T12:28:00.000Z\",\"actor\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"notes\"},\"action\":{\"$type\":\"dev.knotless.getFeed#pushed\",\"ref\":\"refs/heads/main\",\"created\":true,\"count\":1,\"commits\":[{\"sha\":\"e78543abf0f799b7eb5713ccdf8d88b84d0f8ec0\",\"message\":\"Begin\"}]}},{\"createdAt\":\"2026-10-09T11:51:00.000Z\",\"actor\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"notes\"},\"action\":{\"$type\":\"dev.knotless.getFeed#created\",\"description\":\"Scratch notes 📝\"}},{\"createdAt\":\"2026-10-09T10:37:00.000Z\",\"actor\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#pushed\",\"ref\":\"refs/heads/main\",\"created\":true,\"count\":6,\"commits\":[{\"sha\":\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\",\"message\":\"Split parsing into its own module\"},{\"sha\":\"4f6b9a1168c6534f64c836b4c3a11e23e74637ad\",\"message\":\"Document usage and output\"},{\"sha\":\"d1f55d39d12c4d6ba6ab25b1e9c76d15fb2cbaca\",\"message\":\"Add a logo\"},{\"sha\":\"7818a5bc1bef9184ad313cf79175da3d87d4b9cf\",\"message\":\"Read from stdin when no file is given\\n\\nSo tally works at the end of a pipe, as wc does. The name shown\\nfor stdin is \\\"-\\\".\"},{\"sha\":\"a7e467c6f9acd7b1be0603afdec0b8cfdcf3cf49\",\"message\":\"Count words, lines and bytes\"},{\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\",\"message\":\"Start tally\"}]}},{\"createdAt\":\"2026-10-09T10:00:00.000Z\",\"actor\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:3ygt44h3csutc5c7xfpkp45l\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#created\",\"description\":\"Counts lines, words and bytes, with output that lines up\"}}]}"
"body": "{\"events\":[{\"createdAt\":\"2026-10-10T01:25:00.000Z\",\"actor\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#changed\",\"subject\":\"at://did:plc:jxhkyipyrdvki5qrhox3ipdg/dev.knotless.issue/3myggylju6b6y\",\"title\":\"Typo in the README ✏️\",\"state\":\"closed\"}},{\"createdAt\":\"2026-10-10T00:48:00.000Z\",\"actor\":{\"did\":\"did:plc:jxhkyipyrdvki5qrhox3ipdg\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#opened\",\"subject\":\"at://did:plc:jxhkyipyrdvki5qrhox3ipdg/dev.knotless.issue/3myggylju6b6y\",\"title\":\"Typo in the README ✏️\"}},{\"createdAt\":\"2026-10-10T00:11:00.000Z\",\"actor\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#commented\",\"subject\":\"at://did:plc:jxhkyipyrdvki5qrhox3ipdg/dev.knotless.issue/3myggyli5er6w\",\"title\":\"Fails on files that are not UTF-8\",\"text\":\"Confirmed. Reading bytes and counting words lossily would fix it.\"}},{\"createdAt\":\"2026-10-09T23:34:00.000Z\",\"actor\":{\"did\":\"did:plc:jxhkyipyrdvki5qrhox3ipdg\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#opened\",\"subject\":\"at://did:plc:jxhkyipyrdvki5qrhox3ipdg/dev.knotless.issue/3myggyli5er6w\",\"title\":\"Fails on files that are not UTF-8\"}},{\"createdAt\":\"2026-10-09T22:57:00.000Z\",\"actor\":{\"did\":\"did:plc:jxhkyipyrdvki5qrhox3ipdg\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#opened\",\"subject\":\"at://did:plc:jxhkyipyrdvki5qrhox3ipdg/dev.knotless.pull/3myggylhg5l6v\",\"title\":\"Print counts as JSON with --json\"}},{\"createdAt\":\"2026-10-09T22:20:00.000Z\",\"actor\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#changed\",\"subject\":\"at://did:plc:jxhkyipyrdvki5qrhox3ipdg/dev.knotless.pull/3myggykid2nrg\",\"title\":\"Count no lines in empty input\",\"state\":\"merged\"}},{\"createdAt\":\"2026-10-09T21:06:00.000Z\",\"actor\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#pushed\",\"ref\":\"refs/heads/main\",\"created\":false,\"count\":1,\"commits\":[{\"sha\":\"686a36205b82620af07dd99997c570f04f461df9\",\"message\":\"Count no lines in empty input\"}]}},{\"createdAt\":\"2026-10-09T20:29:00.000Z\",\"actor\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#commented\",\"subject\":\"at://did:plc:jxhkyipyrdvki5qrhox3ipdg/dev.knotless.pull/3myggykid2nrg\",\"title\":\"Count no lines in empty input\",\"text\":\"Fair enough. Thanks!\",\"verdict\":\"approve\"}},{\"createdAt\":\"2026-10-09T19:52:00.000Z\",\"actor\":{\"did\":\"did:plc:jxhkyipyrdvki5qrhox3ipdg\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#commented\",\"subject\":\"at://did:plc:jxhkyipyrdvki5qrhox3ipdg/dev.knotless.pull/3myggykid2nrg\",\"title\":\"Count no lines in empty input\",\"text\":\"It only runs per line, so the cost is one comparison each. Happy to change it if you prefer.\"}},{\"createdAt\":\"2026-10-09T19:15:00.000Z\",\"actor\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#commented\",\"subject\":\"at://did:plc:jxhkyipyrdvki5qrhox3ipdg/dev.knotless.pull/3myggykid2nrg\",\"title\":\"Count no lines in empty input\",\"text\":\"Could `lines` check for emptiness once, rather than in the filter?\",\"verdict\":\"changes\"}},{\"createdAt\":\"2026-10-09T18:38:00.000Z\",\"actor\":{\"did\":\"did:plc:jxhkyipyrdvki5qrhox3ipdg\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#opened\",\"subject\":\"at://did:plc:jxhkyipyrdvki5qrhox3ipdg/dev.knotless.pull/3myggykid2nrg\",\"title\":\"Count no lines in empty input\"}},{\"createdAt\":\"2026-10-09T17:24:00.000Z\",\"actor\":{\"did\":\"did:plc:jxhkyipyrdvki5qrhox3ipdg\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:jxhkyipyrdvki5qrhox3ipdg\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#pushed\",\"ref\":\"refs/heads/json\",\"created\":true,\"count\":1,\"commits\":[{\"sha\":\"2063dabcda7417d5831f3b6bfd8ffb18da82fb8e\",\"message\":\"Print counts as JSON with --json\"}]}},{\"createdAt\":\"2026-10-09T16:47:00.000Z\",\"actor\":{\"did\":\"did:plc:jxhkyipyrdvki5qrhox3ipdg\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:jxhkyipyrdvki5qrhox3ipdg\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#pushed\",\"ref\":\"refs/heads/empty-input\",\"created\":true,\"count\":1,\"commits\":[{\"sha\":\"686a36205b82620af07dd99997c570f04f461df9\",\"message\":\"Count no lines in empty input\"}]}},{\"createdAt\":\"2026-10-09T14:19:00.000Z\",\"actor\":{\"did\":\"did:plc:jxhkyipyrdvki5qrhox3ipdg\",\"handle\":\"bob.test\"},\"repo\":{\"did\":\"did:plc:jxhkyipyrdvki5qrhox3ipdg\",\"handle\":\"bob.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#created\",\"description\":\"Counts lines, words and bytes, with output that lines up\",\"forkOf\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"tally\"}}},{\"createdAt\":\"2026-10-09T13:42:00.000Z\",\"actor\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"scratch\"},\"action\":{\"$type\":\"dev.knotless.getFeed#created\"}},{\"createdAt\":\"2026-10-09T12:28:00.000Z\",\"actor\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"notes\"},\"action\":{\"$type\":\"dev.knotless.getFeed#pushed\",\"ref\":\"refs/heads/main\",\"created\":true,\"count\":1,\"commits\":[{\"sha\":\"e78543abf0f799b7eb5713ccdf8d88b84d0f8ec0\",\"message\":\"Begin\"}]}},{\"createdAt\":\"2026-10-09T11:51:00.000Z\",\"actor\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"notes\"},\"action\":{\"$type\":\"dev.knotless.getFeed#created\",\"description\":\"Scratch notes 📝\"}},{\"createdAt\":\"2026-10-09T10:37:00.000Z\",\"actor\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#pushed\",\"ref\":\"refs/heads/main\",\"created\":true,\"count\":6,\"commits\":[{\"sha\":\"97727c2c19d5d8b3849b2ed9ad47130cee9798f5\",\"message\":\"Split parsing into its own module\"},{\"sha\":\"4f6b9a1168c6534f64c836b4c3a11e23e74637ad\",\"message\":\"Document usage and output\"},{\"sha\":\"d1f55d39d12c4d6ba6ab25b1e9c76d15fb2cbaca\",\"message\":\"Add a logo\"},{\"sha\":\"7818a5bc1bef9184ad313cf79175da3d87d4b9cf\",\"message\":\"Read from stdin when no file is given\\n\\nSo tally works at the end of a pipe, as wc does. The name shown\\nfor stdin is \\\"-\\\".\"},{\"sha\":\"a7e467c6f9acd7b1be0603afdec0b8cfdcf3cf49\",\"message\":\"Count words, lines and bytes\"},{\"sha\":\"cac02c464843db4d55fecfa69d327ef7b12702c0\",\"message\":\"Start tally\"}]}},{\"createdAt\":\"2026-10-09T10:00:00.000Z\",\"actor\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\"},\"repo\":{\"did\":\"did:plc:rshwqpmddeusc43mph4ykwxa\",\"handle\":\"alice.test\",\"name\":\"tally\"},\"action\":{\"$type\":\"dev.knotless.getFeed#created\",\"description\":\"Counts lines, words and bytes, with output that lines up\"}}]}"
}
}
}
harness/shots/playwright.config.mjs+2−1
@@ -9,6 +9,7 @@
// Baselines are per platform: fonts render differently on each.
import { defineConfig, devices } from '@playwright/test'
import { base } from './world.mjs'
const desktop = { viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 }
const phone = {
@@ -30,7 +31,7 @@ export default defineConfig({
toHaveScreenshot: { maxDiffPixels: 0, threshold: 0, animations: 'disabled', caret: 'hide', scale: 'device' },
toMatchSnapshot: { maxDiffPixels: 0, threshold: 0 },
},
use: { baseURL: 'http://127.0.0.1:2593', locale: 'en-US', timezoneId: 'UTC' },
use: { baseURL: base, locale: 'en-US', timezoneId: 'UTC' },
projects: [
{ name: 'desktop-light', use: { ...desktop, colorScheme: 'light' } },
{ name: 'desktop-dark', use: { ...desktop, colorScheme: 'dark' } },
harness/shots/world.mjs+3−1
@@ -20,7 +20,9 @@ const here = dirname(fileURLToPath(import.meta.url))
export const root = resolve(here, '../..')
const fixture = join(here, 'fixture')
const bin = join(root, 'target/debug')
export const port = 2593
// Where the world answers, and the pages behind it on the next two ports:
// SHOTS_PORT moves them, for a second checkout's shots beside a first's.
export const port = Number(process.env.SHOTS_PORT ?? 2593)
export const base = `http://127.0.0.1:${port}`
export const saved = join(root, 'target/shots/world.json')
lexicons/dev/knotless/emails.jsonnew+24−0
@@ -0,0 +1,24 @@
{
"lexicon": 1,
"id": "dev.knotless.emails",
"description": "The email addresses an account's commits are authored as, so pages can show those commits as the account's.",
"defs": {
"main": {
"type": "record",
"description": "Keyed self. Readers believe it only on repositories the account owns or is a collaborator on. An account's first push claims the address git commits as there; after that it changes only when the account says.",
"key": "literal:self",
"record": {
"type": "object",
"required": ["hashes", "updatedAt"],
"properties": {
"hashes": {
"type": "array",
"items": { "type": "string", "minLength": 64, "maxLength": 64 },
"description": "Each the lowercase hex SHA-256 of the account's DID, a newline, and the address trimmed and lowercased: salted with the DID, so the record names no address and one address hashes apart in each account that claims it."
},
"updatedAt": { "type": "string", "format": "datetime" }
}
}
}
}
}
web/src/app.css+1−0
@@ -25,6 +25,7 @@ code,pre,.mono{font-family:var(--mono);font-size:12.5px}
svg.i{width:16px;height:16px;flex:none;fill:currentColor;vertical-align:-3px}
.av{display:inline-grid;place-items:center;flex:none;width:20px;height:20px;border-radius:50%;font:600 10px var(--sans);color:#fff;background:hsl(var(--h) 45% 52%);text-transform:uppercase}
img.av{object-fit:cover;background:var(--soft)}
.avs{display:inline-flex;flex:none}.avs .av+.av{margin-left:-6px;box-shadow:0 0 0 2px var(--bg)}
.av.lg{width:32px;height:32px;font-size:13px}
.av.xl{width:88px;height:88px;font-size:34px;font-weight:600}
.profile{display:flex;align-items:center;gap:1.5rem;margin:.75rem 0 2.25rem;min-width:0}
web/src/lib/api/Author.tsnew+12−0
@@ -0,0 +1,12 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Who wrote a commit or helped to: by the name git gives, and by account,
* with its picture, when that is one of the repository's own people whose
* address it is (`knotless::emails`).
*/
export type Author = { name: string,
/**
* Its handle.
*/
account: string | null, avatar: string | null, };
web/src/lib/api/Commit.ts+6−1
@@ -1,6 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Author } from "./Author";
/**
* A commit, as the pages show one.
*/
export type Commit = { sha: string, parents: Array<string>, author: string, date: string, message: string, };
export type Commit = { sha: string, parents: Array<string>, author: Author,
/**
* Who its message's `Co-authored-by` trailers name.
*/
coauthors: Array<Author>, date: string, message: string, };
web/src/lib/molecules/CommitAuthor.sveltenew+18−0
@@ -0,0 +1,18 @@
<script lang="ts">
import type { Author } from '#lib/api/Author.ts';
import type { Commit } from '#lib/api/Commit.ts';
import { urlPath } from '#lib/format.ts';
import Avatar from '#lib/atoms/Avatar.svelte';
/**
* Who wrote a commit, and who its trailers say helped: each by account,
* linked to its profile, when that is one of the repository's own
* people; else by the name git gives.
*/
let { commit: c }: { commit: Commit } = $props();
const all = $derived([c.author, ...c.coauthors]);
const joint = (i: number) => (i === 0 ? '' : i === all.length - 1 ? ' and ' : ', ');
</script>
{#snippet who(a: Author)}{#if a.account}<a href="/{urlPath(a.account)}"><b>{a.name}</b></a>{:else}<b>{a.name}</b>{/if}{/snippet}
{#if c.coauthors.length}<span class="avs">{#each all as a, i (i)}<Avatar name={a.name} src={a.avatar} />{/each}</span><span>{#each all as a, i (i)}{joint(i)}{@render who(a)}{/each}</span>{:else}<Avatar name={c.author.name} src={c.author.avatar} />{@render who(c.author)}{/if}
web/src/lib/organisms/FileList.svelte+2−3
@@ -2,7 +2,7 @@
import type { Commit } from '#lib/api/Commit.ts';
import type { Entry } from '#lib/api/Entry.ts';
import { date, repoHref, size, subject, urlPath } from '#lib/format.ts';
import Avatar from '#lib/atoms/Avatar.svelte';
import CommitAuthor from '#lib/molecules/CommitAuthor.svelte';
import Icon from '#lib/atoms/Icon.svelte';
import Sha from '#lib/atoms/Sha.svelte';
/** A directory: its latest commit, then its entries. */
@@ -17,8 +17,7 @@
<div class="box">
{#if last}
<div class="boxhead">
<Avatar name={last.author} />
<b>{last.author}</b>
<CommitAuthor commit={last} />
<a class="grow clip mute" href={href(`/commit/${last.sha}`)}>{subject(last.message)}</a>
<Sha sha={last.sha} href={href(`/commit/${last.sha}`)} />
<span class="mute hide-sm">{date(last.date)}</span>
web/src/routes/[authority]/[repo]/commit/[rev]/+page.svelte+2−3
@@ -1,6 +1,6 @@
<script lang="ts">
import { date, plural, repoHref, subject } from '#lib/format.ts';
import Avatar from '#lib/atoms/Avatar.svelte';
import CommitAuthor from '#lib/molecules/CommitAuthor.svelte';
import Sha from '#lib/atoms/Sha.svelte';
import DiffFile from '#lib/organisms/DiffFile.svelte';
import RepoPage from '#lib/templates/RepoPage.svelte';
@@ -22,8 +22,7 @@
{#if rest}<pre>{rest}</pre>{/if}
</div>
<div class="boxhead" style="border-top:1px solid var(--line);border-bottom:0">
<Avatar name={c.author} />
<b>{c.author}</b>
<CommitAuthor commit={c} />
<span class="mute">committed on {date(c.date)}</span>
<span class="end mute">{#each c.parents as p, i (p)}{i ? ' parent ' : 'parent '}<Sha sha={p} href={repoHref(r.authority, r.name, `/commit/${p}`)} />{/each}{c.parents.length ? ' commit ' : 'commit '}<Sha sha={c.sha} /></span>
</div>
web/src/routes/[authority]/[repo]/commits/[...rest]/+page.svelte+2−2
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Commit } from '#lib/api/Commit.ts';
import { date, repoHref, subject } from '#lib/format.ts';
import Avatar from '#lib/atoms/Avatar.svelte';
import CommitAuthor from '#lib/molecules/CommitAuthor.svelte';
import Icon from '#lib/atoms/Icon.svelte';
import Sha from '#lib/atoms/Sha.svelte';
import Toolbar from '#lib/organisms/Toolbar.svelte';
@@ -33,7 +33,7 @@
<li>
<div class="grow">
<a class="title clip" href={href(c)}>{subject(c.message)}</a>
<div class="meta"><Avatar name={c.author} /><b>{c.author}</b> committed</div>
<div class="meta"><CommitAuthor commit={c} /> committed</div>
</div>
<Sha sha={c.sha} href={href(c)} />
</li>