chadtmiller.com/knotless

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

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

web: a feed for the front page, and a quieter look

The front page is now what happened on the repositories the index
knows: repositories made and forked, pushes with the commits they
brought, branches and tags made and deleted, and pulls and issues
opened, discussed, merged, closed and reopened, a day at a time.
Searching moves to its own results page, and getting started to /start.

The index keeps each push from its claim, so the feed outlives
compaction. A fork's copy of its source's refs is not shown as pushes,
and a fork's new branch lists only what its source lacked just before.

The pages take a neutral palette with GitHub's code and diff colours,
round avatars, monospace only for code-like text, and no icon on
profile repository lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chad Miller committed on Oct 21, 2026 parent 4de1da8 commit 698c4c0
Showing 176 changed files with 1103 additions and 164 deletions
crates/knotless-serve/src/index.rs+198−2
@@ -33,13 +33,33 @@ pub struct Listed {
pub created_at: String,
}
/// A ref moving, as a claim recorded it.
#[derive(Debug, Clone, PartialEq)]
pub struct Push {
/// The repository's record.
pub repo: String,
pub did: String,
/// The repository's name now.
pub name: String,
/// The repository it was forked from, if it is a fork.
pub fork_of: Option<String>,
/// The full ref name, such as `refs/heads/main`.
pub refname: String,
/// Where it was, when it was there before.
pub from: Option<String>,
/// Where it is now, unless it was deleted.
pub to: Option<String>,
pub created_at: String,
}
/// The shape of the tables. An index of another shape is dropped and
/// built again: it is a cache, and `--backfill` refills it.
const VERSION: i64 = 5;
const VERSION: i64 = 6;
/// Drops the tables that are a cache. Takedowns, the labels given out for
/// them and reports are not, and stay.
const CACHE: &str = "DROP TABLE IF EXISTS repo; DROP TABLE IF EXISTS forge;
const CACHE: &str =
"DROP TABLE IF EXISTS repo; DROP TABLE IF EXISTS forge; DROP TABLE IF EXISTS push;
DROP TABLE IF EXISTS moved; DROP TABLE IF EXISTS block; DROP TABLE IF EXISTS label;
DROP TABLE IF EXISTS labeler;";
@@ -67,6 +87,10 @@ impl Index {
uri TEXT PRIMARY KEY, author TEXT NOT NULL, repo_did TEXT NOT NULL,
repo_key TEXT NOT NULL, value TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS forge_repo ON forge (repo_did, repo_key);
CREATE TABLE IF NOT EXISTS push (
uri TEXT PRIMARY KEY, did TEXT NOT NULL, key TEXT NOT NULL, ref TEXT NOT NULL,
before TEXT, after TEXT, created_at TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS push_created ON push (created_at);
CREATE TABLE IF NOT EXISTS moved (
uri TEXT PRIMARY KEY, from_did TEXT NOT NULL, from_key TEXT NOT NULL,
to_did TEXT NOT NULL, to_key TEXT NOT NULL);
@@ -140,6 +164,21 @@ impl Index {
text
],
)
} else if collection == nsid::CLAIM {
// A ref moving, kept after compaction drops its claim: the
// claims are the reflog, and this is the history of it.
db.execute(
"INSERT OR REPLACE INTO push VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
uri,
did,
value["repo"].as_str().unwrap_or_default(),
value["name"].as_str().unwrap_or_default(),
value["from"].as_str(),
value["to"].as_str(),
value["createdAt"].as_str().unwrap_or_default()
],
)
} else if let Some(repo) = named("repo") {
db.execute(
"INSERT OR REPLACE INTO forge VALUES (?1, ?2, ?3, ?4, ?5)",
@@ -409,6 +448,52 @@ impl Index {
Ok((rows.into_iter().map(|(r, _)| r).collect(), next))
}
/// Pulls, issues, comments and changes of state on any repository,
/// newest first, with the owner and key of the repository each is on.
pub fn recent(&self, limit: usize) -> rusqlite::Result<Vec<(Entry, String, String)>> {
let db = self.db.lock().unwrap();
let mut stmt = db.prepare_cached(
"SELECT uri, author, value, repo_did, repo_key FROM forge
WHERE uri NOT LIKE '%/dev.knotless.gate/%' AND uri NOT LIKE '%/dev.knotless.block/%'
AND uri NOT IN down AND author NOT IN down
AND coalesce(json_extract(value, '$.subject'), '') NOT IN down
ORDER BY json_extract(value, '$.createdAt') DESC LIMIT ?1",
)?;
stmt.query_map([limit as i64], recent)?.collect()
}
/// Refs that moved on repositories that stand, newest first.
pub fn pushes(&self, limit: usize) -> rusqlite::Result<Vec<Push>> {
let db = self.db.lock().unwrap();
let mut stmt = db.prepare_cached(
"SELECT r.uri, p.did, r.name, r.fork_of, p.ref, p.before, p.after, p.created_at FROM push p
JOIN repo r ON r.did = p.did AND r.key = p.key
WHERE p.did NOT IN down AND r.uri NOT IN down
ORDER BY p.created_at DESC LIMIT ?1",
)?;
stmt.query_map([limit as i64], push)?.collect()
}
/// Where the repository at `repo` had `refname` just before `at`, as
/// far as the index saw.
pub fn tip_before(
&self,
repo: &str,
refname: &str,
at: &str,
) -> rusqlite::Result<Option<String>> {
let db = self.db.lock().unwrap();
let mut stmt = db.prepare_cached(
"SELECT p.after FROM push p JOIN repo r ON r.did = p.did AND r.key = p.key
WHERE r.uri = ?1 AND p.ref = ?2 AND p.created_at < ?3
ORDER BY p.created_at DESC LIMIT 1",
)?;
let tip = stmt
.query_row([repo, refname, at], |row| row.get(0))
.optional()?;
Ok(tip.flatten())
}
/// Forks of `did`'s repository `key`.
pub fn forks(&self, did: &str, key: &str) -> rusqlite::Result<Vec<Listed>> {
let db = self.db.lock().unwrap();
@@ -445,6 +530,23 @@ fn entry(row: &rusqlite::Row) -> rusqlite::Result<Entry> {
})
}
fn push(row: &rusqlite::Row) -> rusqlite::Result<Push> {
Ok(Push {
repo: row.get(0)?,
did: row.get(1)?,
name: row.get(2)?,
fork_of: row.get(3)?,
refname: row.get(4)?,
from: row.get(5)?,
to: row.get(6)?,
created_at: row.get(7)?,
})
}
fn recent(row: &rusqlite::Row) -> rusqlite::Result<(Entry, String, String)> {
Ok((entry(row)?, row.get(3)?, row.get(4)?))
}
/// A listed repository, and its URI to page from.
fn paged(row: &rusqlite::Row) -> rusqlite::Result<(Listed, String)> {
Ok((listed(row)?, row.get(5)?))
@@ -494,6 +596,8 @@ mod tests {
assert!(index.forks("did:a", "k").is_err());
assert!(index.forge("did:a", "k").is_err());
assert!(index.name("did:a", "k").is_err());
assert!(index.recent(1).is_err());
assert!(index.pushes(1).is_err());
}
#[test]
@@ -641,6 +745,98 @@ mod tests {
assert_eq!(index.labeler_seq("did:web:l").unwrap(), None);
}
#[test]
fn what_happened() {
let dir = tempfile::tempdir().unwrap();
let index = Index::open(&dir.path().join("i.db")).unwrap();
let repo = json!({"name": "r", "key": "k", "createdAt": "2026-01-01T00:00:00Z"});
index.put("did:a", nsid::REPO, "r", &repo).unwrap();
let claim = |rkey: &str, from: Option<&str>, to: Option<&str>, at: &str| {
let mut c =
json!({"repo": "k", "name": "refs/heads/main", "generation": 0, "createdAt": at});
if let Some(f) = from {
c["from"] = json!(f);
}
if let Some(t) = to {
c["to"] = json!(t);
}
index.put("did:a", nsid::CLAIM, rkey, &c).unwrap();
};
claim("k.m.0", None, Some("a1"), "2026-01-02T00:00:00Z");
claim("k.m.1", Some("a1"), Some("b2"), "2026-01-03T00:00:00Z");
// Of a repository the index does not know.
index
.put(
"did:a",
nsid::CLAIM,
"x.m.0",
&json!({"repo": "x", "name": "refs/heads/main", "createdAt": "2026-01-04T00:00:00Z"}),
)
.unwrap();
let pushes = index.pushes(10).unwrap();
assert_eq!(
pushes,
[
Push {
repo: "at://did:a/dev.knotless.repo/r".into(),
did: "did:a".into(),
name: "r".into(),
fork_of: None,
refname: "refs/heads/main".into(),
from: Some("a1".into()),
to: Some("b2".into()),
created_at: "2026-01-03T00:00:00Z".into(),
},
Push {
repo: "at://did:a/dev.knotless.repo/r".into(),
did: "did:a".into(),
name: "r".into(),
fork_of: None,
refname: "refs/heads/main".into(),
from: None,
to: Some("a1".into()),
created_at: "2026-01-02T00:00:00Z".into(),
},
]
);
let uri = "at://did:a/dev.knotless.repo/r";
let main = "refs/heads/main";
let tip = |at| index.tip_before(uri, main, at).unwrap();
assert_eq!(tip("2026-01-03T00:00:00Z"), Some("a1".into()));
assert_eq!(tip("2026-01-02T00:00:00Z"), None);
claim("k.m.2", Some("b2"), None, "2026-01-03T12:00:00Z");
assert_eq!(tip("2026-01-04T00:00:00Z"), None);
index.delete("did:a", nsid::CLAIM, "k.m.2").unwrap();
// Compaction drops claims; the history stays.
index.delete("did:a", nsid::CLAIM, "k.m.0").unwrap();
assert_eq!(index.pushes(10).unwrap().len(), 3);
assert_eq!(index.pushes(1).unwrap().len(), 1);
// Pulls, issues and what was said, newest first, without gates.
let named = json!({"uri": "at://did:a/dev.knotless.repo/r", "key": "k"});
for (collection, rkey, at) in [
(nsid::ISSUE, "1", "2026-01-05"),
(nsid::COMMENT, "2", "2026-01-07"),
(nsid::GATE, "3", "2026-01-08"),
(nsid::PULL, "4", "2026-01-06"),
] {
let value = json!({"repo": named, "createdAt": at});
index.put("did:b", collection, rkey, &value).unwrap();
}
let recent = index.recent(10).unwrap();
let kinds: Vec<&str> = recent
.iter()
.map(|(e, _, _)| e.uri.rsplit('/').nth(1).unwrap())
.collect();
assert_eq!(kinds, [nsid::COMMENT, nsid::PULL, nsid::ISSUE]);
assert_eq!((recent[0].1.as_str(), recent[0].2.as_str()), ("did:a", "k"));
assert_eq!(index.recent(1).unwrap().len(), 1);
// Gone with the repository.
index.delete("did:a", nsid::REPO, "r").unwrap();
assert!(index.pushes(10).unwrap().is_empty());
}
#[test]
fn an_index_of_another_version_is_rebuilt() {
let dir = tempfile::tempdir().unwrap();
crates/knotless-serve/src/lib.rs+6−2
@@ -327,8 +327,12 @@ pub async fn backfill(ctx: &Ctx, index: &index::Index, service: &str) -> Result<
for did in dids {
let url = RepoUrl::parse(&format!("knotless://{did}/_"))?;
let remote = Remote::connect(ctx, &url).await?;
let collections =
std::iter::once(knotless::records::nsid::REPO).chain(knotless::records::nsid::FORGE);
let collections = [
knotless::records::nsid::REPO,
knotless::records::nsid::CLAIM,
]
.into_iter()
.chain(knotless::records::nsid::FORGE);
for collection in collections {
for r in remote.list_all::<serde_json::Value>(collection).await? {
index.put(&did, collection, &r.rkey, &r.value)?;
crates/knotless-serve/src/objects.rs+50−14
@@ -159,26 +159,52 @@ impl Objects {
skipped += 1;
continue;
}
let author = commit.author()?;
let date = author
.time()?
.format(gix::date::time::format::ISO8601_STRICT)?;
out.push(Commit {
sha: commit.id.to_string(),
parents: commit.parent_ids().map(|p| p.to_string()).collect(),
author: Author {
name: author.name.to_string(),
email: author.email.to_string(),
date,
},
message: commit.message_raw()?.to_string().trim_end().to_string(),
});
out.push(summary(&commit)?);
if out.len() == limit {
break;
}
}
Ok(out)
}
/// What moving a ref to `to` brought: the commits it reaches that none
/// of `known` does, newest first, at most `limit` of them. A revision
/// in `known` that is not here is passed over.
pub fn brought(&self, to: &str, known: &[&str], limit: usize) -> Result<Vec<Commit>> {
let tip = self.commit(to)?.id;
let hidden: Vec<gix::ObjectId> = known
.iter()
.filter_map(|k| self.commit(k).ok().map(|c| c.id))
.collect();
let walk = self
.0
.rev_walk([tip])
.with_hidden(hidden)
.sorting(Sorting::ByCommitTime(CommitTimeOrder::NewestFirst))
.all()?;
let mut out = Vec::new();
for info in walk.take(limit) {
out.push(summary(&info?.object()?)?);
}
Ok(out)
}
}
fn summary(commit: &gix::Commit<'_>) -> Result<Commit> {
let author = commit.author()?;
let date = author
.time()?
.format(gix::date::time::format::ISO8601_STRICT)?;
Ok(Commit {
sha: commit.id.to_string(),
parents: commit.parent_ids().map(|p| p.to_string()).collect(),
author: Author {
name: author.name.to_string(),
email: author.email.to_string(),
date,
},
message: commit.message_raw()?.to_string().trim_end().to_string(),
})
}
#[cfg(test)]
@@ -286,6 +312,16 @@ mod tests {
assert!(o.log("nope", "", 0, 10).is_err());
assert!(open(&d.join("missing")).is_err());
// What a push of main brought, past side and a ref it never saw.
assert_eq!(
subjects(o.brought("main", &["side", "gone"], 10).unwrap()),
["vendor", "Merge branch 'side'", "two"]
);
assert_eq!(o.brought("main", &[], 10).unwrap().len(), 5);
assert_eq!(o.brought("main", &[], 2).unwrap().len(), 2);
assert!(o.brought("main", &["main"], 10).unwrap().is_empty());
assert!(o.brought("nope", &[], 10).is_err());
run(vec!["checkout", "-q", "--detach"]);
assert_eq!(open(&d.join(".git")).unwrap().head(), None);
}
crates/knotless-serve/src/web.rs+81−64
@@ -29,9 +29,12 @@ use std::collections::HashMap;
use std::fmt::Write;
use std::sync::{Arc, LazyLock, Mutex};
mod feed;
pub fn routes() -> Router<Arc<App>> {
Router::new()
.route("/", get(home))
.route("/start", get(start))
.route("/{authority}", get(profile))
.route("/{authority}/{repo}", get(code))
.route("/{authority}/{repo}/tree/{*rest}", get(tree))
@@ -83,8 +86,8 @@ const COMMITS_PER_PAGE: usize = 50;
const DIFF_ROWS: usize = 2000;
const CSS: &str = r#"
:root{color-scheme:light;--bg:#fff;--fg:#1c1d20;--mute:#6a6d75;--faint:#9a9ca3;--line:#e6e6e9;--soft:#f6f6f7;--panel:#fbfbfc;--accent:#2f6af5;--accent-soft:#eef3ff;--open:#1d8348;--merged:#7a48d6;--closed:#c2410c;--ins:#e9f8ee;--ins-n:#d3f2dd;--ins-x:#b2e8c4;--del:#fdeeee;--del-n:#fbdcdc;--del-x:#f6bcbc;--hunk:#f0f5ff;--target:#fff4c2;--comment:#8a8f98;--string:#0b7a4b;--keyword:#c0285e;--number:#a85d00;--function:#2b59c3;--type:#7a4fc2;--tag:#c0285e;--attr:#a85d00;--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;--sans:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;--r:10px}
@media (prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#0d0e10;--fg:#e8e8ea;--mute:#999ba3;--faint:#6b6e76;--line:#26282d;--soft:#17181b;--panel:#121316;--accent:#7aa2ff;--accent-soft:#18213a;--open:#4cc38a;--merged:#b197fc;--closed:#f0884f;--ins:#0f2418;--ins-n:#163421;--ins-x:#1f5232;--del:#2a1214;--del-n:#3b191c;--del-x:#6a2a2f;--hunk:#121b2e;--target:#3b3313;--comment:#6c717a;--string:#7dd3a8;--keyword:#ff8fb3;--number:#efb36a;--function:#8db3ff;--type:#c4a5ff;--tag:#ff8fb3;--attr:#efb36a}}
:root{color-scheme:light;--bg:#fff;--fg:#171717;--mute:#636363;--faint:#8f8f8f;--line:#e8e8e8;--soft:#f4f4f4;--panel:#fafafa;--accent:#2f5fb3;--accent-soft:#eaeff7;--open:#1a7f37;--merged:#7048b5;--closed:#b35a14;--add:#1a7f37;--rem:#cf222e;--ins:#e6ffec;--ins-n:#ccffd8;--ins-x:#abf2bc;--del:#ffebe9;--del-n:#ffd7d5;--del-x:#ffb8b0;--hunk:#ddf4ff;--target:#fff8c5;--dir:#54aeff;--comment:#59636e;--string:#0a3069;--keyword:#cf222e;--number:#0550ae;--function:#6639ba;--type:#953800;--tag:#116329;--attr:#0550ae;--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;--sans:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;--r:4px}
@media (prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#111;--fg:#ededed;--mute:#a1a1a1;--faint:#707070;--line:#262626;--soft:#1a1a1a;--panel:#161616;--accent:#7aa7f0;--accent-soft:#222935;--open:#3fb950;--merged:#b39af0;--closed:#e8a05c;--add:#3fb950;--rem:#f85149;--ins:rgba(46,160,67,.15);--ins-n:rgba(63,185,80,.3);--ins-x:rgba(46,160,67,.4);--del:rgba(248,81,73,.1);--del-n:rgba(248,81,73,.3);--del-x:rgba(248,81,73,.4);--hunk:rgba(56,139,253,.1);--target:#3b3313;--dir:#9198a1;--comment:#9198a1;--string:#a5d6ff;--keyword:#ff7b72;--number:#79c0ff;--function:#d2a8ff;--type:#ffa657;--tag:#7ee787;--attr:#79c0ff}}
@view-transition{navigation:auto}
*{box-sizing:border-box}
html{-webkit-text-size-adjust:100%}
@@ -95,7 +98,6 @@ a:hover{color:var(--accent)}
.top{border-bottom:1px solid var(--line);view-transition-name:top}
.top .wrap{display:flex;align-items:center;gap:1.5rem;height:3.5rem}
.brand{display:flex;align-items:center;gap:.55rem;font-weight:650;font-size:15px;letter-spacing:-.01em}
.brand svg{width:22px;height:22px}
.top nav{margin-left:auto;display:flex;gap:1.25rem;color:var(--mute)}
main.wrap{flex:1;padding-top:1.75rem;padding-bottom:4rem}
.foot{color:var(--faint);font-size:12.5px;padding-top:1.25rem;padding-bottom:2rem;border-top:1px solid var(--line)}
@@ -136,24 +138,11 @@ svg.i{width:16px;height:16px;flex:none;fill:currentColor;vertical-align:-3px}
.sha{font:12px var(--mono);color:var(--mute);padding:.15rem .45rem;border:1px solid var(--line);border-radius:6px}
.sha:hover{color:var(--accent)}
.empty{padding:2.5rem 1rem;text-align:center;color:var(--mute)}
.hero{padding:2.5rem 0 2.25rem;max-width:44rem}
.hero h1{font-size:clamp(28px,5vw,40px);line-height:1.12;letter-spacing:-.03em;font-weight:650}
.hero p{font-size:16px;color:var(--mute);margin-top:.9rem;max-width:36rem}
.steps{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1rem;margin-bottom:3rem}
.step{border:1px solid var(--line);border-radius:var(--r);padding:1.1rem 1.1rem 1rem;background:var(--panel);min-width:0}
.step h3{display:flex;align-items:center;gap:.55rem}
.step h3 b{display:inline-grid;place-items:center;width:20px;height:20px;border-radius:50%;background:var(--fg);color:var(--bg);font-size:11px}
.step p{color:var(--mute);margin:.4rem 0 .8rem}
.step details{margin-top:.6rem;color:var(--mute);font-size:12.5px}
.step summary{cursor:pointer}
.step details pre{margin-top:.5rem}
.cmd{display:flex;align-items:flex-start;gap:.5rem;border:1px solid var(--line);border-radius:8px;background:var(--bg);padding:.5rem .5rem .5rem .75rem}
.cmd+.cmd{margin-top:.5rem}
.cmd pre{flex:1;min-width:0;margin:0;padding:.2rem 0;white-space:pre-wrap;overflow-wrap:anywhere}
.cmd .btn{height:24px;padding:0 .5rem;font-size:11.5px}
.section{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem;margin:0 0 .9rem}
.search{flex:1;min-width:12rem;max-width:22rem;margin-left:auto;font:inherit;color:inherit;background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:.4rem .75rem;height:32px}
.search:focus{outline:2px solid var(--accent);outline-offset:-1px}
.repohead{display:flex;flex-wrap:wrap;align-items:flex-start;gap:1rem 2rem}
.repohead h1{font-weight:500;display:flex;flex-wrap:wrap;align-items:center;gap:.35rem}
.repohead h1 b{font-weight:650}
@@ -200,9 +189,8 @@ svg.i{width:16px;height:16px;flex:none;fill:currentColor;vertical-align:-3px}
.commit-msg pre{white-space:pre-wrap;color:var(--mute);margin:.6rem 0 0;font-size:12.5px}
.summary{margin:1.5rem 0 .9rem;color:var(--mute)}
.summary b{color:var(--fg)}
.add{color:var(--open)}.rem{color:#cf222e}
@media (prefers-color-scheme:dark){.rem{color:#f47067}}
.bars{display:inline-flex;gap:1px;vertical-align:-1px}.bars i{width:7px;height:7px;border-radius:1px;background:var(--line)}.bars i.a{background:var(--open)}.bars i.d{background:#cf222e}
.add{color:var(--add)}.rem{color:var(--rem)}
.bars{display:inline-flex;gap:1px;vertical-align:-1px}.bars i{width:7px;height:7px;border-radius:1px;background:var(--line)}.bars i.a{background:var(--add)}.bars i.d{background:var(--rem)}
.file{border:1px solid var(--line);border-radius:var(--r);margin:0 0 1rem;overflow:hidden}
.file>summary{display:flex;flex-wrap:wrap;align-items:center;gap:.4rem .75rem;padding:.55rem 1rem;background:var(--panel);cursor:pointer;list-style:none;position:sticky;top:0}
.file>summary::-webkit-details-marker{display:none}
@@ -248,18 +236,14 @@ svg.i{width:16px;height:16px;flex:none;fill:currentColor;vertical-align:-3px}
.error{padding:4rem 0;text-align:center}
.error h1{font-size:44px;letter-spacing:-.04em}
.error p{color:var(--mute);margin-top:.5rem}
@media (max-width:860px){.steps{grid-template-columns:minmax(0,1fr)}}
@media (max-width:640px){
.av.xl{width:64px;height:64px;font-size:26px}
.profile{gap:1rem;align-items:flex-start}
.wrap{padding:0 1rem}
main.wrap{padding-top:1.25rem}
.hero{padding:1.25rem 0 1.75rem}
.hero p{font-size:15px}
.top nav{gap:1rem}
.tabs a{padding:.55rem .5rem;gap:.35rem}
.clone{width:100%}
.search{max-width:none;min-width:100%}
.prose{padding:1rem 1.1rem 1.25rem}
.list li{align-items:flex-start}
.list .sha,.list .hide-sm{display:none}
@@ -268,6 +252,46 @@ main.wrap{padding-top:1.25rem}
.diff .n{min-width:2.25rem;padding:0 .35rem}
.item-title{font-size:20px}
}
body{border-top:3px solid var(--fg)}
.wrap{max-width:58rem}
.top{border-bottom:0}
.top .wrap{height:3.25rem}
.brand{font:600 14px var(--mono)}
.top nav{font:13px var(--mono)}
.foot{font-family:var(--mono);font-size:11.5px;border-top:1px dashed var(--line)}
.repohead h1,.profile h1{font-size:20px;font-weight:600;letter-spacing:-.01em}
.tabs{gap:1.25rem;font-family:var(--mono);font-size:13px}
.tabs svg{display:none}
.tabs a{padding:.45rem 0;text-transform:lowercase}
.boxhead{background:transparent}
.list li:hover{background:var(--soft)}
.files .dir svg.i{color:var(--dir)}
.intro{max-width:44rem;padding:1.25rem 0 1rem;font-size:15px;line-height:1.6;color:var(--mute)}
.intro b{color:var(--fg);font-weight:600}
.intro a{color:var(--accent);text-decoration:underline;text-underline-offset:3px}
.find input{font:13px var(--mono);color:inherit;background:transparent;border:0;border-bottom:1px solid var(--line);padding:.4rem 0;width:min(22rem,100%)}
.find input:focus{outline:0;border-color:var(--accent)}
.feed-day{font-size:13px;font-weight:600;color:var(--mute);margin:2rem 0 .5rem;padding-bottom:.35rem;border-bottom:1px solid var(--line);letter-spacing:0}
.feed{list-style:none;margin:0;padding:0}
.feed>li{display:flex;gap:1rem;padding:.45rem 0;align-items:baseline}
.feed time{font:12px var(--mono);color:var(--faint);flex:none;width:2.75rem}
.feed p{overflow-wrap:anywhere}
.feed .who,.feed .repo{font-weight:600}
.feed a:not(.who):not(.repo):not(.hash){text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}
.feed .ref{background:transparent;color:var(--mute);padding:0}
.feed-note{color:var(--mute);font-size:13px;margin-top:.1rem}
.commits{list-style:none;margin:.25rem 0 .1rem;padding:0 0 0 .9rem;border-left:1px solid var(--line)}
.commits li{display:flex;align-items:baseline;gap:.7rem;font-size:13px;color:var(--mute);min-width:0}
.commits .hash{font:12px var(--mono);color:var(--accent);flex:none}
.commits .rest{display:block;font:12px var(--mono)}
.start{max-width:40rem;margin-top:1rem}
.start h1{font-size:18px;font-weight:600;margin:0 0 .4rem}
.start .lede{color:var(--mute);margin:0 0 1.5rem}
.start>p:not(.lede){font:12.5px var(--mono);color:var(--faint);margin:.9rem 0 .35rem}
.start .cmd{background:var(--panel)}
.results h1{font-size:18px;font-weight:600;margin:1rem 0}
.results .find{margin-bottom:1.25rem}
@media (max-width:640px){.feed>li{flex-direction:column;gap:.1rem}.feed time{width:auto}}
::highlight(comment){color:var(--comment)}::highlight(string){color:var(--string)}::highlight(keyword){color:var(--keyword)}::highlight(number){color:var(--number)}::highlight(function){color:var(--function)}::highlight(type){color:var(--type)}::highlight(tag){color:var(--tag)}::highlight(attr){color:var(--attr)}
::highlight(xins){background-color:var(--ins-x)}::highlight(xdel){background-color:var(--del-x)}::highlight(target){background-color:var(--target)}
"#;
@@ -289,9 +313,6 @@ static POLICY: LazyLock<String> = LazyLock::new(|| {
)
});
/// The mark: two commits on one straight thread.
const LOGO: &str = r#"<svg viewBox="0 0 24 24" aria-hidden="true"><rect width="24" height="24" rx="6.5" fill="currentColor"/><path d="M7 12h10" stroke="var(--bg)" stroke-width="2" stroke-linecap="round"/><circle cx="7" cy="12" r="2.6" fill="var(--bg)"/><circle cx="17" cy="12" r="2.6" fill="var(--bg)"/></svg>"#;
/// Icons, each a 16-unit path.
mod icon {
pub const CODE: &str = "M4.7 3.3a.75.75 0 0 1 0 1.06L1.06 8l3.64 3.64a.75.75 0 1 1-1.06 1.06L-.53 8.53a.75.75 0 0 1 0-1.06L3.64 3.3a.75.75 0 0 1 1.06 0Zm6.6 0a.75.75 0 0 1 1.06 0l4.17 4.17a.75.75 0 0 1 0 1.06l-4.17 4.17a.75.75 0 1 1-1.06-1.06L14.94 8 11.3 4.36a.75.75 0 0 1 0-1.06Z";
@@ -304,7 +325,6 @@ mod icon {
pub const FILE: &str = "M2 1.75C2 .78 2.78 0 3.75 0h6.59c.46 0 .9.18 1.23.51l2.92 2.92c.33.33.51.77.51 1.23v9.59A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .14.11.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.06V4.25c0 .14.11.25.25.25h2.69L10.5 1.56Z";
pub const BRANCH: &str = "M9.5 3.25a2.25 2.25 0 1 1 3 2.12V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.13a2.25 2.25 0 1 1-1.5 0V5.37a2.25 2.25 0 1 1 1.5 0v1.84A2.49 2.49 0 0 1 6 7h4a1 1 0 0 0 1-1v-.63a2.25 2.25 0 0 1-1.5-2.12ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM3.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z";
pub const CHAT: &str = "M1 2.75C1 1.78 1.78 1 2.75 1h10.5c.97 0 1.75.78 1.75 1.75v7.5A1.75 1.75 0 0 1 13.25 12H9.06l-2.57 2.57A1.46 1.46 0 0 1 4 13.54V12H2.75A1.75 1.75 0 0 1 1 10.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .14.11.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.75.75 0 0 1 .53-.22h4.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z";
pub const REPO: &str = "M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.71 1.7.75.75 0 1 1-1.08 1.05A2.5 2.5 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.71A2.49 2.49 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.09a.25.25 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z";
pub const FORK: &str = "M5 5.37v.88c0 .41.34.75.75.75h4.5a.75.75 0 0 0 .75-.75v-.88a2.25 2.25 0 1 1 1.5 0v.88A2.25 2.25 0 0 1 10.25 8.5h-1.5v2.13a2.25 2.25 0 1 1-1.5 0V8.5h-1.5A2.25 2.25 0 0 1 3.5 6.25v-.88a2.25 2.25 0 1 1 1.5 0ZM5 3.25a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Zm6.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm-3 8.75a.75.75 0 1 0-1.5 0 .75.75 0 0 0 1.5 0Z";
}
@@ -412,7 +432,7 @@ fn command(text: &str) -> String {
fn layout(title: &str, body: &str) -> String {
format!(
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>{}</title><style>{CSS}</style><script type="speculationrules">{SPECULATION}</script></head><body><header class="top"><div class="wrap"><a class="brand" href="/">{LOGO}<span>knotless</span></a><nav><a href="/#repositories">Repositories</a><a href="/#connect">Connect</a></nav></div></header><main class="wrap">{body}</main><footer class="wrap foot">Git repositories stored in atproto accounts. This server only reads them.</footer><script>{SCRIPT}</script></body></html>"#,
r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="light dark"><title>{}</title><style>{CSS}</style><script type="speculationrules">{SPECULATION}</script></head><body><header class="top"><div class="wrap"><a class="brand" href="/">~ knotless</a><nav><a href="/">feed</a><a href="/?q=">repos</a><a href="/start">getting started</a></nav></div></header><main class="wrap">{body}</main><footer class="wrap foot">every repository here is records on its owner’s PDS. this server only reads them.</footer><script>{SCRIPT}</script></body></html>"#,
esc(title)
)
}
@@ -748,47 +768,45 @@ pub struct HomeQuery {
q: Option<String>,
}
/// What knotless is, how to start, and the repositories the index knows.
async fn home(
State(app): State<Arc<App>>,
headers: HeaderMap,
Query(query): Query<HomeQuery>,
) -> Result<Response> {
/// What happened on the repositories the index knows;
/// or, asked for `q`, the repositories whose name or description holds it.
async fn home(State(app): State<Arc<App>>, Query(query): Query<HomeQuery>) -> Result<Response> {
let body = match (&app.index, query.q) {
(Some(index), Some(q)) => found(&app, index, q.trim()).await?,
(index, _) => feed::home(&app, index.as_deref()).await?,
};
Ok(page(StatusCode::OK, "no-cache", "knotless", &body))
}
/// How to start, with clone commands for this server.
async fn start(headers: HeaderMap) -> Response {
let host = headers
.get(header::HOST)
.and_then(|h| h.to_str().ok())
.unwrap_or("localhost");
let mcp = r#"{ "mcpServers": { "knotless": { "command": "knotless-mcp" } } }"#;
page(
StatusCode::OK,
"no-cache",
"getting started · knotless",
&feed::start(host),
)
}
/// The repositories whose name or description holds `q`, newest first.
async fn found(app: &App, index: &crate::index::Index, q: &str) -> Result<String> {
let heading = match q {
"" => "repositories".to_string(),
q => format!("repositories matching “{}”", esc(q)),
};
let mut body = format!(
r#"<section class="hero"><h1>Git that lives in your atproto account.</h1><p>Each repository is a few records on your own PDS: no git server to run, nothing to install on the PDS. Clone and push with plain git, and let agents read, commit, review and merge over MCP.</p></section>
<section class="steps" id="connect">
<div class="step"><h3><b>1</b>Install and log in</h3><p>From a checkout of knotless. Use an app password.</p>{}{}</div>
<div class="step"><h3><b>2</b>Push</h3><p>Any repository, to any account on any PDS.</p>{}{}</div>
<div class="step"><h3><b>3</b>Connect your agent</h3><p>The MCP server acts as the account you logged in as.</p>{}<details><summary>Other MCP clients</summary><p>Add this to the client's MCP settings, such as Claude Desktop's or Cursor's:</p>{}</details></div>
</section>"#,
command("cargo install --path crates/knotless\ncargo install --path crates/knotless-mcp"),
command("knotless login you.bsky.social"),
command("git push knotless://you.bsky.social/project main"),
command(&format!("git clone http://{host}/you.bsky.social/project")),
command("claude mcp add knotless -- knotless-mcp"),
command(mcp),
r#"<section class="results"><h1>{heading}</h1><form action="/" role="search" class="find"><input type="search" name="q" value="{}" placeholder="find a repository" aria-label="Find a repository"></form><div class="box"><ul class="list">"#,
esc(q)
);
let Some(index) = &app.index else {
return Ok(page(StatusCode::OK, "no-cache", "knotless", &body));
};
let q = query.q.unwrap_or_default();
write!(
body,
r#"<section id="repositories"><div class="section"><h2>Repositories</h2><form action="/" role="search" class="search-form"><input class="search" type="search" name="q" value="{}" placeholder="Find a repository" aria-label="Find a repository"></form></div>"#,
esc(&q)
)
.unwrap();
let repos = index
.repos(&q, 50)
.repos(q, 50)
.map_err(anyhow::Error::from)
.map_err(AppError::internal)?;
let owners = futures::future::join_all(repos.iter().map(|r| handle(&app, &r.did))).await;
body.push_str(r#"<div class="box"><ul class="list">"#);
let owners = futures::future::join_all(repos.iter().map(|r| handle(app, &r.did))).await;
for (r, owner) in repos.iter().zip(owners) {
write!(
body,
@@ -806,8 +824,8 @@ async fn home(
if repos.is_empty() {
body.push_str(r#"<li class="empty">No repositories found.</li>"#);
}
body.push_str("</ul></div></section>");
Ok(page(StatusCode::OK, "no-cache", "knotless", &body))
body.push_str(r#"</ul></div></section>"#);
Ok(body)
}
/// An account: who it is, as its Bluesky profile says if it has one, and
@@ -868,8 +886,7 @@ async fn profile(State(app): State<Arc<App>>, Path(authority): Path<String>) ->
});
write!(
body,
r#"<li>{}<div class="grow"><a class="title" href="{href}">{}</a><div class="meta">{}</div>{}</div><span class="mute hide-sm">{}</span></li>"#,
svg(icon::REPO),
r#"<li><div class="grow"><a class="title" href="{href}">{}</a><div class="meta">{}</div>{}</div><span class="mute hide-sm">{}</span></li>"#,
esc(&r.value.name),
esc(r.value.description.as_deref().unwrap_or("No description")),
from.unwrap_or_default(),
crates/knotless-serve/src/web/feed.rsnew+570−0
@@ -0,0 +1,570 @@
//! The front page: what happened on the repositories the index knows,
//! newest first and a day at a time, and how to start.
use super::*;
use crate::index::Index;
use knotless::records::nsid;
use std::collections::HashSet;
/// How far back the feed reaches, in each kind of thing that happens.
const REACH: usize = 40;
/// Commits listed under a push; the rest are counted.
const LISTED: usize = 5;
/// Commits a push is walked for: past this, it says only that there were
/// more.
const WALKED: usize = 100;
/// Something `who` did to `repo`, shown as `handle/name`, and its page.
struct Event {
at: String,
who: String,
repo: (String, String),
what: What,
}
enum What {
/// A repository made, or forked from another.
Created {
description: Option<String>,
from: Option<(String, String)>,
},
/// A ref made or moved, and the commits that brought.
Pushed {
refname: String,
new: bool,
commits: Vec<read::Commit>,
},
Deleted {
refname: String,
},
Opened {
kind: &'static str,
title: String,
href: String,
},
Said {
kind: &'static str,
title: String,
href: String,
body: String,
verdict: Option<String>,
},
Changed {
kind: &'static str,
title: String,
href: String,
state: String,
},
}
const SEARCH: &str = r#"<form action="/" role="search" class="find"><input type="search" name="q" placeholder="find a repository" aria-label="Find a repository"></form>"#;
/// The page: what knotless is, and what happened.
pub(super) async fn home(app: &App, index: Option<&Index>) -> Result<String> {
let mut body = String::from(
r##"<section class="intro"><p><b>knotless</b> keeps git repositories as records in your atproto account. push with plain git, clone from any PDS, and let agents commit over MCP. <a href="/start">getting started →</a></p></section>"##,
);
if let Some(index) = index {
body.push_str(SEARCH);
let events = events(app, index).await?;
for (day, events) in by_day(&events) {
write!(
body,
r#"<h2 class="feed-day">{}</h2><ul class="feed">"#,
weekday(day)
)
.unwrap();
for e in events {
write!(
body,
r#"<li><time datetime="{}">{}</time><div class="grow"><p><a class="who" href="/{}">{}</a> {}</p>{}</div></li>"#,
esc(&e.at),
esc(e.at.get(11..16).unwrap_or_default()),
url_path(&e.who),
esc(&e.who),
sentence(e),
detail(e)
)
.unwrap();
}
body.push_str("</ul>");
}
body.push_str(r#"<p class="feed-note">pushes, pulls and issues show up here as they happen, from any account on any PDS.</p>"#);
}
Ok(body)
}
/// How to start: install, log in, push, clone and connect an agent.
pub(super) fn start(host: &str) -> String {
let mut body = String::from(
r#"<section class="start"><h1>getting started</h1><p class="lede">knotless works with any atmosphere account. log in with an app password.</p>"#,
);
let mcp = r#"{ "mcpServers": { "knotless": { "command": "knotless-mcp" } } }"#;
for (say, run) in [
(
"install, from a checkout of knotless",
"cargo install --path crates/knotless\ncargo install --path crates/knotless-mcp",
),
(
"log in with your handle and an app password",
"knotless login you.bsky.social",
),
(
"push any repository, to any account on any PDS",
"git push knotless://you.bsky.social/project main",
),
(
"or clone one without knotless installed",
&format!("git clone http://{host}/you.bsky.social/project"),
),
(
"connect an agent: it acts as the account you logged in as",
"claude mcp add knotless -- knotless-mcp",
),
("other MCP clients take this in their settings", mcp),
] {
write!(body, "<p># {say}</p>{}", command(run)).unwrap();
}
body.push_str("</section>");
body
}
/// `handle/name`, and its page.
fn named(owner: &str, name: &str) -> (String, String) {
(
format!("{owner}/{name}"),
format!("/{}/{}", url_path(owner), url_path(name)),
)
}
/// The owner and name of the repository `uri` names.
fn owner_and_name(uri: &str) -> Option<(String, String)> {
let (did, _, name) = forge::parse_uri(uri).ok()?;
Some((did.to_string(), name.to_string()))
}
/// `pull` or `issue`, for the record at `uri`.
fn kind(uri: &str) -> &'static str {
if uri.contains(&format!("/{}/", nsid::PULL)) {
"pull"
} else {
"issue"
}
}
/// What happened, newest first: repositories made, refs moved, and what
/// was opened, said and decided on them.
async fn events(app: &App, index: &Index) -> Result<Vec<Event>> {
let repos = index
.repos("", REACH)
.map_err(anyhow::Error::from)
.map_err(AppError::internal)?;
let pushes = index
.pushes(REACH)
.map_err(anyhow::Error::from)
.map_err(AppError::internal)?;
let said = index
.recent(REACH)
.map_err(anyhow::Error::from)
.map_err(AppError::internal)?;
// Each account named, resolved together.
let parents: Vec<(String, String)> = repos
.iter()
.filter_map(|r| r.fork_of.as_deref())
.filter_map(owner_and_name)
.collect();
let mut dids: HashSet<&str> = parents.iter().map(|(did, _)| did.as_str()).collect();
dids.extend(repos.iter().map(|r| r.did.as_str()));
dids.extend(pushes.iter().map(|p| p.did.as_str()));
dids.extend(
said.iter()
.flat_map(|(e, did, _)| [e.author.as_str(), did.as_str()]),
);
let handles: HashMap<&str, String> = futures::future::join_all(
dids.into_iter()
.map(|did| async move { (did, handle(app, did).await) }),
)
.await
.into_iter()
.collect();
let h = |did: &str| handles[did].clone();
let mut events: Vec<Event> = repos
.iter()
.map(|r| Event {
at: r.created_at.clone(),
who: h(&r.did),
repo: named(&h(&r.did), &r.name),
what: What::Created {
description: r.description.clone(),
from: r
.fork_of
.as_deref()
.and_then(owner_and_name)
.map(|(did, name)| named(&h(&did), &name)),
},
})
.collect();
// The mirror of each repository pushed to, opened once.
let wanted: HashSet<(String, String)> = pushes
.iter()
.map(|p| (p.did.clone(), p.name.clone()))
.collect();
let mirrors: HashMap<(String, String), Repo> =
futures::future::join_all(wanted.into_iter().map(|at| async move {
let repo = Repo::open(app, &at.0, &at.1).await;
(at, repo)
}))
.await
.into_iter()
.filter_map(|(at, repo)| Some((at, repo.ok()?)))
.collect();
for p in &pushes {
let repo = mirrors.get(&(p.did.clone(), p.name.clone()));
let refname = p
.refname
.strip_prefix("refs/heads/")
.or_else(|| p.refname.strip_prefix("refs/tags/"))
.unwrap_or(&p.refname)
.to_string();
let what = match &p.to {
None => What::Deleted { refname },
Some(to) => {
// A ref that moved brought what it did not reach before; a
// new one, what the default branch did not reach just
// before, nor the default branch of a fork's source, which
// a fork's is named for.
let head = repo.map(|r| r.head.as_str()).unwrap_or_default();
let branch = format!("refs/heads/{head}");
let before = |at: &str| index.tip_before(at, &branch, &p.created_at);
let known: Vec<String> = match &p.from {
Some(from) => vec![from.clone()],
None => [
(head != refname).then_some(p.repo.as_str()),
p.fork_of.as_deref(),
]
.into_iter()
.flatten()
.map(before)
.collect::<rusqlite::Result<Vec<_>>>()
.map_err(anyhow::Error::from)
.map_err(AppError::internal)?
.into_iter()
.flatten()
.collect(),
};
let known: Vec<&str> = known.iter().map(String::as_str).collect();
let commits = repo
.and_then(|r| r.objects().ok())
.and_then(|o| o.brought(to, &known, WALKED).ok())
.unwrap_or_default();
// A fork's copy of its source's refs is the fork itself.
if p.fork_of.is_some() && p.from.is_none() && commits.is_empty() {
continue;
}
What::Pushed {
refname,
new: p.from.is_none(),
commits,
}
}
};
let who = h(&p.did);
events.push(Event {
at: p.created_at.clone(),
repo: named(&who, &p.name),
who,
what,
});
}
// Pulls and issues, as their repositories' owners let them stand, by
// title and page.
let mut titles: HashMap<String, (String, String)> = HashMap::new();
let on: HashSet<(&str, &str)> = said
.iter()
.map(|(_, did, key)| (did.as_str(), key.as_str()))
.collect();
let mut pages = HashMap::new();
for (did, key) in on {
let name = index
.name(did, key)
.map_err(anyhow::Error::from)
.map_err(AppError::internal)?
.unwrap_or_default();
let page = named(&h(did), &name);
let entries = index
.forge(did, key)
.map_err(anyhow::Error::from)
.map_err(AppError::internal)?;
for e in entries {
let (author, collection, rkey) = forge::parse_uri(&e.uri).unwrap_or_default();
if !matches!(collection, nsid::PULL | nsid::ISSUE) {
continue;
}
let href = format!("{}/{}/{author}/{rkey}", page.1, kind(&e.uri));
let title = e.value["title"].as_str().unwrap_or_default().to_string();
titles.insert(e.uri, (title, href));
}
pages.insert((did, key), page);
}
for (e, did, key) in &said {
let page = &pages[&(did.as_str(), key.as_str())];
let subject = e.value["subject"].as_str().unwrap_or_default();
let shown = titles.contains_key(&e.uri) || titles.contains_key(subject);
let text = |field: &str| e.value[field].as_str().unwrap_or_default().to_string();
let (title, href) = titles
.get(&e.uri)
.or_else(|| titles.get(subject))
.cloned()
.unwrap_or_default();
let what = match forge::parse_uri(&e.uri).unwrap_or_default().1 {
nsid::PULL | nsid::ISSUE => What::Opened {
kind: kind(&e.uri),
title,
href,
},
nsid::COMMENT => What::Said {
kind: kind(subject),
title,
href,
body: text("body"),
verdict: e.value["verdict"].as_str().map(str::to_string),
},
_ => What::Changed {
kind: kind(subject),
title,
href,
state: text("state"),
},
};
let event = Event {
at: text("createdAt"),
who: h(&e.author),
repo: page.clone(),
what,
};
events.extend(shown.then_some(event));
}
events.sort_by(|a, b| b.at.cmp(&a.at));
events.truncate(REACH);
Ok(events)
}
/// Events by the day they happened on, as `2026-10-09`, in order.
fn by_day(events: &[Event]) -> Vec<(&str, Vec<&Event>)> {
let mut days: Vec<(&str, Vec<&Event>)> = Vec::new();
for e in events {
let on = e.at.get(..10).unwrap_or_default();
match days.last_mut() {
Some((day, list)) if *day == on => list.push(e),
_ => days.push((on, vec![e])),
}
}
days
}
/// What the event says `who` did, as HTML.
fn sentence(e: &Event) -> String {
let repo = format!(
r#"<a class="repo" href="{}">{}</a>"#,
e.repo.1,
esc(&e.repo.0)
);
let item = |title: &str, href: &str| format!(r#"<a href="{href}">{}</a>"#, esc(title));
let branch = |name: &str| format!(r#"<span class="ref">{}</span>"#, esc(name));
match &e.what {
What::Created {
from: Some((from, href)),
..
} => format!(
r#"forked <a class="repo" href="{href}">{}</a> to {repo}"#,
esc(from)
),
What::Created { from: None, .. } => format!("created {repo}"),
What::Pushed {
refname, new: true, ..
} => format!("created {} on {repo}", branch(refname)),
What::Pushed {
refname, commits, ..
} => format!(
"pushed {} to {} on {repo}",
count(commits.len()),
branch(refname)
),
What::Deleted { refname } => format!("deleted {} on {repo}", branch(refname)),
What::Opened { kind, title, href } => {
format!("opened {kind} {} on {repo}", item(title, href))
}
What::Said {
kind,
title,
href,
verdict,
..
} => format!(
"{} {kind} {} on {repo}",
said(verdict.as_deref()),
item(title, href)
),
What::Changed {
kind,
title,
href,
state,
} => format!("{} {kind} {} on {repo}", changed(state), item(title, href)),
}
}
/// How many commits a push brought, in words.
fn count(n: usize) -> String {
match n {
0 => "nothing new".into(),
1 => "1 commit".into(),
WALKED => format!("{WALKED}+ commits"),
n => format!("{n} commits"),
}
}
/// What a comment with `verdict` did.
fn said(verdict: Option<&str>) -> &'static str {
match verdict {
Some("approve") => "approved",
Some("changes") => "asked for changes on",
_ => "commented on",
}
}
/// What moving to `state` did.
fn changed(state: &str) -> &str {
match state {
"open" => "reopened",
s => s,
}
}
/// What the event shows under its sentence: a repository's description,
/// the commits a push brought, or the start of what was said.
fn detail(e: &Event) -> String {
match &e.what {
What::Created {
description: Some(d),
..
} => format!(r#"<p class="feed-note">{}</p>"#, esc(d)),
What::Pushed { commits, .. } => {
let lines: String = commits
.iter()
.take(LISTED)
.map(|c| {
format!(
r#"<li><a class="hash" href="{}/commit/{}">{}</a><span class="clip">{}</span></li>"#,
e.repo.1,
c.sha,
&c.sha[..7],
esc(subject(&c.message))
)
})
.collect();
let rest = if commits.len() > LISTED {
format!(
r#"<li class="rest">and {} more</li>"#,
commits.len() - LISTED
)
} else {
String::new()
};
if commits.is_empty() {
String::new()
} else {
format!(r#"<ul class="commits">{lines}{rest}</ul>"#)
}
}
What::Said { body, .. } => {
format!(r#"<p class="feed-note">“{}”</p>"#, esc(&excerpt(body, 120)))
}
_ => String::new(),
}
}
/// `2026-10-09` as `friday, october 9`.
fn weekday(iso: &str) -> String {
const MONTHS: [&str; 12] = [
"january",
"february",
"march",
"april",
"may",
"june",
"july",
"august",
"september",
"october",
"november",
"december",
];
// From 1970-01-01, a Thursday.
const DAYS: [&str; 7] = [
"thursday",
"friday",
"saturday",
"sunday",
"monday",
"tuesday",
"wednesday",
];
let num = |at: std::ops::Range<usize>| iso.get(at).and_then(|s| s.parse::<i64>().ok());
let (Some(y), Some(m @ 1..=12), Some(d)) = (num(0..4), num(5..7), num(8..10)) else {
return iso.to_string();
};
// Days since the epoch, as civil_from_days in reverse.
let (y, mp) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + (153 * mp + 2) / 5 + d - 1;
let days = era * 146_097 + doe - 719_468;
format!(
"{}, {} {d}",
DAYS[days.rem_euclid(7) as usize],
MONTHS[(m - 1) as usize]
)
}
/// The first paragraph of Markdown, as plain text, cut short.
fn excerpt(text: &str, max: usize) -> String {
let first = text.split("\n\n").next().unwrap_or_default();
let plain: String = first
.chars()
.map(|c| if c == '\n' { ' ' } else { c })
.filter(|c| !matches!(c, '`' | '*'))
.collect();
match plain.char_indices().nth(max) {
Some((at, _)) => format!("{}…", plain[..at].trim_end()),
None => plain,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn days_and_words() {
assert_eq!(weekday("2026-10-09T10:00:00Z"), "friday, october 9");
assert_eq!(weekday("1970-01-01"), "thursday, january 1");
assert_eq!(weekday("2024-02-29"), "thursday, february 29");
assert_eq!(weekday("1969-12-31"), "wednesday, december 31");
assert_eq!(weekday("2026-13-01"), "2026-13-01");
assert_eq!(weekday("soon"), "soon");
assert_eq!(count(0), "nothing new");
assert_eq!(count(1), "1 commit");
assert_eq!(count(7), "7 commits");
assert_eq!(count(WALKED), "100+ commits");
assert_eq!(excerpt("one\ntwo `x`\n\nthree", 50), "one two x");
assert_eq!(excerpt("abcdef ghij", 7), "abcdef…");
assert_eq!(kind("at://did:a/dev.knotless.pull/1"), "pull");
assert_eq!(kind("at://did:a/dev.knotless.issue/1"), "issue");
assert_eq!(owner_and_name("nope"), None);
}
}
crates/knotless-serve/tests/web.rs+111−5
@@ -120,8 +120,8 @@ fn browsing_a_repository() {
let h = &s.account.handle;
let repo = format!("/{h}/browsed");
// The front page finds it once the index has seen it.
wait_until(|| site.page("/").contains("<b>browsed</b></a>"));
// The search finds it once the index has seen it.
wait_until(|| site.page("/?q=").contains("<b>browsed</b></a>"));
assert!(site.page("/?q=BROWS").contains("<b>browsed</b></a>"));
assert!(site.page("/?q=zzzz").contains("No repositories found."));
@@ -487,10 +487,116 @@ fn pulls_and_issues() {
// Without an index, there is no knowing.
let bare = serve(false);
let front = bare.page("/");
assert!(front.contains("Git that lives in your atproto account."));
assert!(front.contains("claude mcp add knotless -- knotless-mcp"));
assert!(!front.contains("Find a repository"));
assert!(front.contains("keeps git repositories as records"));
assert!(front.contains(r#"href="/start">getting started"#));
let start = bare.page("/start");
assert!(start.contains("claude mcp add knotless -- knotless-mcp"));
assert!(start.contains(&format!("git clone {}/you.bsky.social/project", bare.base)));
assert!(!front.contains("Find a repository") && !front.contains(r#"class="feed""#));
let (status, body) = bare.status(&format!("{repo}/pulls"));
assert_eq!(status, 404);
assert!(body.contains("keeps no index"));
}
#[test]
fn the_feed() {
let site = serve(true);
let owner = person(account(), &site);
let other = person(account(), &site);
let (o, t) = (&owner.account.handle, &other.account.handle);
let repo = format!(r#"<a class="repo" href="/{o}/fed">{o}/fed</a>"#);
let fork_repo = format!(r#"<a class="repo" href="/{t}/fed">{t}/fed</a>"#);
// What the feed shows once it shows `last`: other tests' pushes and
// pulls share it, so each part is looked for while it is recent.
let shows = |last: &str, wants: &[String]| {
wait_until(|| site.page("/").contains(last));
let feed = site.page("/");
for want in wants {
assert!(feed.contains(want), "{want} not in\n{feed}");
}
feed
};
let url = owner.url("fed");
ok(
&owner,
&["create", &url, "--description", "Fed <to> the feed"],
);
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
// A push of several commits lists the first few and counts the rest;
// one back to where it was brings nothing.
for i in 0..6 {
owner.commit_file(&work, "f", &format!("{i}\n"));
}
owner.git(&work, &["push", "-q", &url, "main"]);
owner.git(&work, &["push", "-q", "-f", &url, "HEAD~2:main"]);
owner.git(&work, &["checkout", "-q", "-b", "side"]);
owner.commit_file(&work, "s", "s\n");
owner.git(&work, &["push", "-q", &url, "side"]);
owner.git(&work, &["tag", "v1"]);
owner.git(&work, &["push", "-q", &url, "v1"]);
owner.git(&work, &["push", "-q", &url, ":side"]);
shows(
&format!(r#"deleted <span class="ref">side</span> on {repo}"#),
&[
format!("created {repo}"),
"<p class=\"feed-note\">Fed &lt;to&gt; the feed</p>".into(),
format!(r#"pushed 6 commits to <span class="ref">main</span> on {repo}"#),
r#"<li class="rest">and 1 more</li>"#.into(),
format!(r#"pushed nothing new to <span class="ref">main</span> on {repo}"#),
format!(r#"created <span class="ref">side</span> on {repo}"#),
format!(r#"created <span class="ref">v1</span> on {repo}"#),
r#"<h2 class="feed-day">"#.into(),
],
);
let fork = ok(&other, &["fork", &url]);
let clone = other.path("clone");
other.git(other.dir.path(), &["clone", "-q", &fork, "clone"]);
other.commit_file(&clone, "b", "b\n");
other.git(&clone, &["push", "-q", "origin", "main:topic"]);
let feed = shows(
&format!(r#"created <span class="ref">topic</span> on {fork_repo}"#),
&[
format!("forked {repo} to {fork_repo}"),
format!(r#"<a class="who" href="/{t}">{t}</a>"#),
],
);
// A fork's copy of its source's branches is the fork itself.
assert!(!feed.contains(&format!(r#"<span class="ref">main</span> on {fork_repo}"#)));
let pull = ok(&other, &["pull", &url, "main", &fork, "topic", "Add b"]);
let issue = ok(&other, &["issue", &url, "Broken <thing>"]);
ok(&owner, &["comment", &url, &pull, "Not *yet*", "--changes"]);
ok(&other, &["comment", &url, &pull, "Fixed", "--approve"]);
ok(&owner, &["comment", &url, &issue, "Seen it"]);
ok(&owner, &["close", &url, &issue]);
ok(&owner, &["reopen", &url, &issue]);
let reopened = format!(r#"reopened issue <a href="/{o}/fed/issue/"#);
wait_until(|| site.page("/").contains(&reopened));
ok(&owner, &["merge", &url, &pull]);
let feed = shows(
&format!(r#"merged pull <a href="/{o}/fed/pull/"#),
&[
format!(r#"opened pull <a href="/{o}/fed/pull/"#),
format!(r#"asked for changes on pull <a href="/{o}/fed/pull/"#),
"<p class=\"feed-note\">“Not yet”</p>".into(),
format!(r#"approved pull <a href="/{o}/fed/pull/"#),
format!(r#"opened issue <a href="/{o}/fed/issue/"#),
format!(r#"commented on issue <a href="/{o}/fed/issue/"#),
format!(r#"closed issue <a href="/{o}/fed/issue/"#),
reopened.clone(),
],
);
// Opened, said and decided about, by title.
assert_eq!(feed.matches(&format!(">Add b</a> on {repo}")).count(), 4);
assert_eq!(
feed.matches(&format!(">Broken &lt;thing&gt;</a> on {repo}"))
.count(),
4
);
let found = site.page("/?q=FED");
assert!(found.contains("repositories matching “FED”"), "{found}");
assert!(found.contains("<b>fed</b></a>"), "{found}");
}
harness/shots/baseline/darwin/commit-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commit-desktop-dark.sha256+1−1
@@ -1 +1 @@
395dfe6b7d9b6dd3529765c95ecd6b390911cc2465155df44224590719577a33
\ No newline at end of file
6f3126dcf59f505b8bd736abcd5bd72102816586a51d1d2a41e83bce76eeefc2
\ No newline at end of file
harness/shots/baseline/darwin/commit-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commit-desktop-light.sha256+1−1
@@ -1 +1 @@
395dfe6b7d9b6dd3529765c95ecd6b390911cc2465155df44224590719577a33
\ No newline at end of file
6f3126dcf59f505b8bd736abcd5bd72102816586a51d1d2a41e83bce76eeefc2
\ No newline at end of file
harness/shots/baseline/darwin/commit-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commit-phone-dark.sha256+1−1
@@ -1 +1 @@
395dfe6b7d9b6dd3529765c95ecd6b390911cc2465155df44224590719577a33
\ No newline at end of file
6f3126dcf59f505b8bd736abcd5bd72102816586a51d1d2a41e83bce76eeefc2
\ No newline at end of file
harness/shots/baseline/darwin/commit-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commit-phone-light.sha256+1−1
@@ -1 +1 @@
395dfe6b7d9b6dd3529765c95ecd6b390911cc2465155df44224590719577a33
\ No newline at end of file
6f3126dcf59f505b8bd736abcd5bd72102816586a51d1d2a41e83bce76eeefc2
\ No newline at end of file
harness/shots/baseline/darwin/commits-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commits-desktop-dark.sha256+1−1
@@ -1 +1 @@
df500af42b1f11143328c2fd195ebb95ed00e9e333b4c5d501ba9a2baa24ba2b
\ No newline at end of file
4f8ee51c661659caecdaa855762b2cc07e49fab33ca0f03f9d3aa0bf7c611376
\ No newline at end of file
harness/shots/baseline/darwin/commits-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commits-desktop-light.sha256+1−1
@@ -1 +1 @@
df500af42b1f11143328c2fd195ebb95ed00e9e333b4c5d501ba9a2baa24ba2b
\ No newline at end of file
4f8ee51c661659caecdaa855762b2cc07e49fab33ca0f03f9d3aa0bf7c611376
\ No newline at end of file
harness/shots/baseline/darwin/commits-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commits-phone-dark.sha256+1−1
@@ -1 +1 @@
df500af42b1f11143328c2fd195ebb95ed00e9e333b4c5d501ba9a2baa24ba2b
\ No newline at end of file
4f8ee51c661659caecdaa855762b2cc07e49fab33ca0f03f9d3aa0bf7c611376
\ No newline at end of file
harness/shots/baseline/darwin/commits-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/commits-phone-light.sha256+1−1
@@ -1 +1 @@
df500af42b1f11143328c2fd195ebb95ed00e9e333b4c5d501ba9a2baa24ba2b
\ No newline at end of file
4f8ee51c661659caecdaa855762b2cc07e49fab33ca0f03f9d3aa0bf7c611376
\ No newline at end of file
harness/shots/baseline/darwin/directory-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/directory-desktop-dark.sha256+1−1
@@ -1 +1 @@
245bc4f87cc8dc053418d4d0ac1eea6f61e43323927f9c083d6de57a0f28f7d6
\ No newline at end of file
c893bb1d3630150a435fbcd9170dcfa636e37f6ef4147614bfceb5adec13c377
\ No newline at end of file
harness/shots/baseline/darwin/directory-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/directory-desktop-light.sha256+1−1
@@ -1 +1 @@
245bc4f87cc8dc053418d4d0ac1eea6f61e43323927f9c083d6de57a0f28f7d6
\ No newline at end of file
c893bb1d3630150a435fbcd9170dcfa636e37f6ef4147614bfceb5adec13c377
\ No newline at end of file
harness/shots/baseline/darwin/directory-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/directory-phone-dark.sha256+1−1
@@ -1 +1 @@
245bc4f87cc8dc053418d4d0ac1eea6f61e43323927f9c083d6de57a0f28f7d6
\ No newline at end of file
c893bb1d3630150a435fbcd9170dcfa636e37f6ef4147614bfceb5adec13c377
\ No newline at end of file
harness/shots/baseline/darwin/directory-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/directory-phone-light.sha256+1−1
@@ -1 +1 @@
245bc4f87cc8dc053418d4d0ac1eea6f61e43323927f9c083d6de57a0f28f7d6
\ No newline at end of file
c893bb1d3630150a435fbcd9170dcfa636e37f6ef4147614bfceb5adec13c377
\ No newline at end of file
harness/shots/baseline/darwin/empty-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/empty-desktop-dark.sha256+1−1
@@ -1 +1 @@
678ef4190c22bd0d9808e92c6b5558e991221dd1f34218a3d893c881ce98f92e
\ No newline at end of file
a112dde8084332bb97824d92201970f56c782b8450c6ffcad5923ab1017f021b
\ No newline at end of file
harness/shots/baseline/darwin/empty-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/empty-desktop-light.sha256+1−1
@@ -1 +1 @@
678ef4190c22bd0d9808e92c6b5558e991221dd1f34218a3d893c881ce98f92e
\ No newline at end of file
a112dde8084332bb97824d92201970f56c782b8450c6ffcad5923ab1017f021b
\ No newline at end of file
harness/shots/baseline/darwin/empty-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/empty-phone-dark.sha256+1−1
@@ -1 +1 @@
678ef4190c22bd0d9808e92c6b5558e991221dd1f34218a3d893c881ce98f92e
\ No newline at end of file
a112dde8084332bb97824d92201970f56c782b8450c6ffcad5923ab1017f021b
\ No newline at end of file
harness/shots/baseline/darwin/empty-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/empty-phone-light.sha256+1−1
@@ -1 +1 @@
678ef4190c22bd0d9808e92c6b5558e991221dd1f34218a3d893c881ce98f92e
\ No newline at end of file
a112dde8084332bb97824d92201970f56c782b8450c6ffcad5923ab1017f021b
\ No newline at end of file
harness/shots/baseline/darwin/file-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/file-desktop-dark.sha256+1−1
@@ -1 +1 @@
f65deb19f2417b3ff935c2c557764002393c26effd2a9d26014d138a5f65df47
\ No newline at end of file
5971fafa26705ac66dd2c0a8646bb3562787c5b6ebf7be91833d5efd0bf9230b
\ No newline at end of file
harness/shots/baseline/darwin/file-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/file-desktop-light.sha256+1−1
@@ -1 +1 @@
f65deb19f2417b3ff935c2c557764002393c26effd2a9d26014d138a5f65df47
\ No newline at end of file
5971fafa26705ac66dd2c0a8646bb3562787c5b6ebf7be91833d5efd0bf9230b
\ No newline at end of file
harness/shots/baseline/darwin/file-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/file-phone-dark.sha256+1−1
@@ -1 +1 @@
f65deb19f2417b3ff935c2c557764002393c26effd2a9d26014d138a5f65df47
\ No newline at end of file
5971fafa26705ac66dd2c0a8646bb3562787c5b6ebf7be91833d5efd0bf9230b
\ No newline at end of file
harness/shots/baseline/darwin/file-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/file-phone-light.sha256+1−1
@@ -1 +1 @@
f65deb19f2417b3ff935c2c557764002393c26effd2a9d26014d138a5f65df47
\ No newline at end of file
5971fafa26705ac66dd2c0a8646bb3562787c5b6ebf7be91833d5efd0bf9230b
\ No newline at end of file
harness/shots/baseline/darwin/fork-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/fork-desktop-dark.sha256+1−1
@@ -1 +1 @@
ff01a27651eaf9bfa3c74af1e01c2f54365b305abae358163d946203cbe9c563
\ No newline at end of file
702a3d355908cf411ac573086f1358cfe2c2c37bee8b4c5a715b2abdaa33c033
\ No newline at end of file
harness/shots/baseline/darwin/fork-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/fork-desktop-light.sha256+1−1
@@ -1 +1 @@
ff01a27651eaf9bfa3c74af1e01c2f54365b305abae358163d946203cbe9c563
\ No newline at end of file
702a3d355908cf411ac573086f1358cfe2c2c37bee8b4c5a715b2abdaa33c033
\ No newline at end of file
harness/shots/baseline/darwin/fork-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/fork-phone-dark.sha256+1−1
@@ -1 +1 @@
ff01a27651eaf9bfa3c74af1e01c2f54365b305abae358163d946203cbe9c563
\ No newline at end of file
702a3d355908cf411ac573086f1358cfe2c2c37bee8b4c5a715b2abdaa33c033
\ No newline at end of file
harness/shots/baseline/darwin/fork-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/fork-phone-light.sha256+1−1
@@ -1 +1 @@
ff01a27651eaf9bfa3c74af1e01c2f54365b305abae358163d946203cbe9c563
\ No newline at end of file
702a3d355908cf411ac573086f1358cfe2c2c37bee8b4c5a715b2abdaa33c033
\ 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 @@
7ebdd3b2b907ba4afa5e6ceff837c0ad9b15975f813b5b9299ab163807685c97
\ No newline at end of file
edf63673480bfbff33f4eb54e000fdfedd86f82f5e69d6f48a66bc4bd6ba09c4
\ 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 @@
7ebdd3b2b907ba4afa5e6ceff837c0ad9b15975f813b5b9299ab163807685c97
\ No newline at end of file
edf63673480bfbff33f4eb54e000fdfedd86f82f5e69d6f48a66bc4bd6ba09c4
\ 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 @@
7ebdd3b2b907ba4afa5e6ceff837c0ad9b15975f813b5b9299ab163807685c97
\ No newline at end of file
edf63673480bfbff33f4eb54e000fdfedd86f82f5e69d6f48a66bc4bd6ba09c4
\ 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 @@
7ebdd3b2b907ba4afa5e6ceff837c0ad9b15975f813b5b9299ab163807685c97
\ No newline at end of file
edf63673480bfbff33f4eb54e000fdfedd86f82f5e69d6f48a66bc4bd6ba09c4
\ No newline at end of file
harness/shots/baseline/darwin/image-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/image-desktop-dark.sha256+1−1
@@ -1 +1 @@
a556f5e05a48f6e28f48d198ace64a670b3eb76e3a86e17b1ed1e0ec845cb9e8
\ No newline at end of file
3db7d53c4e90da306721b60a7fd7c03588bea0c0b2698618f25b177749aeb0c0
\ No newline at end of file
harness/shots/baseline/darwin/image-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/image-desktop-light.sha256+1−1
@@ -1 +1 @@
a556f5e05a48f6e28f48d198ace64a670b3eb76e3a86e17b1ed1e0ec845cb9e8
\ No newline at end of file
3db7d53c4e90da306721b60a7fd7c03588bea0c0b2698618f25b177749aeb0c0
\ No newline at end of file
harness/shots/baseline/darwin/image-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/image-phone-dark.sha256+1−1
@@ -1 +1 @@
a556f5e05a48f6e28f48d198ace64a670b3eb76e3a86e17b1ed1e0ec845cb9e8
\ No newline at end of file
3db7d53c4e90da306721b60a7fd7c03588bea0c0b2698618f25b177749aeb0c0
\ No newline at end of file
harness/shots/baseline/darwin/image-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/image-phone-light.sha256+1−1
@@ -1 +1 @@
a556f5e05a48f6e28f48d198ace64a670b3eb76e3a86e17b1ed1e0ec845cb9e8
\ No newline at end of file
3db7d53c4e90da306721b60a7fd7c03588bea0c0b2698618f25b177749aeb0c0
\ No newline at end of file
harness/shots/baseline/darwin/issue-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-desktop-dark.sha256+1−1
@@ -1 +1 @@
de3f0d2dca0027cf1b3e5f4d610a8098e22b5fac1835d6f9a8c383cd15aebcda
\ No newline at end of file
533bcd94c22d0290be80039eb843ade15b8270345fc6fc95471a0e23d02e88aa
\ No newline at end of file
harness/shots/baseline/darwin/issue-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-desktop-light.sha256+1−1
@@ -1 +1 @@
de3f0d2dca0027cf1b3e5f4d610a8098e22b5fac1835d6f9a8c383cd15aebcda
\ No newline at end of file
533bcd94c22d0290be80039eb843ade15b8270345fc6fc95471a0e23d02e88aa
\ No newline at end of file
harness/shots/baseline/darwin/issue-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-phone-dark.sha256+1−1
@@ -1 +1 @@
de3f0d2dca0027cf1b3e5f4d610a8098e22b5fac1835d6f9a8c383cd15aebcda
\ No newline at end of file
533bcd94c22d0290be80039eb843ade15b8270345fc6fc95471a0e23d02e88aa
\ No newline at end of file
harness/shots/baseline/darwin/issue-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issue-phone-light.sha256+1−1
@@ -1 +1 @@
de3f0d2dca0027cf1b3e5f4d610a8098e22b5fac1835d6f9a8c383cd15aebcda
\ No newline at end of file
533bcd94c22d0290be80039eb843ade15b8270345fc6fc95471a0e23d02e88aa
\ No newline at end of file
harness/shots/baseline/darwin/issues-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issues-desktop-dark.sha256+1−1
@@ -1 +1 @@
eea04c8136733b5f2e46410f9f19fe56d7a2a4baed7a4ba462c5ca3d7ab0ee6c
\ No newline at end of file
0c9a831618e36679cd82b05dd958ef2452d58cce758306044fa476e38dd4863f
\ No newline at end of file
harness/shots/baseline/darwin/issues-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issues-desktop-light.sha256+1−1
@@ -1 +1 @@
eea04c8136733b5f2e46410f9f19fe56d7a2a4baed7a4ba462c5ca3d7ab0ee6c
\ No newline at end of file
0c9a831618e36679cd82b05dd958ef2452d58cce758306044fa476e38dd4863f
\ No newline at end of file
harness/shots/baseline/darwin/issues-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issues-phone-dark.sha256+1−1
@@ -1 +1 @@
eea04c8136733b5f2e46410f9f19fe56d7a2a4baed7a4ba462c5ca3d7ab0ee6c
\ No newline at end of file
0c9a831618e36679cd82b05dd958ef2452d58cce758306044fa476e38dd4863f
\ No newline at end of file
harness/shots/baseline/darwin/issues-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/issues-phone-light.sha256+1−1
@@ -1 +1 @@
eea04c8136733b5f2e46410f9f19fe56d7a2a4baed7a4ba462c5ca3d7ab0ee6c
\ No newline at end of file
0c9a831618e36679cd82b05dd958ef2452d58cce758306044fa476e38dd4863f
\ No newline at end of file
harness/shots/baseline/darwin/markdown-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/markdown-desktop-dark.sha256+1−1
@@ -1 +1 @@
de18e32d46eff8532df160ab0d19a646aaf32c929cea1e2c996c15f75ef440ca
\ No newline at end of file
a9254e07a1a019448cb6f62527da01b13b3f547e142ac79cccff53e373d9f875
\ No newline at end of file
harness/shots/baseline/darwin/markdown-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/markdown-desktop-light.sha256+1−1
@@ -1 +1 @@
de18e32d46eff8532df160ab0d19a646aaf32c929cea1e2c996c15f75ef440ca
\ No newline at end of file
a9254e07a1a019448cb6f62527da01b13b3f547e142ac79cccff53e373d9f875
\ No newline at end of file
harness/shots/baseline/darwin/markdown-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/markdown-phone-dark.sha256+1−1
@@ -1 +1 @@
de18e32d46eff8532df160ab0d19a646aaf32c929cea1e2c996c15f75ef440ca
\ No newline at end of file
a9254e07a1a019448cb6f62527da01b13b3f547e142ac79cccff53e373d9f875
\ No newline at end of file
harness/shots/baseline/darwin/markdown-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/markdown-phone-light.sha256+1−1
@@ -1 +1 @@
de18e32d46eff8532df160ab0d19a646aaf32c929cea1e2c996c15f75ef440ca
\ No newline at end of file
a9254e07a1a019448cb6f62527da01b13b3f547e142ac79cccff53e373d9f875
\ No newline at end of file
harness/shots/baseline/darwin/missing-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/missing-desktop-dark.sha256+1−1
@@ -1 +1 @@
99cb2c771e1c620cf8f48907ab9ffd1e906b1771e91820c9a5e4dcb63fba4561
\ No newline at end of file
4191be27e69e85d5111a7bf7ad548afcaf8d29820584858e58488ba6946a724b
\ No newline at end of file
harness/shots/baseline/darwin/missing-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/missing-desktop-light.sha256+1−1
@@ -1 +1 @@
99cb2c771e1c620cf8f48907ab9ffd1e906b1771e91820c9a5e4dcb63fba4561
\ No newline at end of file
4191be27e69e85d5111a7bf7ad548afcaf8d29820584858e58488ba6946a724b
\ No newline at end of file
harness/shots/baseline/darwin/missing-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/missing-phone-dark.sha256+1−1
@@ -1 +1 @@
99cb2c771e1c620cf8f48907ab9ffd1e906b1771e91820c9a5e4dcb63fba4561
\ No newline at end of file
4191be27e69e85d5111a7bf7ad548afcaf8d29820584858e58488ba6946a724b
\ No newline at end of file
harness/shots/baseline/darwin/missing-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/missing-phone-light.sha256+1−1
@@ -1 +1 @@
99cb2c771e1c620cf8f48907ab9ffd1e906b1771e91820c9a5e4dcb63fba4561
\ No newline at end of file
4191be27e69e85d5111a7bf7ad548afcaf8d29820584858e58488ba6946a724b
\ No newline at end of file
harness/shots/baseline/darwin/profile-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-desktop-dark.sha256+1−1
@@ -1 +1 @@
ff564a4b5f0667775a0efa4fe9df6a8887e29bcdb8f32621dc94ea535a9a47ce
\ No newline at end of file
81bdae63742139c4876c06c7dc4e15b9e323f426e7b486d7f4e78d2e51044e99
\ No newline at end of file
harness/shots/baseline/darwin/profile-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-desktop-light.sha256+1−1
@@ -1 +1 @@
ff564a4b5f0667775a0efa4fe9df6a8887e29bcdb8f32621dc94ea535a9a47ce
\ No newline at end of file
81bdae63742139c4876c06c7dc4e15b9e323f426e7b486d7f4e78d2e51044e99
\ No newline at end of file
harness/shots/baseline/darwin/profile-fork-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-fork-desktop-dark.sha256+1−1
@@ -1 +1 @@
7dc4d219eb757b1ad44cb42b0b8c8f86b7fae29f4f4ddb6f5accf1abef2e5f75
\ No newline at end of file
ed7c3ad7fb00c99273b6f945d93911f19e5cc861af935a0c0d982e5fb8843c25
\ No newline at end of file
harness/shots/baseline/darwin/profile-fork-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-fork-desktop-light.sha256+1−1
@@ -1 +1 @@
7dc4d219eb757b1ad44cb42b0b8c8f86b7fae29f4f4ddb6f5accf1abef2e5f75
\ No newline at end of file
ed7c3ad7fb00c99273b6f945d93911f19e5cc861af935a0c0d982e5fb8843c25
\ No newline at end of file
harness/shots/baseline/darwin/profile-fork-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-fork-phone-dark.sha256+1−1
@@ -1 +1 @@
7dc4d219eb757b1ad44cb42b0b8c8f86b7fae29f4f4ddb6f5accf1abef2e5f75
\ No newline at end of file
ed7c3ad7fb00c99273b6f945d93911f19e5cc861af935a0c0d982e5fb8843c25
\ No newline at end of file
harness/shots/baseline/darwin/profile-fork-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-fork-phone-light.sha256+1−1
@@ -1 +1 @@
7dc4d219eb757b1ad44cb42b0b8c8f86b7fae29f4f4ddb6f5accf1abef2e5f75
\ No newline at end of file
ed7c3ad7fb00c99273b6f945d93911f19e5cc861af935a0c0d982e5fb8843c25
\ No newline at end of file
harness/shots/baseline/darwin/profile-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-phone-dark.sha256+1−1
@@ -1 +1 @@
ff564a4b5f0667775a0efa4fe9df6a8887e29bcdb8f32621dc94ea535a9a47ce
\ No newline at end of file
81bdae63742139c4876c06c7dc4e15b9e323f426e7b486d7f4e78d2e51044e99
\ No newline at end of file
harness/shots/baseline/darwin/profile-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/profile-phone-light.sha256+1−1
@@ -1 +1 @@
ff564a4b5f0667775a0efa4fe9df6a8887e29bcdb8f32621dc94ea535a9a47ce
\ No newline at end of file
81bdae63742139c4876c06c7dc4e15b9e323f426e7b486d7f4e78d2e51044e99
\ No newline at end of file
harness/shots/baseline/darwin/pull-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-desktop-dark.sha256+1−1
@@ -1 +1 @@
11cf70ccfbd7e11f7031f9244e37b1cd225d0ef465d60dfa006527f4d4d7065e
\ No newline at end of file
25869bd00ce889dcd0801ee38377c60e4af1448e10e6de6dd9115f1d65b607a6
\ No newline at end of file
harness/shots/baseline/darwin/pull-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-desktop-light.sha256+1−1
@@ -1 +1 @@
11cf70ccfbd7e11f7031f9244e37b1cd225d0ef465d60dfa006527f4d4d7065e
\ No newline at end of file
25869bd00ce889dcd0801ee38377c60e4af1448e10e6de6dd9115f1d65b607a6
\ No newline at end of file
harness/shots/baseline/darwin/pull-open-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-open-desktop-dark.sha256+1−1
@@ -1 +1 @@
0ae0f190a6d78f5a4cde33afccbc3996eaea695e3affec567cb13a916204976b
\ No newline at end of file
ee3fefa2ecaf463c037a8905b2e5f2473d921afa0063d1f7ac8724e2efc20141
\ No newline at end of file
harness/shots/baseline/darwin/pull-open-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-open-desktop-light.sha256+1−1
@@ -1 +1 @@
0ae0f190a6d78f5a4cde33afccbc3996eaea695e3affec567cb13a916204976b
\ No newline at end of file
ee3fefa2ecaf463c037a8905b2e5f2473d921afa0063d1f7ac8724e2efc20141
\ No newline at end of file
harness/shots/baseline/darwin/pull-open-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-open-phone-dark.sha256+1−1
@@ -1 +1 @@
0ae0f190a6d78f5a4cde33afccbc3996eaea695e3affec567cb13a916204976b
\ No newline at end of file
ee3fefa2ecaf463c037a8905b2e5f2473d921afa0063d1f7ac8724e2efc20141
\ No newline at end of file
harness/shots/baseline/darwin/pull-open-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-open-phone-light.sha256+1−1
@@ -1 +1 @@
0ae0f190a6d78f5a4cde33afccbc3996eaea695e3affec567cb13a916204976b
\ No newline at end of file
ee3fefa2ecaf463c037a8905b2e5f2473d921afa0063d1f7ac8724e2efc20141
\ No newline at end of file
harness/shots/baseline/darwin/pull-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-phone-dark.sha256+1−1
@@ -1 +1 @@
11cf70ccfbd7e11f7031f9244e37b1cd225d0ef465d60dfa006527f4d4d7065e
\ No newline at end of file
25869bd00ce889dcd0801ee38377c60e4af1448e10e6de6dd9115f1d65b607a6
\ No newline at end of file
harness/shots/baseline/darwin/pull-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pull-phone-light.sha256+1−1
@@ -1 +1 @@
11cf70ccfbd7e11f7031f9244e37b1cd225d0ef465d60dfa006527f4d4d7065e
\ No newline at end of file
25869bd00ce889dcd0801ee38377c60e4af1448e10e6de6dd9115f1d65b607a6
\ No newline at end of file
harness/shots/baseline/darwin/pulls-closed-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-closed-desktop-dark.sha256+1−1
@@ -1 +1 @@
7d4aa88c997ce4f8583bcb17f1d9c03986d6ab4b2e701edb34fcbf9a7d2a4b45
\ No newline at end of file
392c53bfa5ddb33b6071b63fc3adf7e6c9b52169cc2611329099263f9571d833
\ No newline at end of file
harness/shots/baseline/darwin/pulls-closed-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-closed-desktop-light.sha256+1−1
@@ -1 +1 @@
7d4aa88c997ce4f8583bcb17f1d9c03986d6ab4b2e701edb34fcbf9a7d2a4b45
\ No newline at end of file
392c53bfa5ddb33b6071b63fc3adf7e6c9b52169cc2611329099263f9571d833
\ No newline at end of file
harness/shots/baseline/darwin/pulls-closed-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-closed-phone-dark.sha256+1−1
@@ -1 +1 @@
7d4aa88c997ce4f8583bcb17f1d9c03986d6ab4b2e701edb34fcbf9a7d2a4b45
\ No newline at end of file
392c53bfa5ddb33b6071b63fc3adf7e6c9b52169cc2611329099263f9571d833
\ No newline at end of file
harness/shots/baseline/darwin/pulls-closed-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-closed-phone-light.sha256+1−1
@@ -1 +1 @@
7d4aa88c997ce4f8583bcb17f1d9c03986d6ab4b2e701edb34fcbf9a7d2a4b45
\ No newline at end of file
392c53bfa5ddb33b6071b63fc3adf7e6c9b52169cc2611329099263f9571d833
\ No newline at end of file
harness/shots/baseline/darwin/pulls-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-desktop-dark.sha256+1−1
@@ -1 +1 @@
6cdae0d8e63ec3c34c4f2fe200a96abac9ef12b0d295fbccb8a0a30b2b2ec7b7
\ No newline at end of file
b0e3fcae9f778f69469537d28d241b9915e03a061970d9bc688e834b3a187c16
\ No newline at end of file
harness/shots/baseline/darwin/pulls-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-desktop-light.sha256+1−1
@@ -1 +1 @@
6cdae0d8e63ec3c34c4f2fe200a96abac9ef12b0d295fbccb8a0a30b2b2ec7b7
\ No newline at end of file
b0e3fcae9f778f69469537d28d241b9915e03a061970d9bc688e834b3a187c16
\ No newline at end of file
harness/shots/baseline/darwin/pulls-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-phone-dark.sha256+1−1
@@ -1 +1 @@
6cdae0d8e63ec3c34c4f2fe200a96abac9ef12b0d295fbccb8a0a30b2b2ec7b7
\ No newline at end of file
b0e3fcae9f778f69469537d28d241b9915e03a061970d9bc688e834b3a187c16
\ No newline at end of file
harness/shots/baseline/darwin/pulls-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/pulls-phone-light.sha256+1−1
@@ -1 +1 @@
6cdae0d8e63ec3c34c4f2fe200a96abac9ef12b0d295fbccb8a0a30b2b2ec7b7
\ No newline at end of file
b0e3fcae9f778f69469537d28d241b9915e03a061970d9bc688e834b3a187c16
\ No newline at end of file
harness/shots/baseline/darwin/repository-desktop-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/repository-desktop-dark.sha256+1−1
@@ -1 +1 @@
549f070ed956084a55d56ad6a076d65eb6bf98c67374c205b00460aef47dbd55
\ No newline at end of file
8322c3e7013480bdf31871a1332aca8219b39f0a5b85446dfadb1f3d641780f2
\ No newline at end of file
harness/shots/baseline/darwin/repository-desktop-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/repository-desktop-light.sha256+1−1
@@ -1 +1 @@
549f070ed956084a55d56ad6a076d65eb6bf98c67374c205b00460aef47dbd55
\ No newline at end of file
8322c3e7013480bdf31871a1332aca8219b39f0a5b85446dfadb1f3d641780f2
\ No newline at end of file
harness/shots/baseline/darwin/repository-phone-dark.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/repository-phone-dark.sha256+1−1
@@ -1 +1 @@
549f070ed956084a55d56ad6a076d65eb6bf98c67374c205b00460aef47dbd55
\ No newline at end of file
8322c3e7013480bdf31871a1332aca8219b39f0a5b85446dfadb1f3d641780f2
\ No newline at end of file
harness/shots/baseline/darwin/repository-phone-light.png+0−0
A binary file, not shown.
harness/shots/baseline/darwin/repository-phone-light.sha256+1−1
@@ -1 +1 @@
549f070ed956084a55d56ad6a076d65eb6bf98c67374c205b00460aef47dbd55
\ No newline at end of file
8322c3e7013480bdf31871a1332aca8219b39f0a5b85446dfadb1f3d641780f2
\ No newline at end of file
harness/shots/baseline/darwin/search-desktop-dark.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/search-desktop-dark.sha256new+1−0
@@ -0,0 +1 @@
989b5abbd2493e3b57d631394f732e03b0b20246223d4735dc1b25131493a6e8
\ No newline at end of file
harness/shots/baseline/darwin/search-desktop-light.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/search-desktop-light.sha256new+1−0
@@ -0,0 +1 @@
989b5abbd2493e3b57d631394f732e03b0b20246223d4735dc1b25131493a6e8
\ No newline at end of file
harness/shots/baseline/darwin/search-phone-dark.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/search-phone-dark.sha256new+1−0
@@ -0,0 +1 @@
989b5abbd2493e3b57d631394f732e03b0b20246223d4735dc1b25131493a6e8
\ No newline at end of file
harness/shots/baseline/darwin/search-phone-light.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/search-phone-light.sha256new+1−0
@@ -0,0 +1 @@
989b5abbd2493e3b57d631394f732e03b0b20246223d4735dc1b25131493a6e8
\ No newline at end of file
harness/shots/baseline/darwin/start-desktop-dark.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/start-desktop-dark.sha256new+1−0
@@ -0,0 +1 @@
48443ebc3527b6cb430f9724929ac3b6e7437d492d9eea1484e22ae967fba7df
\ No newline at end of file
harness/shots/baseline/darwin/start-desktop-light.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/start-desktop-light.sha256new+1−0
@@ -0,0 +1 @@
48443ebc3527b6cb430f9724929ac3b6e7437d492d9eea1484e22ae967fba7df
\ No newline at end of file
harness/shots/baseline/darwin/start-phone-dark.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/start-phone-dark.sha256new+1−0
@@ -0,0 +1 @@
48443ebc3527b6cb430f9724929ac3b6e7437d492d9eea1484e22ae967fba7df
\ No newline at end of file
harness/shots/baseline/darwin/start-phone-light.pngnew+0−0
A binary file, not shown.
harness/shots/baseline/darwin/start-phone-light.sha256new+1−0
@@ -0,0 +1 @@
48443ebc3527b6cb430f9724929ac3b6e7437d492d9eea1484e22ae967fba7df
\ No newline at end of file
harness/shots/views.spec.mjs+1−1
@@ -21,7 +21,7 @@ const read = (file, otherwise) => {
const unchanged = new Set(read('unchanged.json', []))
const names = [
'home', 'profile', 'profile-fork', 'repository', 'directory', 'file', 'markdown', 'image', 'commits', 'commit',
'home', 'search', 'start', 'profile', 'profile-fork', 'repository', 'directory', 'file', 'markdown', 'image', 'commits', 'commit',
'pulls', 'pulls-closed', 'pull', 'pull-open', 'issues', 'issue', 'fork', 'empty', 'missing',
]
harness/shots/world.mjs+2−0
@@ -269,6 +269,8 @@ export async function create() {
}
const views = {
home: '/',
search: '/?q=t',
start: '/start',
profile: '/alice.test',
'profile-fork': '/bob.test',
repository: '/alice.test/tally',