~ 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

Merges always name their pull, and a push that lands a pull merges it

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:forge/merges-name-their-pulls
.changeset/merges-name-their-pulls.mdnew+9−0
@@ -0,0 +1,9 @@
---
default: minor
---
# Merges always name their pull, and a push that lands a pull merges it
`knotless merge` into a branch that is not protected as linear now always makes a merge commit naming the pull, even where it could have fast-forwarded, so the branch's history says which pull each commit came from. Its method is `merge` or `squash`; `fastForward` is no longer made. A pull whose head is already on its base is marked merged without moving the branch.
A `git push` that puts an open pull's head on the branch it targets now marks that pull merged, and closes the issues it names, as `knotless merge` would, saying so on git's output. The pages link each commit to the pull that brought it in, whether it was squashed, merged or pushed.
crates/knotless/src/cli.rs+1−1
@@ -318,7 +318,7 @@ const USAGE: &str = "usage: knotless <command>
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
merge <url> <at-uri> [--keep-branch] [--wait <secs>] [--force]
merge a pull: fast-forward or merge commit,
merge a pull: a merge commit naming it,
or squashed into a protected branch; its
branch here goes, unless kept or still
named by another open pull. With runners
crates/knotless/src/forge.rs+124−14
@@ -2226,8 +2226,7 @@ pub async fn set_state(ctx: &Ctx, target: &RepoUrl, subject: &str, state: &str)
#[derive(Debug, PartialEq)]
pub struct Merged {
pub sha: String,
/// How: `fastForward`, `merge`, or `squash` into a branch protected as
/// linear.
/// How: `merge`, or `squash` into a branch protected as linear.
pub method: &'static str,
pub deleted: Option<String>,
/// Why its head branch was kept, when it was not deleted ([`kept`]).
@@ -2271,9 +2270,10 @@ pub(crate) fn drafted(url: &RepoUrl, pull: &str) -> 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
/// Merges a pull: a merge commit, naming the pull, even where a
/// fast-forward could have been; or, into a branch whose rule is linear,
/// one commit of the whole pull on the base. One whose head the branch has
/// already, pushed there by hand, is marked merged as it stands. Fails on
/// conflicts, and, into a branch whose rule asks for reviews, unless
/// reviewed as it asks ([`approvals`]). In a repository that names runners, only once each workflow
/// that runs on the pull has passed at its head merged with the base as it
@@ -2320,6 +2320,9 @@ pub async fn merge(
let branch = sides.refname.trim_start_matches("refs/heads/");
let squash = t.record.rule(branch).is_some_and(records::Rule::linear);
let fast = git.is_ancestor(base, head).await?;
// Its head on the branch already, pushed there by hand: it is merged
// as it stands, and the branch stays where it is.
let there = git.is_ancestor(head, base).await?;
let tree = match merge_tree(git, base, head).await? {
Ok(tree) => tree,
Err(files) => bail!("merging conflicts in {}", files.join(", ")),
@@ -2342,22 +2345,24 @@ pub async fn merge(
};
checked(ctx, &t, pull, &sides, &at, owner, gate.wait).await?;
}
let merged = if fast && !squash {
let merged = if there {
head.clone()
} else if squash {
squash_commit(git, &tree, base, head, &item, &me).await?
} else {
merge_commit(git, &tree, base, head, &item, &me).await?
};
let updates = [Update {
src: Some(merged.clone()),
dst: sides.refname.clone(),
force: false,
}];
let others = assemble(&t.record, t.remote.did(), &entries);
let spent = (!keep).then(|| spent(t.facts(), &item, &others)).flatten();
let kept = kept(&t, &item, &others, keep).filter(|_| spent.is_none());
push_as(ctx, &t, git, &me, &updates).await?;
if !there {
let to = [Update {
src: Some(merged.clone()),
dst: sides.refname.clone(),
force: false,
}];
push_as(ctx, &t, git, &me, &to).await?;
}
// The target has moved: a delete that is refused leaves the branch,
// not the pull open.
let deleted = match spent {
@@ -2391,10 +2396,9 @@ pub async fn merge(
writes.extend(closing::writes(&t, &closed, pull));
let (mine, me) = t.mine(ctx).await?;
mine.apply(writes, &me.auth()).await?;
let methods = [["merge", "fastForward"], ["squash", "squash"]];
Ok(Merged {
sha: merged,
method: methods[usize::from(squash)][usize::from(fast)],
method: if squash && !there { "squash" } else { "merge" },
deleted,
kept,
closed,
@@ -2967,6 +2971,112 @@ pub(crate) async fn push_as(
accepted(report)
}
/// After a push to `remote` that landed, marks merged each open pull into a
/// branch it moved whose head that branch now has and did not before: a
/// pull fast-forwarded by hand, or a stack of them pushed at once. `before`
/// is the repository as the push read it, `git` where it pushed from.
/// Returns the pulls marked, each with the branch it landed on. A pull
/// whose head commit `git` does not have is left as it is.
pub async fn landed(
ctx: &Ctx,
remote: &Remote,
before: &Snapshot,
git: &Git,
updates: &[Update],
report: &push::Report,
) -> Result<Vec<(String, String)>> {
let mut moved = BTreeMap::new();
for update in updates {
let ok = report.iter().any(|(n, r)| *n == update.dst && r.is_ok());
let branch = update.dst.strip_prefix("refs/heads/");
if let (true, Some(branch), Some(src)) = (ok, branch, &update.src) {
moved.insert(branch.to_string(), git.rev_parse(src).await?);
}
}
// Pulls on a public repository are read from an appview: with none,
// there are none to mark.
let unread = ctx.env.appview.is_none() && remote.space.is_none();
if moved.is_empty() || unread || before.repo.is_none() {
return Ok(Vec::new());
}
let t = Target::read(ctx, remote.clone(), before.clone()).await?;
let items = assemble(&t.record, t.remote.did(), &t.entries(ctx).await?);
let mut found = Vec::new();
for item in items
.iter()
.filter(|i| i.kind == "pull" && i.state == "open")
{
let Some((branch, to)) = item.base.as_ref().and_then(|b| moved.get_key_value(b)) else {
continue;
};
let refname = format!("refs/heads/{branch}");
let from = before
.find_ref(&refname)
.and_then(|r| r.value.target.clone());
let head = head_now(ctx, &t, &moved, item).await?;
if let Some(head) = head
&& brought(git, &head, from.as_deref().unwrap_or_default(), to).await?
{
found.push((item, branch, head, from));
}
}
if found.is_empty() {
return Ok(Vec::new());
}
let (mine, me) = t.mine(ctx).await?;
let mut writes = Vec::new();
let mut marked = Vec::new();
for (item, branch, head, from) in found {
let record = StateRecord {
repo: t.reference(),
subject: item.uri.clone(),
state: Some("merged".into()),
commit: Some(head),
from,
batch: None,
base: None,
pull: None,
created_at: records::now(),
};
let value = records::typed(nsid::STATE, &record);
writes.push(remote::create(nsid::STATE, &keys::tid(), value));
let owner = t.remote.did();
let closed = closing::to_close(&t.record, owner, &items, item, branch, &me.did);
writes.extend(closing::writes(&t, &closed, &item.uri));
marked.push((item.uri.clone(), branch.clone()));
}
mine.apply(writes, &me.auth()).await?;
Ok(marked)
}
/// The commit `item`'s head branch is at once the push in `moved` lands:
/// as it pushed, as `t` read before it, or as its fork has it.
async fn head_now(
ctx: &Ctx,
t: &Target,
moved: &BTreeMap<String, String>,
item: &Item,
) -> Result<Option<String>> {
let tip = |snap: &Snapshot, branch: &str| {
let found = snap.find_ref(&format!("refs/heads/{branch}"));
found.and_then(|r| r.value.target.clone())
};
if let Some(branch) = head_here(t.facts(), item) {
return Ok(moved.get(branch).cloned().or_else(|| tip(&t.snap, branch)));
}
let head = item.head.as_ref().expect("a pull has a head");
let source = Target::open(ctx, &repo_url(&head.repo.uri)?).await?;
Ok(tip(&source.snap, &head.branch))
}
/// Whether a branch moved from `from` (empty for one that was not there)
/// to `to` brought in `head`: it has it now and did not before.
async fn brought(git: &Git, head: &str, from: &str, to: &str) -> Result<bool> {
let has = git.has(head).await? && git.is_ancestor(head, to).await?;
let had = has && !from.is_empty() && git.has(from).await?;
Ok(has && !(had && git.is_ancestor(head, from).await?))
}
/// Fails on the first ref the push refused.
fn accepted(report: push::Report) -> Result<()> {
match report.into_iter().find(|(_, r)| r.is_err()) {
crates/knotless/src/helper.rs+18−0
@@ -5,6 +5,7 @@ use crate::compact;
use crate::emails;
use crate::env::{CompactMode, Ctx};
use crate::fetch;
use crate::forge;
use crate::gateway;
use crate::git::Git;
use crate::push::{self, Report, Update};
@@ -213,6 +214,21 @@ fn said(done: Result<()>) {
}
impl Knotless {
/// After a push that landed, marks merged the open pulls whose heads
/// it brought into the branches they target, and says so. The push has
/// landed either way, so what fails is said and left.
async fn landed(&self, before: &Snapshot, updates: &[Update], report: &Report) {
let found = forge::landed(&self.ctx, &self.remote, before, &self.git, updates, report);
match found.await {
Ok(marked) => {
for (pull, branch) in marked {
eprintln!("knotless: {pull} merged, as {branch} now has its head");
}
}
Err(err) => eprintln!("knotless: pulls this push merged are not marked so: {err:#}"),
}
}
/// After a push that landed, claims the address git authors commits as here, the
/// first time `me` pushes from this machine, and says so. The push has
/// landed either way, so a claim that fails is left for the next.
@@ -273,6 +289,7 @@ impl Backend for Knotless {
let report =
gateway::push(&self.ctx, &self.remote, &snap, &self.git, &me, &updates).await?;
self.claim_email(&me, &report).await;
self.landed(&snap, &updates, &report).await;
return Ok(report);
}
let auth = session::ensure(&self.ctx, &self.remote.identity).await?;
@@ -290,6 +307,7 @@ impl Backend for Knotless {
self.after_push(&snap, &auth).await?;
}
self.claim_email(&auth, &report).await;
self.landed(&snap, &updates, &report).await;
Ok(report)
}
}
crates/knotless/src/landing.rs+6−8
@@ -36,8 +36,8 @@ pub struct Mergeable {
/// no runners or has no workflow a pull into its base runs, so a merge
/// waits for none, or the pull conflicts.
pub checks: &'static str,
/// `fastForward`, `merge`, or `squash` into a branch protected as
/// linear.
/// `merge`, or `squash` into a branch protected as linear.
/// (`fastForward`, from older servers, is no longer made.)
pub method: &'static str,
/// Where its reviews stand against what its base's rule asks of them:
/// `passed` or `blocked`; `none` when the rule asks nothing of them,
@@ -190,12 +190,10 @@ pub async fn gauge(
let pull = item.uri.as_str();
let (git, base, head) = (&sides.git, &sides.base, &sides.head);
let branch = sides.refname.trim_start_matches("refs/heads/");
let fast = git.is_ancestor(base, head).await?;
let squash = t.record.rule(branch).is_some_and(Rule::linear);
// Into a linear branch, a squash; else a fast-forward, when the base
// has not moved since the head branched off it.
let methods = [["merge", "fastForward"], ["squash", "squash"]];
let method = methods[usize::from(squash)][usize::from(fast)];
// Into a linear branch, a squash; else a merge commit, as one names
// the pull it merged even where a fast-forward could have been.
let method = if squash { "squash" } else { "merge" };
let mut found = Mergeable {
state: "clean",
reason: None,
@@ -241,7 +239,7 @@ pub async fn gauge(
if t.record.runners.is_empty() {
return Ok(none(found));
}
let at = match fast {
let at = match git.is_ancestor(base, head).await? {
true => head.clone(),
false => forge::checked_merge(git, &tree, base, head).await?,
};
crates/knotless/tests/git_flows.rs+24−0
@@ -331,3 +331,27 @@ fn tags_only_repository() {
s.git(&work, &["rev-parse", "HEAD"])
);
}
#[test]
fn a_push_lands_though_its_pulls_cannot_be_read() {
let mut s = Sandbox::new();
let url = s.url("unread");
let work = s.work("work", 1);
s.git(&work, &["push", "-q", &url, "main"]);
// An appview that is not there: the push lands, and says what it
// could not do after.
s.set("KNOTLESS_APPVIEW", "http://127.0.0.1:9");
let tip = s.commit_file(&work, "f", "again\n");
let (pushed, _, said) = s.git_raw(&work, &["push", &url, "main"]);
assert!(pushed, "{said}");
assert!(
said.contains("pulls this push merged are not marked so"),
"{said}"
);
assert_eq!(
s.git(&work, &["ls-remote", &url, "main"])
.split('\t')
.next(),
Some(tip.as_str())
);
}
crates/knotless-mcp/src/lib.rs+1−1
@@ -2451,7 +2451,7 @@ 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. In a repository that names runners, only once every workflow that runs on the pull has passed at its head merged with the target branch as it is now: it refuses, naming what failed, what is still running, or the older head or base its checks are of, and checks with pull and log shows why. With wait, it waits up to that many seconds for checks running or not yet run, and the owner's merge asks the runners once to run a head and base none has; force, the owner's alone, merges whatever they say. Into a branch protected with queue (list_rules), it queues the pull instead, as queue does, and answers queued with why, unless the owner forces it. 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. Into the repository's default branch, it closes each open issue on it that the pull's title or body names right after fixes, closes or resolves (or fix, fixed, close, closed, resolve, resolved), by its page URL (https://<appview>/<owner>/<repo>/issue/<did>/<rkey>) or at:// URI, returning them as closed; the merge queue does the same as it lands. With queue, it puts the pull in its branch's merge queue instead and returns at once with its place (queued: position of of), and, queued again at the very head the queue last ejected, why it was (queued.ejectedBefore), as it will be again unless that was not the pull's doing: the quickest way to land, as a runner checks queued pulls together a batch at a time, and there is no need to wait. Queue a pull once it builds, though: one that does not fails its whole batch, and every pull in it waits while the queue halves the batch to find it. Merge the target branch into the pull's branch just before queueing: one that conflicts with it is refused. Ejected, read why with show_item and the failed checks' logs with checks (pull and log), fix it, and queue it again; queue the same head again only when the log shows the failure is not the pull's, such as a test it does not touch that fails other pulls too. One that conflicts only with a pull ahead of it waits for that pull to land or leave, and is ejected then if it conflicts with the branch: merge the branch into it. Ejected again for the same reason, stop and say so rather than queue it once more. merge_queue shows where it is, and show_item whether it merged, or where it stands (standing.said: Queued, 3rd of 5, running its checks, ready for the next batch or waiting for its review, from a gate its branch names; In a batch of 4 being checked; Landing; or why it was ejected and which checks failed, also under item.queue.reason and item.queue.checks). Into a branch protected with reviews, it refuses until the pull is reviewed as the rule asks (see protect_branch), naming what is missing. With check, it merges nothing and says whether it would now: state clean, conflicting (conflicts, the paths) or blocked by its checks (checks: passed, failed, running, pending, or none when the repository names no runners) or its reviews (reviews: passed, blocked, or none when its branch asks for none; reviewsMissing, what they lack, as reviews 1 of 2, owners of web/: @alice or @bob, or a review of its head from @bot; reviewsUncounted, the approvals that do not count and why; codeOwnersWarnings, the CODEOWNERS lines skipped and why); reason, why not, in the words a merge would refuse with, and that you may not merge it when you may not; method (fastForward, merge or squash); deletes, the branch it would delete; and may, what your part in the repository lets you do to it (merge, force, queue, base and head as retarget_pull changes them, rerun): not whether it would work now, which state and reason say. A blocked pull's merge is refused, the owner's too, unless the owner forces it. queueOnly, when its branch takes pulls only through the merge queue. Merged, it answers the commit its branch is at now (sha), how (method: fastForward, merge or squash), the branch it deleted (deleted) or why it kept the head branch (kept: asked to, in another repository such as a fork, the branch it merges into or the default branch, or named by another open pull), and the issues it closed."
description = "Merge an open pull request: a merge commit naming the pull, even where a fast-forward could have been; into a branch protected as linear, one squashed commit. One whose head is already on the branch, pushed there by hand, is marked merged as it stands. Fails on conflicts. In a repository that names runners, only once every workflow that runs on the pull has passed at its head merged with the target branch as it is now: it refuses, naming what failed, what is still running, or the older head or base its checks are of, and checks with pull and log shows why. With wait, it waits up to that many seconds for checks running or not yet run, and the owner's merge asks the runners once to run a head and base none has; force, the owner's alone, merges whatever they say. Into a branch protected with queue (list_rules), it queues the pull instead, as queue does, and answers queued with why, unless the owner forces it. 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. Into the repository's default branch, it closes each open issue on it that the pull's title or body names right after fixes, closes or resolves (or fix, fixed, close, closed, resolve, resolved), by its page URL (https://<appview>/<owner>/<repo>/issue/<did>/<rkey>) or at:// URI, returning them as closed; the merge queue does the same as it lands. With queue, it puts the pull in its branch's merge queue instead and returns at once with its place (queued: position of of), and, queued again at the very head the queue last ejected, why it was (queued.ejectedBefore), as it will be again unless that was not the pull's doing: the quickest way to land, as a runner checks queued pulls together a batch at a time, and there is no need to wait. Queue a pull once it builds, though: one that does not fails its whole batch, and every pull in it waits while the queue halves the batch to find it. Merge the target branch into the pull's branch just before queueing: one that conflicts with it is refused. Ejected, read why with show_item and the failed checks' logs with checks (pull and log), fix it, and queue it again; queue the same head again only when the log shows the failure is not the pull's, such as a test it does not touch that fails other pulls too. One that conflicts only with a pull ahead of it waits for that pull to land or leave, and is ejected then if it conflicts with the branch: merge the branch into it. Ejected again for the same reason, stop and say so rather than queue it once more. merge_queue shows where it is, and show_item whether it merged, or where it stands (standing.said: Queued, 3rd of 5, running its checks, ready for the next batch or waiting for its review, from a gate its branch names; In a batch of 4 being checked; Landing; or why it was ejected and which checks failed, also under item.queue.reason and item.queue.checks). Into a branch protected with reviews, it refuses until the pull is reviewed as the rule asks (see protect_branch), naming what is missing. With check, it merges nothing and says whether it would now: state clean, conflicting (conflicts, the paths) or blocked by its checks (checks: passed, failed, running, pending, or none when the repository names no runners) or its reviews (reviews: passed, blocked, or none when its branch asks for none; reviewsMissing, what they lack, as reviews 1 of 2, owners of web/: @alice or @bob, or a review of its head from @bot; reviewsUncounted, the approvals that do not count and why; codeOwnersWarnings, the CODEOWNERS lines skipped and why); reason, why not, in the words a merge would refuse with, and that you may not merge it when you may not; method (merge or squash); deletes, the branch it would delete; and may, what your part in the repository lets you do to it (merge, force, queue, base and head as retarget_pull changes them, rerun): not whether it would work now, which state and reason say. A blocked pull's merge is refused, the owner's too, unless the owner forces it. queueOnly, when its branch takes pulls only through the merge queue. Merged, it answers the commit its branch is at now (sha), how (method: merge or squash), the branch it deleted (deleted) or why it kept the head branch (kept: asked to, in another repository such as a fork, the branch it merges into or the default branch, or named by another open pull), and the issues it closed."
)]
async fn merge(
&self,
crates/knotless-mcp/tests/mcp.rs+5−4
@@ -1298,8 +1298,8 @@ fn agents_fork_review_and_merge() {
// Merged, it says how, and why its branch stays: it is in the fork.
let merged = o.json("merge", json!({"repo": repo, "uri": pull}));
assert_eq!(merged["sha"], change["sha"]);
assert_eq!(merged["method"], "fastForward");
assert_ne!(merged["sha"], change["sha"]);
assert_eq!(merged["method"], "merge");
let kept = merged["kept"].as_str().unwrap();
assert!(kept.starts_with("main is in another repository"), "{kept}");
wait_until(|| listed("merged").len() == 1);
@@ -1311,8 +1311,9 @@ fn agents_fork_review_and_merge() {
let shown = o.json("show_item", json!({"repo": repo, "uri": pull}));
let compared = &shown["compare"];
assert_eq!(compared["mergeable"], "merged");
assert_eq!(compared["head"], change["sha"]);
assert_eq!(compared["commits"].as_array().unwrap().len(), 1);
// The merge commit, and the commit it brought in.
assert_eq!(compared["head"], merged["sha"]);
assert_eq!(compared["commits"].as_array().unwrap().len(), 2);
// Merged, it reverts in a pull of its own, on a branch of its own.
let reverted = o.json(
"revert",
crates/knotless-serve/src/web.rs+39−11
@@ -1156,17 +1156,28 @@ fn brought_in(dir: &std::path::Path, ranges: Vec<Option<Range>>) -> Vec<Option<H
/// Whose claims `repo`'s pages believe, and where: its owner, its
/// collaborators and the members of the organization its owner is who may
/// push to it first, so no pull's author can take their addresses.
async fn vouched(app: &App, repo: &Repo, record: RepoRecord, items: &[Item]) -> Vec<Vouched> {
let merged = items.iter().filter_map(|i| {
let range = brought(i.merged.as_deref()?, i.merged_from.as_deref());
Some((i.author.clone(), range))
});
/// push to it first, so no pull's author can take their addresses. And
/// the merged pull that brought each commit in, from what its merge record
/// says it brought: a fast-forward's commits, a merge's and a squash's.
async fn vouched(
app: &App,
repo: &Repo,
record: RepoRecord,
items: &[Item],
) -> (Vec<Vouched>, HashMap<String, ItemViewBasic>) {
let merged: Vec<(&Item, Range)> = items
.iter()
.filter_map(|i| Some((i, brought(i.merged.as_deref()?, i.merged_from.as_deref()))))
.collect();
let writers: Vec<String> = record.writers().map(String::from).collect();
let everywhere = std::iter::once(repo.did.clone()).chain(writers);
let people: Vec<(String, Option<Range>)> = everywhere
.map(|did| (did, None))
.chain(merged.map(|(did, range)| (did, Some(range))))
.chain(
merged
.iter()
.map(|(i, r)| (i.author.clone(), Some(r.clone()))),
)
.collect();
// Each account's claims are read once, however many of its pulls
// merged: one page asks its PDS once, not once a pull.
@@ -1181,13 +1192,30 @@ async fn vouched(app: &App, repo: &Repo, record: RepoRecord, items: &[Item]) ->
let dir = repo.git.dir.clone();
let found = tokio::task::spawn_blocking(move || brought_in(&dir, ranges));
let found = found.await.expect("a walk does not panic");
dids.into_iter()
// A commit two merges brought in, as a stack pushed at once brings
// each pull its own and those under it, is the one bringing least's.
let ranges = found[found.len() - merged.len()..].iter().flatten();
let mut brought: Vec<_> = merged.iter().zip(ranges).collect();
brought.sort_by_key(|(_, shas)| shas.len());
let mut pulls = HashMap::new();
for ((item, _), shas) in brought {
let view = ItemViewBasic {
uri: item.uri.clone(),
title: item.title.clone(),
};
for sha in shas {
pulls.entry(sha.clone()).or_insert_with(|| view.clone());
}
}
let people = dids
.into_iter()
.zip(found)
.map(|(did, only)| {
let hashes = claimed.get(&did).cloned().unwrap_or_default();
Vouched { did, hashes, only }
})
.collect()
.collect();
(people, pulls)
}
/// `commits`, as the pages show them, each with the accounts that wrote it
@@ -1196,12 +1224,12 @@ async fn shown(app: &App, repo: &Repo, commits: Vec<read::Commit>) -> Vec<Commit
let record = repo.record(app).unwrap_or_default();
let made = assembled(app, repo).await.unwrap_or_default();
let items = &made.items;
let people = vouched(app, repo, record, items).await;
let (people, pulls) = vouched(app, repo, record, items).await;
let mut out = Vec::new();
for c in commits {
let (pull, message) = match from_pull(items, &c.message) {
Some((pull, message)) => (Some(pull), message),
None => (None, c.message),
None => (pulls.get(&c.sha).cloned(), c.message),
};
let mut helped = Vec::new();
for (name, email) in coauthors(&message) {
crates/knotless-serve/tests/gateway/private_flow.rs+5−1
@@ -166,7 +166,11 @@ fn private_repositories_made_and_shared_from_the_pages() {
let one = json!({"repo": repo, "pull": pull});
assert_eq!(write("mergePull", them, one.clone()).0, 400);
let (code, _, merged) = write("mergePull", me, one);
assert_eq!((code, &merged["commit"]), (200, &json!(head)), "{merged}");
assert_eq!(code, 200, "{merged}");
assert!(
merged["commit"].is_string() && merged["commit"] != json!(head),
"{merged}"
);
// Unshared, they read it no more.
let unshare = json!({"repo": repo, "with": member.account.did});
crates/knotless-serve/tests/forge.rs+125−16
crates/knotless-serve/tests/gateway.rs+9−4
crates/knotless-serve/tests/web.rs+2−1
docs/design/collaboration.md+20−6
docs/design/runner.md+3−2
docs/reference/api.md+2−1
docs/reference/cli.md+1−1
docs/reference/mcp.md+1−1
lexicons/dev/knotless/defs.json+1−1
lexicons/dev/knotless/getMergeStatus.json+1−1