chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds 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>
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> | ||