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
Merging a pull deletes its branch, unless another pull still names it
A pull from a branch of the repository itself takes that branch with it, in the same push as the merge. The branch stays when another open pull targets it or brings it in, or when asked: `knotless merge --keep-branch`, or `keep_branch` over MCP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 5 changed files with 117 additions and 18 deletions
README.md+3−1
| @@ -168,7 +168,9 @@ knotless merge knotless://alice.test/proj <pull at-uri> # fast-forward, or a m | ||
Pulls and issues on public repositories are listed by an appview | ||
(`KNOTLESS_APPVIEW`), which indexes them from Jetstream or the firehose; on private ones, | ||
from the members' repos in the space. A review reads the pull from its | ||
author's repo directly, so it never waits for an index. | ||
author's repo directly, so it never waits for an index. Merging a pull from | ||
a branch of the repository itself deletes that branch in the same push, | ||
unless another open pull still names it or `--keep-branch` keeps it. | ||
| ||
Moderation is atproto's own. An owner hides what is said about their | ||
repository and blocks accounts from it or from all of theirs; anyone reports to the | ||
crates/knotless-mcp/src/lib.rs+18−4
| @@ -324,6 +324,16 @@ pub struct Items { | ||
pub state: Option<String>, | ||
} | ||
| ||
#[derive(Deserialize, schemars::JsonSchema)] | ||
pub struct MergePull { | ||
pub repo: String, | ||
/// The pull's at:// URI. | ||
pub uri: String, | ||
/// Keep its head branch, which otherwise goes with it. | ||
#[serde(default)] | ||
pub keep_branch: bool, | ||
} | ||
| ||
#[derive(Deserialize, schemars::JsonSchema)] | ||
pub struct Subject { | ||
pub repo: String, | ||
| @@ -875,13 +885,17 @@ impl Server { | ||
} | ||
| ||
#[tool( | ||
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." | ||
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. Its head branch, when in this repository, is deleted with it unless keep_branch or another open pull names it. The owner and collaborators may." | ||
)] | ||
async fn merge(&self, Parameters(p): Parameters<Subject>) -> Result<CallToolResult, McpError> { | ||
async fn merge( | ||
&self, | ||
Parameters(p): Parameters<MergePull>, | ||
) -> Result<CallToolResult, McpError> { | ||
answer( | ||
async { | ||
let sha = forge::merge(&self.ctx, &repo_url(&p.repo)?, &p.uri).await?; | ||
Ok(json!({"sha": sha})) | ||
let url = repo_url(&p.repo)?; | ||
let merged = forge::merge(&self.ctx, &url, &p.uri, p.keep_branch).await?; | ||
Ok(json!({"sha": merged.sha, "deleted": merged.deleted})) | ||
} | ||
.await, | ||
) | ||
crates/knotless-serve/tests/forge.rs+31−1
| @@ -239,6 +239,33 @@ fn forks_pulls_reviews_and_merges() { | ||
assert_eq!(ok(&owner, &["branch", &url, "pinned", &two]), two); | ||
ok(&owner, &["branch", &url, "pinned", "--delete"]); | ||
| ||
// A branch in the repository itself goes with its merged pull, unless | ||
// another open pull still names it, or it is asked to stay. | ||
owner.git(&work, &["pull", "-q", &url, "main"]); | ||
owner.git(&work, &["checkout", "-q", "-b", "tidy"]); | ||
owner.commit_file(&work, "g", "tidy\n"); | ||
owner.git(&work, &["checkout", "-q", "-b", "atop"]); | ||
owner.commit_file(&work, "h", "atop\n"); | ||
owner.git(&work, &["push", "-q", &url, "tidy", "atop", "tidy:kept"]); | ||
let tidy = ok(&owner, &["pull", &url, "main", &url, "tidy", "Tidy"]); | ||
let atop = ok(&owner, &["pull", &url, "tidy", &url, "atop", "Atop"]); | ||
let kept = ok(&owner, &["pull", &url, "main", &url, "kept", "Kept"]); | ||
listed(&owner, &url, &atop); | ||
listed(&owner, &url, &kept); | ||
let merged = ok(&owner, &["merge", &url, &tidy]); | ||
assert!(!merged.contains("deleted"), "{merged}"); | ||
let merged = ok(&owner, &["merge", &url, &atop]); | ||
assert!(merged.ends_with("\ndeleted atop"), "{merged}"); | ||
fails(&owner, &["merge", &url, &kept, "--bogus"]); | ||
let merged = ok(&owner, &["merge", &url, &kept, "--keep-branch"]); | ||
assert!(!merged.contains("deleted"), "{merged}"); | ||
let (_, refs) = owner.cli(&["refs", &url], b""); | ||
assert!( | ||
refs.contains("refs/heads/tidy") && refs.contains("refs/heads/kept"), | ||
"{refs}" | ||
); | ||
assert!(!refs.contains("refs/heads/atop"), "{refs}"); | ||
| ||
// Someone else's commit, elsewhere on the firehose, changes nothing. | ||
av.app.observe(knotless_serve::firehose::Event::default()); | ||
assert_eq!(av.app.events_for("did:plc:nobody"), 0); | ||
| @@ -572,7 +599,10 @@ fn private_repositories_keep_their_issues_in_the_space() { | ||
assert!(!ok(&owner, &["list", &url]).contains(&issue)); | ||
ok(&owner, &["unblock", &reader.account.handle, &url]); | ||
assert!(ok(&owner, &["list", &url]).contains(&issue)); | ||
assert_eq!(ok(&owner, &["merge", &url, &pull]), fix); | ||
assert_eq!( | ||
ok(&owner, &["merge", &url, &pull]), | ||
format!("{fix}\ndeleted fix") | ||
); | ||
let listing = ok(&reader, &["list", &url]); | ||
assert!( | ||
listing.contains(&format!("pull\tmerged\t{pull}")), | ||
crates/knotless/src/cli.rs+15−4
| @@ -95,8 +95,10 @@ 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, | ||
or squashed into a protected branch | ||
merge <url> <at-uri> [--keep-branch] merge a pull: fast-forward or merge commit, | ||
or squashed into a protected branch; its | ||
branch here goes, unless kept or still | ||
named by another open pull | ||
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 | ||
| @@ -544,8 +546,17 @@ async fn command( | ||
} | ||
"merge" => { | ||
let url = args.url()?; | ||
let sha = forge::merge(ctx, &url, args.next("<at-uri>")?).await?; | ||
writeln!(out, "{sha}")?; | ||
let pull = args.next("<at-uri>")?; | ||
let keep = match args.rest.next().map(String::as_str) { | ||
Some("--keep-branch") => true, | ||
Some(other) => bail!("unexpected {other}"), | ||
None => false, | ||
}; | ||
let merged = forge::merge(ctx, &url, pull, keep).await?; | ||
writeln!(out, "{}", merged.sha)?; | ||
if let Some(branch) = merged.deleted { | ||
writeln!(out, "deleted {branch}")?; | ||
} | ||
} | ||
"rename" => { | ||
let url = args.url()?; | ||
crates/knotless/src/forge.rs+50−8
| @@ -733,16 +733,26 @@ async fn write_state( | ||
.await | ||
} | ||
| ||
/// A merged pull: the commit its target branch now points at, and its head | ||
/// branch, if the merge deleted it. | ||
#[derive(Debug, PartialEq)] | ||
pub struct Merged { | ||
pub sha: String, | ||
pub deleted: Option<String>, | ||
} | ||
| ||
/// Merges a pull: a fast-forward when the base has not moved since the | ||
/// 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> { | ||
/// the gateway. A head branch in the same repository goes with it, in the | ||
/// same push, unless `keep` or another open pull still names it. | ||
pub async fn merge(ctx: &Ctx, target: &RepoUrl, pull: &str, keep: bool) -> Result<Merged> { | ||
let t = Target::open(ctx, target).await?; | ||
// The pull as its author's repo has it now, not as the index last saw | ||
// it: the index may not have it yet. | ||
let (item, _) = t.item(ctx, pull).await?; | ||
let (entries, _) = t.around(ctx, pull).await?; | ||
let item = thread(&t.record, t.remote.did(), &entries, pull)?.item; | ||
if item.state != "open" { | ||
bail!("{pull} is {}", item.state); | ||
} | ||
| @@ -760,20 +770,52 @@ pub async fn merge(ctx: &Ctx, target: &RepoUrl, pull: &str) -> Result<String> { | ||
Err(files) => bail!("merging conflicts in {}", files.join(", ")), | ||
} | ||
}; | ||
let update = Update { | ||
let mut updates = vec![Update { | ||
src: Some(merged.clone()), | ||
dst: sides.refname.clone(), | ||
force: false, | ||
}; | ||
}]; | ||
let others = assemble(&t.record, t.remote.did(), &entries); | ||
let deleted = (!keep).then(|| spent(&t, &item, &others)).flatten(); | ||
if let Some(gone) = &deleted { | ||
updates.push(Update { | ||
src: None, | ||
dst: format!("refs/heads/{gone}"), | ||
force: false, | ||
}); | ||
} | ||
let report = if me.did == t.remote.did() { | ||
push::push(ctx, &t.remote, &t.snap, git, &me.auth(), &[update]).await? | ||
push::push(ctx, &t.remote, &t.snap, git, &me.auth(), &updates).await? | ||
} else { | ||
gateway::push(ctx, &t.remote, &t.snap, git, &me, &[update]).await? | ||
gateway::push(ctx, &t.remote, &t.snap, git, &me, &updates).await? | ||
}; | ||
accepted(report)?; | ||
let from = Some(base.clone()); | ||
write_state(ctx, &t, pull, "merged", Some(merged.clone()), from).await?; | ||
Ok(merged) | ||
Ok(Merged { | ||
sha: merged, | ||
deleted, | ||
}) | ||
} | ||
| ||
/// The head branch of `item`, once merged, when nothing needs it any more: | ||
/// it is in the target itself, is neither the target branch nor the | ||
/// default one, and no other open pull brings it in or targets it. | ||
fn spent(t: &Target, item: &Item, items: &[Item]) -> Option<String> { | ||
let head = item.head.as_ref()?; | ||
let here = t.reference().key; | ||
let default = t.record.default_branch.as_deref().unwrap_or("main"); | ||
let named = |other: &Item| { | ||
let into = other.base.as_deref() == Some(&head.branch); | ||
let from = other | ||
.head | ||
.as_ref() | ||
.is_some_and(|h| h.repo.key == here && h.branch == head.branch); | ||
other.uri != item.uri && other.state == "open" && (into || from) | ||
}; | ||
let mine = head.repo.key == here && parse_uri(&head.repo.uri).ok()?.0 == t.remote.did(); | ||
let base = item.base.as_deref() == Some(&head.branch) || head.branch == default; | ||
(mine && !base && !items.iter().any(named)).then(|| head.branch.clone()) | ||
} | ||
| ||
/// A pull's two sides, in the target's mirror, with the head fetched from | ||