~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

A pull's branch updates from its base, keeping lines both sides added

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:client/update-branch
.changeset/update-branch.mdnew+7−0
@@ -0,0 +1,7 @@
---
default: minor
---
# Update a pull's branch
`knotless update-branch <url> <pull>` and the MCP `update_branch` tool merge a pull's base into its branch and push it, with no checkout, as GitHub's Update branch button does. Where both sides only added lines at the same place, as two pulls each adding a section to one docs page, the base's lines come first and the pull's after; any other conflict is refused, naming its paths.
crates/knotless/src/branch_update.rsnew+468−0
468 lines changed: not loaded until asked for.
crates/knotless/src/cli.rs+19−0
@@ -333,6 +333,11 @@ const USAGE: &str = "usage: knotless <command>
revert <url> <at-uri> [-m <reason>] as owner or collaborator, undo a merged
pull in one commit on revert/<rkey> and
open a pull of it into the same branch
update-branch <url> <at-uri> as owner or collaborator, merge an open
pull's base into its branch, where both
sides only added lines keeping the
base's first; refused on any other
conflict, naming its paths
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
@@ -2044,6 +2049,20 @@ async fn command(
let reverted = crate::revert::revert(ctx, &url, &pull, reason.as_deref()).await?;
writeln!(out, "{}", reverted.pull)?;
}
"update-branch" => {
let url = args.url()?;
let pull = args.next("<at-uri>")?;
let updated = crate::branch_update::update_branch(ctx, &url, pull).await?;
let branch = &updated.branch;
match &updated.commit {
Some(commit) => writeln!(out, "merged its base into {branch}: {commit}")?,
None => writeln!(out, "{branch} holds its base already")?,
}
for path in &updated.added {
let said = format!("both sides only added lines in {path}: the base's come first");
writeln!(out, "{said}")?;
}
}
"hide" | "unhide" => {
let url = args.url()?;
let subject = args.next("<at-uri>")?;
crates/knotless/src/commit.rs+1−1
@@ -257,7 +257,7 @@ pub fn apply(path: &str, mut text: String, edits: &[Edit]) -> Result<String> {
Ok(text)
}
struct Scratch(PathBuf);
pub(crate) struct Scratch(pub(crate) PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
crates/knotless/src/lib.rs+1−0
@@ -15,6 +15,7 @@ pub mod approvals;
pub mod aturi;
pub mod auth;
pub mod base32;
pub mod branch_update;
pub mod checks;
pub mod cli;
pub mod closing;
crates/knotless-mcp/src/lib.rs+26−1
@@ -1142,6 +1142,13 @@ pub struct RevertPull {
pub reason: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct UpdateBranch {
pub repo: String,
/// The open pull's at:// URI.
pub uri: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Workflows {
/// `handle/name`, `did/name` or `knotless://handle/name`.
@@ -2513,6 +2520,24 @@ impl Server {
)
}
#[tool(
description = "Bring an open pull request's branch up to date with the branch it merges into, as GitHub's Update branch button does: a merge commit of that branch's tip into the pull's branch, pushed there, no checkout needed. Where both sides only added lines at the same place, as two pulls each adding a section to one docs page, it keeps the base's lines first and the pull's after, and names those paths (added). Any other conflict fails, naming the paths, for the author to merge by hand; so does a push to the branch since it was read. The owner and collaborators may, for a pull whose branch is in the repository itself. Answers the branch, the merge commit (none when the branch held the base already) and added."
)]
async fn update_branch(
&self,
Parameters(p): Parameters<UpdateBranch>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let updated =
knotless::branch_update::update_branch(&self.ctx, &url, &p.uri).await?;
Ok(serde_json::to_value(updated)?)
}
.await,
)
}
#[tool(
description = "A repository's merge queues: the pulls queued with merge and queue, in the order they land, each with its place in its branch's queue and, while its batch is being checked or landing, that batch (batch: the commit it would leave the branch at, its pulls, state checking or landing, and its checks); then the open pulls the queue ejected, each with why under queue.reason and the checks that failed under queue.checks. A runner the repository names takes queued pulls a batch at a time, squashes each onto the branch as a commit of its own, runs the checks once at the last, and moves the branch there once they pass, marking each merged; a pull that conflicts, or that breaks the checks (found by halving a failing batch), it ejects. checks with queue shows a batch's runs."
)]
@@ -3492,7 +3517,7 @@ impl Server {
#[tool_handler(
router = self.router(),
name = "knotless",
instructions = "Git repositories stored in atproto accounts. Name a repository as handle/name. Before changing a repository, read its AGENTS.md (or CLAUDE.md) with read_file if it has one: how that repository wants changes made and checked. Read with list_tree, read_file, search, blame, log and diff: no clone needed. Change files with commit: no checkout needed. Collaborate with fork, template, open_pull, open_issue, list_items, show_item, comment, set_state, label_item (see list_labels and define_label), assign_item, dismiss_review and merge (with queue, into the merge queue: see merge_queue and leave_queue), and undo a merged pull with revert; change what you wrote with edit_item, and take a comment back with delete_comment. Workflows are YAML files under .knotless/workflows/, run by the runners a repository names (see runners) on the commit a push or a pull brings: `on: {push: {refs: [refs/heads/*]}, pull: {base: [refs/heads/main], paths: [web/**]}}` (a file with no `on` runs on every push to a branch; `paths`, globs, runs it only on a change touching one, and passes it as skipped otherwise), `image` (the one container all the steps run in, in turn, so a step sees what earlier ones installed), `env`, `secrets` (names; never given to a pull), and `steps: [{name, run, timeout}]`, each `run` a shell script, stopping at the first that fails. After a push, checks says whether they passed, workflows which a ref has and what runs each, and rerun asks for them again, or, as owner, for one of a pull's workflows by name, or one at a ref, with variables: one with `on: {asked: {refs: [refs/heads/main]}}` runs only so. Moderate your repositories with hide_item and block_account (see list_blocks), and report with report. React to pulls, issues and comments with react. Star repositories with star, and see an account's with list_stars; follow accounts with follow, and see whom an account follows, or who follows it, with list_follows. Make, choose and delete themes for knotless's pages with theme, and find others' with list_themes. See what others did that concerns you with notifications, choose which you hear of with notification_settings, and watch a repository, or subscribe to or mute a pull or an issue, with subscribe. Set how you show on knotless with set_profile, and which email addresses show commits as yours with set_email. Publish a version of a package from a repository with publish, see its versions with releases, and read a file of one with read_release. Make your account an organization, or run one you are an admin of, with org, once you have delegated to a gateway with delegate. An error naming a `knotless` command names a tool here: `knotless delegate` is delegate, `knotless org join` is org with join, and so on."
instructions = "Git repositories stored in atproto accounts. Name a repository as handle/name. Before changing a repository, read its AGENTS.md (or CLAUDE.md) with read_file if it has one: how that repository wants changes made and checked. Read with list_tree, read_file, search, blame, log and diff: no clone needed. Change files with commit: no checkout needed. Collaborate with fork, template, open_pull, open_issue, list_items, show_item, comment, set_state, label_item (see list_labels and define_label), assign_item, dismiss_review and merge (with queue, into the merge queue: see merge_queue and leave_queue), bring a pull's branch up to date with its base with update_branch, and undo a merged pull with revert; change what you wrote with edit_item, and take a comment back with delete_comment. Workflows are YAML files under .knotless/workflows/, run by the runners a repository names (see runners) on the commit a push or a pull brings: `on: {push: {refs: [refs/heads/*]}, pull: {base: [refs/heads/main], paths: [web/**]}}` (a file with no `on` runs on every push to a branch; `paths`, globs, runs it only on a change touching one, and passes it as skipped otherwise), `image` (the one container all the steps run in, in turn, so a step sees what earlier ones installed), `env`, `secrets` (names; never given to a pull), and `steps: [{name, run, timeout}]`, each `run` a shell script, stopping at the first that fails. After a push, checks says whether they passed, workflows which a ref has and what runs each, and rerun asks for them again, or, as owner, for one of a pull's workflows by name, or one at a ref, with variables: one with `on: {asked: {refs: [refs/heads/main]}}` runs only so. Moderate your repositories with hide_item and block_account (see list_blocks), and report with report. React to pulls, issues and comments with react. Star repositories with star, and see an account's with list_stars; follow accounts with follow, and see whom an account follows, or who follows it, with list_follows. Make, choose and delete themes for knotless's pages with theme, and find others' with list_themes. See what others did that concerns you with notifications, choose which you hear of with notification_settings, and watch a repository, or subscribe to or mute a pull or an issue, with subscribe. Set how you show on knotless with set_profile, and which email addresses show commits as yours with set_email. Publish a version of a package from a repository with publish, see its versions with releases, and read a file of one with read_release. Make your account an organization, or run one you are an admin of, with org, once you have delegated to a gateway with delegate. An error naming a `knotless` command names a tool here: `knotless delegate` is delegate, `knotless org join` is org with join, and so on."
)]
impl ServerHandler for Server {
async fn call_tool(
crates/knotless-mcp/tests/mcp.rs+20−0
@@ -1351,6 +1351,26 @@ fn agents_fork_review_and_merge() {
assert_eq!(compared["conflicts"], json!(["a"]));
let (text, err) = o.call("merge", json!({"repo": repo, "uri": clash}));
assert!(err && text.contains("conflicts in a"), "{text}");
// Its branch is in the fork: the fork's owner brings it up to date.
let (text, err) = o.call("update_branch", json!({"repo": repo, "uri": clash}));
assert!(err && text.contains("in another repository"), "{text}");
// Merged, a pull's branch is not brought up to date.
let (text, err) = o.call("update_branch", json!({"repo": repo, "uri": pull}));
assert!(err && text.contains("only an open pull"), "{text}");
// One here is, the lines both sides added at one place kept, main's first.
o.json("create_branch", json!({"repo": repo, "branch": "notes"}));
o.json("commit", json!({"repo": repo, "branch": "notes", "message": "n", "changes": [{"path": "a", "content": "owner\nnotes\n"}]}));
o.json("commit", json!({"repo": repo, "branch": "main", "message": "m", "changes": [{"path": "a", "content": "owner\nmain\n"}]}));
let notes = o.json("open_pull", json!({"repo": repo, "base": "main", "head_repo": repo, "head_branch": "notes", "title": "Notes"}));
let notes = notes["uri"].as_str().unwrap().to_string();
let updated = o.json("update_branch", json!({"repo": repo, "uri": notes}));
assert_eq!(updated["branch"], "notes");
assert_eq!(updated["added"], json!(["a"]));
let read = o.ok(
"read_file",
json!({"repo": repo, "path": "a", "rev": "notes"}),
);
assert_eq!(read, "owner\nmain\nnotes\n");
// One that touches other files merges cleanly though main moved on.
c.json("commit", json!({"repo": fork, "branch": "aside", "message": "c", "changes": [{"path": "c", "content": "c\n"}]}));
let aside = c.json("open_pull", json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "aside", "title": "Aside"}));
crates/knotless-serve/tests/forge.rs+65−0
@@ -1252,6 +1252,71 @@ fn merged_pulls_revert_in_pulls_of_their_own() {
);
}
/// A pull's branch brought up to date with its base, as GitHub's Update
/// branch button does: the base merged into it and pushed there, by the
/// owner or a collaborator. Lines both sides only added at the same place
/// are kept, the base's first; any other conflict leaves it as it was.
#[test]
fn pull_branches_update_from_their_base() {
let av = appview();
let owner = person(account(), &av);
let stranger = person(account(), &av);
let url = owner.url("updated");
let work = owner.work("w", 1);
owner.commit_file(&work, "notes", "one\n");
owner.git(&work, &["push", "-q", &url, "main"]);
let refused = |who: &Sandbox, why: &str, pull: &str| {
let (done, _, err) = who.knotless(&["update-branch", &url, pull], b"");
assert!(!done && err.contains(why), "{err}");
};
// `notes` as `branch` holds it, pushed there.
let write = |branch: &str, notes: &str| {
owner.git(&work, &["fetch", "-q", &url, branch]);
owner.git(&work, &["checkout", "-q", "-B", branch, "FETCH_HEAD"]);
owner.commit_file(&work, "notes", notes);
owner.git(&work, &["push", "-q", &url, branch]);
};
owner.git(&work, &["push", "-q", &url, "main:notes"]);
write("notes", "one\nmine\n");
let pull = ok(&owner, &["pull", &url, "main", &url, "notes", "Notes"]);
listed(&owner, &url, &pull);
// Holding its base already, it is left as it is.
let said = ok(&owner, &["update-branch", &url, &pull]);
assert_eq!(said, "notes holds its base already");
// Main added a line where the pull did: both stay, main's first.
write("main", "one\nmain\n");
refused(&stranger, "only the owner", &pull);
let said = ok(&owner, &["update-branch", &url, &pull]);
let merge = said.lines().next().unwrap();
let merge = merge.strip_prefix("merged its base into notes: ").unwrap();
assert!(
said.ends_with("both sides only added lines in notes: the base's come first"),
"{said}"
);
owner.git(&work, &["fetch", "-q", &url, "notes"]);
assert_eq!(owner.git(&work, &["rev-parse", "FETCH_HEAD"]), merge);
assert_eq!(
owner.git(&work, &["show", "FETCH_HEAD:notes"]),
"one\nmain\nmine"
);
let subject = owner.git(&work, &["log", "-1", "--format=%s", "FETCH_HEAD"]);
assert_eq!(subject, "Merge main into notes");
let parents = owner.git(&work, &["log", "-1", "--format=%P", "FETCH_HEAD"]);
assert_eq!(parents.split(' ').count(), 2, "{parents}");
// Both changing the same line, it is the author's to merge.
write("main", "uno\nmain\n");
write("notes", "one!\nmain\nmine\n");
refused(&owner, "same lines in notes: merge by hand", &pull);
owner.git(&work, &["fetch", "-q", &url, "notes"]);
assert_eq!(
owner.git(&work, &["show", "FETCH_HEAD:notes"]),
"one!\nmain\nmine"
);
}
#[test]
fn issues_and_who_may_close_them() {
let av = appview();
docs/design/collaboration.md+26−0
@@ -620,6 +620,32 @@ Details:
the pull's base nor the default branch, is not protected, and no other
open pull targets or brings it in. A fork's branch is never spent. `--keep-branch` keeps it.
## Updating a pull's branch
A pull cut from an older base can conflict with what landed since, and
a pull that conflicts is turned away by a merge and by the merge queue.
`knotless update-branch` and the MCP `update_branch` tool bring its branch
up to date, as GitHub's Update branch button does: a merge commit of the
base's tip into the branch, built in the target's mirror with no
checkout, and pushed to the branch. The owner and collaborators may, for
a pull whose branch is in the repository itself; a fork's branch is its
owner's to update.
Two pulls that each add a section to the end of one docs page conflict
in git, though neither side's lines are in doubt. So where every
conflict in a file is lines both sides added at the same place, with
none of the common ancestor's between them, the base's lines are kept
first, as they landed first, and the pull's after. The merge commit's
message names those files. Any other conflict, such as both sides
changing the same line, one side deleting the file, a binary file or a
change of mode, is the author's: nothing is pushed, and the error names
the paths.
The push is not forced, so a push to the branch after it was read fails
the update rather than losing that push. A pull that holds its base
already is left as it is. The merge is a new head, so the pull's checks
run again on it.
## Reverting a pull
`knotless revert`, the MCP `revert` tool and a merged pull's Revert button
docs/reference/cli.md+1−0
@@ -158,6 +158,7 @@ moved, edits are made again in the file as it is there.
| `merge <url> <at-uri> --queue` | put a pull in its branch's merge queue, and return, printing `queued into <base>: <n> of <of>`; takes no `--keep-branch`, `--wait` or `--force` |
| `merge <url> <at-uri> --check` | merge nothing: say whether it would merge now, how, and what you may do to it, as a pull's page's merge box shows it |
| `revert <url> <at-uri> [-m <reason>]` | as owner or collaborator, undo a merged pull: one commit on a new branch `revert/<rkey>`, cut from its target branch, and a pull of it into that branch, titled `Revert "<title>"`; prints the new pull's URI. See [reverting a pull](../design/collaboration.md#reverting-a-pull) |
| `update-branch <url> <at-uri>` | as owner or collaborator, merge an open pull's base into its branch, in the repository itself, and push it; where both sides only added lines at the same place, the base's come first. Prints `merged its base into <branch>: <commit>`, then `both sides only added lines in <path>: the base's come first` for each such file, or `<branch> holds its base already`. Refused on any other conflict, naming its paths. See [updating a pull's branch](../design/collaboration.md#updating-a-pulls-branch) |
| `queue <url> [--json]` | the merge queues in order, and what was ejected |
| `queue <url> --leave <at-uri>` | take a pull out of the merge queue |
| `retarget <url> <at-uri> [--base <branch>] [--head <branch>]` | point a pull at other branches; the head only by the pull's author |
docs/reference/mcp.md+1−0
@@ -47,6 +47,7 @@ nothing, as the [review agent](../guide/reviews.md) is.
| `star`, `list_stars` | `star` stars a public repository, or with `starred: false` takes the star back, answering `{starred, uri}`; `list_stars` lists the repositories an `account` starred, yours by default, as `{stars: [{repo, uri, createdAt}]}` |
| `fork`, `open_pull`, `open_issue`, `list_items`, `show_item`, `pull_diff`, `comment`, `set_state`, `merge`, `retarget_pull` | collaborate; `list_items` takes a `query` in GitHub's search words ([collaboration.md](../design/collaboration.md#filtering-pulls-and-issues)), and says of each pull `draft`, `true` or `false`; `show_item` on a pull says what it brings in and whether it merges cleanly, and on an issue gives `mentionedBy`, the pulls whose title or body names it, oldest first, each `{pull, closes}`, `closes` true where it names it after a closing keyword |
| `revert` | as `knotless revert` does: undoes a merged pull (`uri`) in one commit on `revert/<rkey>` and opens a pull of it into the same branch, saying `reason` if given, answering `{pull, branch, commit}` ([reverting a pull](../design/collaboration.md#reverting-a-pull)) |
| `update_branch` | as `knotless update-branch` does: merges an open pull's (`uri`) base into its branch and pushes it, answering `{branch, commit, added}`, `commit` absent when the branch held its base already, and `added` the paths where both sides only added lines and the base's come first ([updating a pull's branch](../design/collaboration.md#updating-a-pulls-branch)) |
| `assign_item` | assign a pull or issue to accounts, or with `review` ask them to review a pull; with `remove`, undo either. As in [collaboration.md](../design/collaboration.md#assignees): the owner and collaborators assign, and ask besides a pull's author, who asks in the pull. Answers `assignees` and `reviewers`, by DID |
| `dismiss_review` | dismiss a reviewer's standing review of a pull, saying why: a comment naming it, so it counts for nothing, for or against, until they review again. As in [collaboration.md](../design/collaboration.md#dismissing-a-review): the owner and collaborators may, on their own pulls too. Answers the comment's `uri` |
| `list_labels`, `define_label`, `label_item`, `triagers`, `eval_triager` | a repository's labels: what it defines, defining one as owner, putting them on pulls and issues as owner, collaborator or triager, naming the triagers, and how an `account`'s labels agree with everyone else's, per scope, as `[{scope, labelled, compared, agreed}]` |