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

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>
Chad Miller and Claude Opus 5.5 committed on Oct 22, 2026 parent c2f591c commit aa663e0
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