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
Protected branches: rules on the repository, held at the gateway
A repository record's rules protect branches. Through the gateway a protected branch only moves forward and is never deleted, and with a linear history takes no merge commit between its tip and the new one, so none comes in by way of another branch either. The owner writes past the gateway, so the rules hold them only as their client does. knotless merge squashes a pull into a linear branch: the merged tree as one commit on the base, the head's author, the merger as committer, and the pull's title, body and URI as the message. knotless protect/unprotect/rules and the protect_branch tool set them. history is a string with known values, and one this version does not know reads as linear. The repo record and its rules keep the fields a newer client wrote when they are rewritten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 13 changed files with 485 additions and 22 deletions
README.md+8−1
| @@ -98,6 +98,12 @@ carries a verdict and the head it saw) and **`dev.knotless.state`** (open, | ||
closed or merged). An item's state is the latest one set by someone allowed | ||
to: its author, the owner, or a collaborator, and only the latter two merge. | ||
| ||
A repository's `rules` protect branches: through the gateway, a | ||
collaborator can only move a protected branch forward, never delete it, | ||
and, unless the rule allows merges, put no merge commits on it. Pulls then | ||
merge into it squashed, one commit each. The owner writes past the | ||
gateway, so the rules hold them only as their own client does. | ||
| ||
[`docs/design.md`](docs/design.md) has the details: the failure cases, the | ||
limits of a stock PDS, and the prior art. | ||
| ||
| @@ -204,7 +210,7 @@ It acts as the account `knotless login` last stored. To pin another, set | ||
| `list_repos`, `refs`, `list_tree`, `read_file`, `search`, `log`, `diff` | read, by `handle/name` | | ||
| `commit` | change files on a branch, optionally from a base and through the gateway | | ||
| `create_branch`, `delete_branch`, `rename_branch` | make, remove or rename a branch; a rename takes the open pulls on it along | | ||
| `create_repo`, `rename_repo`, `share`, `members`, `delegate`, `transfer_repo`, `accept_transfer` | manage repositories and access; `delegate` hands a gateway (the appview, by default) your writes, so collaborators can push | | ||
| `create_repo`, `rename_repo`, `share`, `members`, `delegate`, `protect_branch`, `transfer_repo`, `accept_transfer` | manage repositories and access; `delegate` hands a gateway (the appview, by default) your writes, so collaborators can push; `protect_branch` holds them to a branch's rules | | ||
| `fork`, `open_pull`, `open_issue`, `list_items`, `show_item`, `pull_diff`, `comment`, `set_state`, `merge`, `retarget_pull` | collaborate; `show_item` on a pull says what it brings in and whether it merges cleanly | | ||
| `hide_item`, `block_account`, `report` | moderate your repositories, report to the appview | | ||
| `takedown`, `moderation_status`, `list_reports` | as the appview's operator (`KNOTLESS_ADMIN_PASSWORD`) | | ||
| @@ -305,6 +311,7 @@ on one small VPS. | ||
| `knotless branch <url> <name> [<from>]` / `--delete` | make or remove a branch without a checkout | | ||
| `knotless delegate <handle> <gateway did>` | let a gateway write for you | | ||
| `knotless share <url> <handle> [--write]` / `unshare` / `members` | access | | ||
| `knotless protect <url> <branch> [--merges]` / `unprotect` / `rules <url>` | protect a branch from collaborators | | ||
| `knotless fork <url> [<name>]` | copy a repository into your account | | ||
| `knotless pull <url> <base> <head url> <branch> <title> [-m <body>]` | open a pull | | ||
| `knotless issue <url> <title> [-m <body>]` | open an issue | | ||
crates/knotless-mcp/src/lib.rs+34−1
| @@ -255,6 +255,21 @@ pub struct Reports { | ||
pub limit: Option<usize>, | ||
} | ||
| ||
#[derive(Deserialize, schemars::JsonSchema)] | ||
pub struct Protect { | ||
/// `handle/name`, `did/name` or `knotless://handle/name`. | ||
pub repo: String, | ||
/// The branch, without refs/heads/. | ||
pub branch: String, | ||
/// Let merge commits in. Without it the branch stays linear, and pulls | ||
/// merge into it squashed. | ||
#[serde(default)] | ||
pub merges: bool, | ||
/// Stop protecting it instead. | ||
#[serde(default)] | ||
pub remove: bool, | ||
} | ||
| ||
#[derive(Deserialize, schemars::JsonSchema)] | ||
pub struct Share { | ||
/// `handle/name`, `did/name` or `knotless://handle/name`. | ||
| @@ -657,6 +672,24 @@ impl Server { | ||
) | ||
} | ||
| ||
#[tool( | ||
description = "Protect a branch of a repository you own, or with remove, stop: collaborators can then only move it forward and never delete it, and unless merges is set, put no merge commits on it. The gateway holds them to it. Answers the protected branches." | ||
)] | ||
async fn protect_branch( | ||
&self, | ||
Parameters(p): Parameters<Protect>, | ||
) -> Result<CallToolResult, McpError> { | ||
answer( | ||
async { | ||
let rule = (!p.remove).then(|| knotless::records::Rule::new(&p.branch, p.merges)); | ||
let url = repo_url(&p.repo)?; | ||
let rules = manage::protect(&self.ctx, &url, &p.branch, rule).await?; | ||
Ok(serde_json::to_value(rules)?) | ||
} | ||
.await, | ||
) | ||
} | ||
| ||
#[tool( | ||
description = "Fork a repository into your account, with all its branches and tags. A private repository forks into a private one that its owner can read, so they can take pulls from it." | ||
)] | ||
| @@ -840,7 +873,7 @@ impl Server { | ||
} | ||
| ||
#[tool( | ||
description = "Merge an open pull request: a fast-forward when possible, else a merge commit. Fails on conflicts. The owner and collaborators may." | ||
description = "Merge an open pull request: a fast-forward when possible, else a merge commit; into a branch protected as linear, one squashed commit. Fails on conflicts. The owner and collaborators may." | ||
)] | ||
async fn merge(&self, Parameters(p): Parameters<Subject>) -> Result<CallToolResult, McpError> { | ||
answer( | ||
crates/knotless-mcp/tests/mcp.rs+19−0
| @@ -215,6 +215,17 @@ fn an_agent_works_on_a_repository() { | ||
assert_eq!(repos[0]["description"], "made by a tool"); | ||
let members = c.json("members", json!({"repo": repo})); | ||
assert_eq!(members[0]["access"], "owner"); | ||
let protect = |extra: Value| { | ||
let mut args = json!({"repo": repo, "branch": "main"}); | ||
args.as_object_mut() | ||
.unwrap() | ||
.extend(extra.as_object().unwrap().clone()); | ||
c.json("protect_branch", args) | ||
}; | ||
let rule = |history| json!([{"branch": "main", "history": history}]); | ||
assert_eq!(protect(json!({})), rule("linear")); | ||
assert_eq!(protect(json!({"merges": true})), rule("merges")); | ||
assert_eq!(protect(json!({"remove": true})), json!([])); | ||
| ||
// Renamed, it answers to both names. | ||
let refs = c.json("refs", json!({"repo": repo})); | ||
| @@ -249,6 +260,14 @@ fn an_agent_works_on_a_repository() { | ||
json!({"repo": format!("{}/missing", s.account.handle)}), | ||
), | ||
("share", json!({"repo": repo, "with": s.account.handle})), | ||
( | ||
"protect_branch", | ||
json!({"repo": repo, "branch": "main", "remove": true}), | ||
), | ||
( | ||
"protect_branch", | ||
json!({"repo": format!("{}/missing", s.account.handle), "branch": "main"}), | ||
), | ||
("rename_repo", json!({"repo": "not a repo", "name": "x"})), | ||
// With no appview, there is no gateway to assume. | ||
("delegate", json!({})), | ||
crates/knotless-serve/src/gateway.rs+13−1
| @@ -377,7 +377,18 @@ pub async fn push( | ||
{ | ||
return Err(bad(format!("the pack does not hold {sha}"))); | ||
} | ||
let report = push::push( | ||
// Protected branches hold whoever pushes here; the owner can write | ||
// past the gateway. | ||
let (mut allowed, mut refused) = (Vec::new(), Vec::new()); | ||
for update in updates { | ||
let refusal = gateway::refusal(&mirror.git, &snap, &update).await; | ||
match refusal.map_err(AppError::internal)? { | ||
Some(reason) => refused.push((update.dst, Err(reason))), | ||
None => allowed.push(update), | ||
} | ||
} | ||
let updates = allowed; | ||
let mut report = push::push( | ||
&app.ctx, | ||
&remote, | ||
&snap, | ||
| @@ -387,6 +398,7 @@ pub async fn push( | ||
) | ||
.await | ||
.map_err(AppError::upstream)?; | ||
report.extend(refused); | ||
Ok(Json(PushResults { | ||
results: report | ||
.into_iter() | ||
crates/knotless-serve/tests/forge.rs+49−0
| @@ -349,6 +349,55 @@ fn forks_pulls_reviews_and_merges() { | ||
assert_eq!(index.forge(did, &key).unwrap().len(), backfilled - 1); | ||
} | ||
| ||
#[test] | ||
fn pulls_squash_into_a_linear_branch() { | ||
let av = appview(); | ||
let owner = person(account(), &av); | ||
let contrib = person(account(), &av); | ||
let url = owner.url("squashed"); | ||
let work = owner.work("w", 1); | ||
owner.git(&work, &["push", "-q", &url, "main"]); | ||
let base = owner.git(&work, &["rev-parse", "HEAD"]); | ||
let fork = ok(&contrib, &["fork", &url]); | ||
let clone = contrib.path("clone"); | ||
contrib.git(contrib.dir.path(), &["clone", "-q", &fork, "clone"]); | ||
contrib.git(&clone, &["checkout", "-q", "-b", "two"]); | ||
contrib.commit_file(&clone, "a", "one\n"); | ||
contrib.commit_file(&clone, "b", "two\n"); | ||
contrib.git(&clone, &["push", "-q", "origin", "two"]); | ||
let pull = ok( | ||
&contrib, | ||
&[ | ||
"pull", | ||
&url, | ||
"main", | ||
&fork, | ||
"two", | ||
"Add a and b", | ||
"-m", | ||
"both", | ||
], | ||
); | ||
listed(&owner, &url, &pull); | ||
| ||
// A fast-forward would bring both commits; protected, it is one. | ||
ok(&owner, &["protect", &url, "main"]); | ||
let sha = ok(&owner, &["merge", &url, &pull]); | ||
let check = owner.path("check"); | ||
owner.git(owner.dir.path(), &["clone", "-q", &url, "check"]); | ||
assert_eq!(owner.git(&check, &["rev-parse", "HEAD"]), sha); | ||
assert_eq!(owner.git(&check, &["rev-parse", "HEAD^"]), base); | ||
assert_eq!(owner.git(&check, &["rev-list", "--count", "HEAD"]), "2"); | ||
assert!(check.join("a").exists() && check.join("b").exists()); | ||
let message = owner.git(&check, &["log", "-1", "--format=%an|%cn|%B"]); | ||
// The head's author wrote it, and the owner, who merged it, committed it. | ||
let committer = &owner.account.handle; | ||
assert_eq!( | ||
message, | ||
format!("Test|{committer}|Add a and b\n\nboth\n\n{pull}") | ||
); | ||
} | ||
| ||
#[test] | ||
fn issues_and_who_may_close_them() { | ||
let av = appview(); | ||
crates/knotless-serve/tests/gateway.rs+96−0
| @@ -102,6 +102,102 @@ fn post(url: &str, token: Option<&str>, body: Vec<u8>, json: bool) -> (u16, Stri | ||
(res.status().as_u16(), res.text().unwrap()) | ||
} | ||
| ||
/// 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> { | ||
let (ok, _, err) = s.git_raw(dir, &["push", "-q", "origin", refspec]); | ||
(!ok).then_some(err) | ||
} | ||
| ||
#[test] | ||
fn protected_branches_hold_collaborators() { | ||
let gw = gateway(); | ||
let owner = person(account()); | ||
let collab = person(account()); | ||
let url = owner.url("guarded"); | ||
let work = owner.work("w", 2); | ||
owner.git(&work, &["push", "-q", &url, "main"]); | ||
assert_eq!( | ||
owner | ||
.cli(&["delegate", &owner.account.handle, &gw.did], b"") | ||
.0, | ||
0 | ||
); | ||
let share = ["share", &url, &collab.account.handle, "--write"]; | ||
assert_eq!(owner.cli(&share, b"").0, 0); | ||
| ||
// Only the owner says which branches are protected. | ||
let (code, out) = collab.cli(&["protect", &url, "main"], b""); | ||
assert_eq!(code, 1, "{out}"); | ||
let missing = owner.url("missing"); | ||
for bad in [ | ||
vec!["rules", &missing], | ||
vec!["protect", &url, "main", "--bogus"], | ||
vec!["unprotect", &url, "main", "--merges"], | ||
vec!["unprotect", &url, "main"], | ||
] { | ||
assert_eq!(owner.cli(&bad, b"").0, 1, "{bad:?}"); | ||
} | ||
let (code, out) = owner.cli(&["protect", &url, "main"], b""); | ||
assert_eq!((code, out.as_str()), (0, "main\tlinear")); | ||
assert_eq!(collab.cli(&["rules", &url], b"").1, "main\tlinear"); | ||
| ||
let clone = collab.path("clone"); | ||
collab.git(collab.dir.path(), &["clone", "-q", &url, "clone"]); | ||
let base = collab.git(&clone, &["rev-parse", "HEAD"]); | ||
// A merge commit, of a side branch off where main was. | ||
collab.git(&clone, &["checkout", "-q", "-b", "side", "HEAD~1"]); | ||
collab.commit_file(&clone, "side", "side\n"); | ||
collab.git(&clone, &["checkout", "-q", "main"]); | ||
collab.git( | ||
&clone, | ||
&["merge", "-q", "--no-ff", "-m", "merge side", "side"], | ||
); | ||
let err = refused(&collab, &clone, "main").unwrap(); | ||
assert!(err.contains("no merge commits"), "{err}"); | ||
// Nor by way of a branch that took it: what counts is what main gains. | ||
assert_eq!(refused(&collab, &clone, "main:refs/heads/feature"), None); | ||
let err = refused(&collab, &clone, "main").unwrap(); | ||
assert!(err.contains("no merge commits"), "{err}"); | ||
// A new protected branch takes none in all its history. | ||
owner.cli(&["protect", &url, "release"], b""); | ||
let err = refused(&collab, &clone, "main:refs/heads/release").unwrap(); | ||
assert!(err.contains("no merge commits"), "{err}"); | ||
collab.git(&clone, &["reset", "-q", "--hard", &base]); | ||
let err = refused(&collab, &clone, "+HEAD~1:main").unwrap(); | ||
assert!(err.contains("only moves forward"), "{err}"); | ||
let err = refused(&collab, &clone, ":main").unwrap(); | ||
assert!(err.contains("not deleted"), "{err}"); | ||
| ||
// Forward, one parent at a time, it lands. | ||
let ahead = collab.commit_file(&clone, "f", "linear\n"); | ||
assert_eq!(refused(&collab, &clone, "main"), None); | ||
let (_, refs) = owner.cli(&["refs", &url], b""); | ||
assert!( | ||
refs.contains(&format!("{ahead}\trefs/heads/main")), | ||
"{refs}" | ||
); | ||
| ||
// The owner writes past the gateway, rules or none. | ||
owner.git(&work, &["push", "-q", "--force", &url, "main"]); | ||
| ||
// Merges allowed, merge commits land; history still only moves forward. | ||
let (code, out) = owner.cli(&["protect", &url, "main", "--merges"], b""); | ||
assert_eq!((code, out.as_str()), (0, "release\tlinear\nmain\tmerges")); | ||
collab.git(&clone, &["fetch", "-q", "origin"]); | ||
collab.git(&clone, &["reset", "-q", "--hard", "origin/main"]); | ||
collab.git( | ||
&clone, | ||
&["merge", "-q", "--no-ff", "-m", "merge side", "side"], | ||
); | ||
assert_eq!(refused(&collab, &clone, "main"), None); | ||
let err = refused(&collab, &clone, "+HEAD~1:main").unwrap(); | ||
assert!(err.contains("only moves forward"), "{err}"); | ||
let (code, out) = owner.cli(&["unprotect", &url, "main"], b""); | ||
assert_eq!((code, out.as_str()), (0, "release\tlinear")); | ||
assert_eq!(refused(&collab, &clone, "+HEAD~1:main"), None); | ||
} | ||
| ||
#[test] | ||
fn collaborators_push_and_commit_through_a_gateway() { | ||
let gw = gateway(); | ||
crates/knotless/src/cli.rs+28−1
| @@ -9,6 +9,7 @@ use crate::helper::{self, Knotless}; | ||
use crate::manage; | ||
use crate::moderation; | ||
use crate::oauth; | ||
use crate::records; | ||
use crate::remote::Remote; | ||
use crate::session; | ||
use crate::url::RepoUrl; | ||
| @@ -66,6 +67,11 @@ const USAGE: &str = "usage: knotless <command> | ||
share <url> <handle> [--write] let an account read, or push too | ||
unshare <url> <handle> | ||
members <url> who may read and push | ||
protect <url> <branch> [--merges] hold collaborators to moving it forward, | ||
never deleting it, and, without --merges, | ||
no merge commits (pulls merge squashed) | ||
unprotect <url> <branch> | ||
rules <url> the protected branches | ||
ls <handle> list an account's repositories | ||
refs <url> print refs, one round trip | ||
commit <url> -b <branch> -m <message> [--put <path> <file>]... | ||
| @@ -88,7 +94,8 @@ const USAGE: &str = "usage: knotless <command> | ||
retarget <url> <at-uri> [--base <branch>] [--head <branch>] | ||
point a pull at other branches; the head | ||
only by the pull's author | ||
merge <url> <at-uri> merge a pull: fast-forward or merge commit | ||
merge <url> <at-uri> merge a pull: fast-forward or merge commit, | ||
or squashed into a protected branch | ||
hide <url> <at-uri> as owner, hide a pull, issue or comment | ||
unhide <url> <at-uri> | ||
block <handle> [<url>] leave out what an account writes about | ||
| @@ -256,6 +263,26 @@ async fn command( | ||
writeln!(out, "{}\t{}", m.did, m.access)?; | ||
} | ||
} | ||
"protect" | "unprotect" | "rules" => { | ||
let url = args.url()?; | ||
let rules = match cmd.as_str() { | ||
"rules" => manage::rules(ctx, &url).await?, | ||
_ => { | ||
let branch = args.next("<branch>")?; | ||
let merges = match args.rest.next().map(String::as_str) { | ||
Some("--merges") if cmd == "protect" => true, | ||
Some(other) => bail!("unexpected {other}"), | ||
None => false, | ||
}; | ||
let rule = (cmd == "protect").then(|| records::Rule::new(branch, merges)); | ||
manage::protect(ctx, &url, branch, rule).await? | ||
} | ||
}; | ||
for rule in rules { | ||
let kind = if rule.linear() { "linear" } else { "merges" }; | ||
writeln!(out, "{}\t{kind}", rule.branch)?; | ||
} | ||
} | ||
"refs" => { | ||
let url = args.url()?; | ||
let (_, snap) = Remote::locate(ctx, &url).await?; | ||
crates/knotless/src/forge.rs+55−16
| @@ -728,8 +728,10 @@ async fn write_state( | ||
} | ||
| ||
/// Merges a pull: a fast-forward when the base has not moved since the | ||
/// head branched off it, else a merge commit. Fails on conflicts. The owner | ||
/// pushes the result directly; a collaborator through the gateway. | ||
/// head branched off it, else a merge commit; or, into a branch whose rule | ||
/// is linear, one commit of the whole pull on the base. Fails on | ||
/// conflicts. The owner pushes the result directly; a collaborator through | ||
/// the gateway. | ||
pub async fn merge(ctx: &Ctx, target: &RepoUrl, pull: &str) -> Result<String> { | ||
let t = Target::open(ctx, target).await?; | ||
// The pull as its author's repo has it now, not as the index last saw | ||
| @@ -741,10 +743,13 @@ pub async fn merge(ctx: &Ctx, target: &RepoUrl, pull: &str) -> Result<String> { | ||
let sides = sides(ctx, &t, &item).await?; | ||
let (git, base, head) = (&sides.git, &sides.base, &sides.head); | ||
let me = session::me(ctx).await?; | ||
let merged = if git.is_ancestor(base, head).await? { | ||
let branch = sides.refname.trim_start_matches("refs/heads/"); | ||
let squash = t.record.rule(branch).is_some_and(records::Rule::linear); | ||
let merged = if !squash && git.is_ancestor(base, head).await? { | ||
head.clone() | ||
} else { | ||
match merge_tree(git, base, head).await? { | ||
Ok(tree) if squash => squash_commit(git, &tree, base, head, &item, &me).await?, | ||
Ok(tree) => merge_commit(git, &tree, base, head, &item, &me).await?, | ||
Err(files) => bail!("merging conflicts in {}", files.join(", ")), | ||
} | ||
| @@ -1016,14 +1021,8 @@ async fn merge_tree(git: &Git, base: &str, head: &str) -> Result<Result<String, | ||
}) | ||
} | ||
| ||
async fn merge_commit( | ||
git: &Git, | ||
tree: &str, | ||
base: &str, | ||
head: &str, | ||
item: &Item, | ||
me: &Session, | ||
) -> Result<String> { | ||
/// `git`, committing as `me`. | ||
fn committer(git: &Git, me: &Session) -> Git { | ||
let mut git = git.clone(); | ||
let email = format!("{}@knotless.invalid", me.did); | ||
git.envs.extend([ | ||
| @@ -1032,12 +1031,52 @@ async fn merge_commit( | ||
("GIT_COMMITTER_NAME".into(), me.handle.clone()), | ||
("GIT_COMMITTER_EMAIL".into(), email), | ||
]); | ||
git | ||
} | ||
| ||
async fn merge_commit( | ||
git: &Git, | ||
tree: &str, | ||
base: &str, | ||
head: &str, | ||
item: &Item, | ||
me: &Session, | ||
) -> Result<String> { | ||
let message = format!("Merge {}\n\n{}", item.title, item.uri); | ||
git.text( | ||
&["commit-tree", tree, "-p", base, "-p", head, "-m", &message], | ||
None, | ||
) | ||
.await | ||
committer(git, me) | ||
.text( | ||
&["commit-tree", tree, "-p", base, "-p", head, "-m", &message], | ||
None, | ||
) | ||
.await | ||
} | ||
| ||
/// The pull as one commit on `base`: the head's author wrote it, `me` | ||
/// commits it, and the message is the pull's. | ||
async fn squash_commit( | ||
git: &Git, | ||
tree: &str, | ||
base: &str, | ||
head: &str, | ||
item: &Item, | ||
me: &Session, | ||
) -> Result<String> { | ||
let mut git = committer(git, me); | ||
let author = git | ||
.text(&["log", "-1", "--format=%an%x00%ae%x00%aI", head], None) | ||
.await?; | ||
let mut author = author.split('\0').map(str::to_string); | ||
for var in ["GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_AUTHOR_DATE"] { | ||
git.envs | ||
.push((var.into(), author.next().unwrap_or_default())); | ||
} | ||
let mut message = item.title.clone(); | ||
if let Some(body) = item.body.as_deref().filter(|b| !b.is_empty()) { | ||
message = format!("{message}\n\n{body}"); | ||
} | ||
let message = format!("{message}\n\n{}", item.uri); | ||
git.text(&["commit-tree", tree, "-p", base, "-m", &message], None) | ||
.await | ||
} | ||
| ||
/// Forks a repository into my account, under `name` or its own. A private | ||
crates/knotless/src/gateway.rs+34−0
| @@ -374,6 +374,40 @@ pub async fn stale_paths( | ||
.collect()) | ||
} | ||
| ||
/// Why `update` may not land on a protected branch, through a gateway: a | ||
/// protected branch only moves forward and is never deleted, and with | ||
/// `linear` takes no merge commits. `None` when it may. `git` holds the | ||
/// repository's objects and the update's. | ||
pub async fn refusal(git: &Git, snap: &Snapshot, update: &Update) -> Result<Option<String>> { | ||
let rule = snap | ||
.repo | ||
.as_ref() | ||
.zip(update.dst.strip_prefix("refs/heads/")) | ||
.and_then(|(repo, branch)| repo.value.rule(branch)); | ||
let Some(rule) = rule else { | ||
return Ok(None); | ||
}; | ||
let from = snap | ||
.find_ref(&update.dst) | ||
.and_then(|r| r.value.target.clone()); | ||
let refused = match (&from, &update.src) { | ||
(Some(_), None) => Some("protected branch: it is not deleted"), | ||
(Some(from), Some(to)) if !git.is_ancestor(from, to).await? => { | ||
Some("protected branch: it only moves forward") | ||
} | ||
(from, Some(to)) if rule.linear() => { | ||
// A merge commit among what it brings to this branch, wherever | ||
// else it already is: all of its history, for a new branch. | ||
let mut args = vec!["rev-list", "--min-parents=2", "-n", "1", to.as_str()]; | ||
args.extend(from.iter().flat_map(|f| ["--not", f.as_str()])); | ||
let merges = git.text(&args, None).await?; | ||
(!merges.is_empty()).then_some("protected branch: no merge commits; squash or rebase") | ||
} | ||
_ => None, | ||
}; | ||
Ok(refused.map(str::to_string)) | ||
} | ||
| ||
/// Whether a commit's tip is a hash git can name, as a gateway requires. | ||
pub fn is_sha(text: &str) -> bool { | ||
text.len() == 40 && text.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) | ||
crates/knotless/src/manage.rs+42−0
| @@ -234,6 +234,48 @@ async fn set_access(ctx: &Ctx, url: &RepoUrl, who: &str, access: Option<bool>) - | ||
.await | ||
} | ||
| ||
/// Protects `branch`, or with `rule: None` stops protecting it, and | ||
/// answers the rules that stand. Only the owner can. | ||
pub async fn protect( | ||
ctx: &Ctx, | ||
url: &RepoUrl, | ||
branch: &str, | ||
rule: Option<records::Rule>, | ||
) -> Result<Vec<records::Rule>> { | ||
let remote = Remote::open(ctx, url).await?; | ||
let auth = session::ensure(ctx, &remote.identity).await?; | ||
let Some(repo) = remote.repo().await? else { | ||
bail!("{url}: no such repository"); | ||
}; | ||
let name = repo.rkey; | ||
let mut record = repo.value; | ||
if rule.is_none() && record.rule(branch).is_none() { | ||
bail!("{url}: {branch} is not protected"); | ||
} | ||
record.rules.retain(|r| r.branch != branch); | ||
record.rules.extend(rule); | ||
remote | ||
.apply( | ||
vec![remote::update( | ||
nsid::REPO, | ||
&name, | ||
records::typed(nsid::REPO, &record), | ||
)], | ||
&auth.auth(), | ||
) | ||
.await?; | ||
Ok(record.rules) | ||
} | ||
| ||
/// The repository's protected branches. | ||
pub async fn rules(ctx: &Ctx, url: &RepoUrl) -> Result<Vec<records::Rule>> { | ||
let remote = Remote::open(ctx, url).await?; | ||
let Some(repo) = remote.repo().await? else { | ||
bail!("{url}: no such repository"); | ||
}; | ||
Ok(repo.value.rules) | ||
} | ||
| ||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] | ||
pub struct Member { | ||
pub did: String, | ||
crates/knotless/src/records.rs+75−0
| @@ -6,6 +6,7 @@ use serde::de::Error as _; | ||
use serde::ser::SerializeMap; | ||
use serde::{Deserialize, Deserializer, Serialize, Serializer}; | ||
use serde_json::Value; | ||
use std::collections::BTreeMap; | ||
| ||
pub mod nsid { | ||
pub const REPO: &str = "dev.knotless.repo"; | ||
| @@ -60,7 +61,22 @@ pub struct RepoRecord { | ||
/// pulls, issues and forks are this one's. | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
pub transferred_from: Option<RepoRef>, | ||
/// Protected branches, which the gateway keeps collaborators to. | ||
#[serde(default, skip_serializing_if = "Vec::is_empty")] | ||
pub rules: Vec<Rule>, | ||
pub created_at: String, | ||
/// Fields this version does not know, which a newer client wrote: kept, | ||
/// so that rewriting the record does not drop them. | ||
#[serde(flatten, deserialize_with = "unknown")] | ||
pub unknown: BTreeMap<String, Value>, | ||
} | ||
| ||
/// The fields a flattened map catches, but for the record's `$type`, which | ||
/// is written afresh. | ||
fn unknown<'de, D: Deserializer<'de>>(d: D) -> Result<BTreeMap<String, Value>, D::Error> { | ||
let mut fields = BTreeMap::<String, Value>::deserialize(d)?; | ||
fields.remove("$type"); | ||
Ok(fields) | ||
} | ||
| ||
impl RepoRecord { | ||
| @@ -68,6 +84,43 @@ impl RepoRecord { | ||
pub fn is_collaborator(&self, did: &str) -> bool { | ||
self.collaborators.iter().any(|c| c.did == did) | ||
} | ||
| ||
/// The rule protecting `branch`, if one does. | ||
pub fn rule(&self, branch: &str) -> Option<&Rule> { | ||
self.rules.iter().find(|r| r.branch == branch) | ||
} | ||
} | ||
| ||
/// A protected branch: through a gateway it only moves forward and is | ||
/// never deleted, and with a linear history takes no merge commits, so | ||
/// pulls merge into it by squashing. | ||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] | ||
pub struct Rule { | ||
/// The branch, without refs/heads/. | ||
pub branch: String, | ||
/// `linear` or `merges`. | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
pub history: Option<String>, | ||
/// What a newer client said of the branch, kept on a rewrite. | ||
#[serde(flatten)] | ||
pub unknown: BTreeMap<String, Value>, | ||
} | ||
| ||
impl Rule { | ||
pub fn new(branch: &str, merges: bool) -> Rule { | ||
Rule { | ||
branch: branch.to_string(), | ||
history: Some(if merges { "merges" } else { "linear" }.into()), | ||
unknown: BTreeMap::new(), | ||
} | ||
} | ||
| ||
/// Whether the branch takes no merge commits: unless the rule says it | ||
/// takes them, so a history this version does not know is held to at | ||
/// least this. | ||
pub fn linear(&self) -> bool { | ||
self.history.as_deref() != Some("merges") | ||
} | ||
} | ||
| ||
/// A repository, as other records name it: the `at://` URI of its record | ||
| @@ -373,6 +426,28 @@ mod tests { | ||
assert!(now().ends_with('Z')); | ||
} | ||
| ||
#[test] | ||
fn rewrites_keep_what_they_do_not_know() { | ||
let written = json!({ | ||
"$type": nsid::REPO, "key": "3k", "name": "r", "createdAt": "t", | ||
"rules": [ | ||
{"branch": "main", "history": "rebase", "reviews": 2}, | ||
{"branch": "dev", "history": "merges"}, | ||
{"branch": "old"}, | ||
], | ||
"topics": ["git"], | ||
}); | ||
let record: RepoRecord = serde_json::from_value(written.clone()).unwrap(); | ||
// A history this version does not know holds at least as hard. | ||
let linear: Vec<bool> = record.rules.iter().map(Rule::linear).collect(); | ||
assert_eq!(linear, [true, false, true]); | ||
assert_eq!(typed(nsid::REPO, &record), written); | ||
assert_eq!( | ||
serde_json::to_value(Rule::new("main", false)).unwrap(), | ||
json!({"branch": "main", "history": "linear"}) | ||
); | ||
} | ||
| ||
#[test] | ||
fn repo_refs() { | ||
let r = RepoRef::new("did:plc:a", "proj", "3kabc"); | ||
docs/design.md+17−2
| @@ -25,7 +25,7 @@ choice below. | ||
``` | ||
dev.knotless.repo <name> key, name, description?, defaultBranch?, | ||
collaborators?, gateway?, forkOf?, | ||
transfer?, transferredFrom? | ||
transfer?, transferredFrom?, rules? | ||
dev.knotless.ref <key>.<h(ref)> name, target?, generation | ||
dev.knotless.claim <key>.<h(ref)>.<generation> name, generation, from?, to?, pack? | ||
dev.knotless.pack <key>.<tid> tips, bases, size, objects, data | parts, supersedes? | ||
| @@ -253,6 +253,18 @@ directly. A gateway is a `knotless-serve` with `--public-url`, identified as | ||
one push. If the branch moved, the chain is rebuilt on the new tip, up to | ||
eight times. A change to a path that also changed since its `base` is a | ||
conflict (409) for that commit alone. | ||
- **Protected branches.** A `rules` entry on the repository record protects | ||
a branch. The gateway refuses a push through it that deletes it or | ||
moves it anywhere but forward, and, when its `history` is `linear`, one that | ||
brings it a merge commit: any commit with two parents between the | ||
branch's tip and the new one (all of its history, for a branch that does | ||
not exist yet), wherever else that commit already is. Commits through | ||
`createCommit` build on the tip with one parent, so they always pass. | ||
A `history` this version does not know reads as `linear`, so a rule a | ||
newer client wrote is held at least as hard, and a client rewriting the | ||
record keeps the fields it does not know. The owner writes their own | ||
records past any gateway, so the rules bind | ||
them only as far as their own client keeps to them. | ||
| ||
The gateway holds an OAuth session for the owner, so it is trusted with | ||
writing knotless records to the owner's account: run your own, or use one | ||
| @@ -280,7 +292,10 @@ does not wait for an index. | ||
| ||
`knotless merge` syncs the target's mirror, fetches the head from the | ||
source's packs, and fast-forwards when it can. Otherwise it builds a merge | ||
commit with `git merge-tree --write-tree`, and stops on conflicts. It pushes | ||
commit with `git merge-tree --write-tree`, and stops on conflicts. Into a | ||
branch whose rule's `history` is `linear` it squashes instead: the merged tree as one | ||
commit on the target, with the head's author, the merger as committer, | ||
and the pull's title, body and URI as its message. It pushes | ||
directly as the owner or through the gateway as a collaborator, then writes | ||
a `merged` state naming the commit. Comparing a pull does the same up to | ||
the push: the commits it brings in, and whether it is merged already, | ||
lexicons/dev/knotless/repo.json+15−0
| @@ -25,10 +25,25 @@ | ||
"gateway": { "type": "string", "format": "did", "description": "A gateway the owner has delegated writes to. Collaborators push and commit through it; it batches concurrent commits to a branch into one write." }, | ||
"transfer": { "type": "ref", "ref": "dev.knotless.defs#repoRef", "description": "Where the owner has offered to move the repository: a record in another account, named before it exists. Once that account holds a dev.knotless.repo there whose `transferredFrom` names this one, the repository has moved, and readers follow." }, | ||
"transferredFrom": { "type": "ref", "ref": "dev.knotless.defs#repoRef", "description": "The repository this one was moved from. Readers count records naming that one as naming this one, if its owner agreed: its `transfer`, or the redirect left in its place, leads here." }, | ||
"rules": { | ||
"type": "array", | ||
"maxLength": 100, | ||
"items": { "type": "ref", "ref": "#rule" }, | ||
"description": "Protected branches. The gateway holds collaborators to them; the owner, who writes these records directly, is held only by their own client." | ||
}, | ||
"createdAt": { "type": "string", "format": "datetime" } | ||
} | ||
} | ||
}, | ||
"rule": { | ||
"type": "object", | ||
"description": "A protected branch: through a gateway it only moves forward and is never deleted.", | ||
"required": ["branch"], | ||
"properties": { | ||
"branch": { "type": "string", "maxLength": 255, "description": "The branch, without refs/heads/." }, | ||
"history": { "type": "string", "maxLength": 64, "knownValues": ["linear", "merges"], "description": "`linear`: the branch takes no merge commits, so pulls merge into it by squashing. `merges`: it takes them. Absent, or a value a reader does not know, is read as `linear`, so a rule is never held more loosely than it was written." } | ||
} | ||
}, | ||
"collaborator": { | ||
"type": "object", | ||
"description": "An account that may push to the repository.", | ||