chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
Pulls based on a pull's branch move when it lands
.changeset/stacked-pulls-move.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# Pulls based on a pull's branch move when it lands | ||
| ||
When a pull lands and its branch goes, open pulls based on that branch now target what it landed on, and show only their own changes. Before, they stayed on the old branch, which was kept for them. | ||
crates/knotless/src/closing.rs+1−0
| @@ -190,6 +190,7 @@ pub fn writes(t: &Target, issues: &[String], pull: &str) -> Vec<Value> { | ||
from: None, | ||
batch: None, | ||
base: None, | ||
since: None, | ||
pull: Some(pull.to_string()), | ||
created_at: records::now(), | ||
}; | ||
crates/knotless/src/forge.rs+106−13
| @@ -121,6 +121,10 @@ pub struct Item { | ||
pub merged: Option<String>, | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
pub merged_from: Option<String>, | ||
/// For a pull moved onto this base as the pull below it landed, that | ||
/// pull's head: where this one's own changes start ([`since_of`]). | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
pub since: Option<String>, | ||
/// For a pull the merge queue landed, the tip of its batch: the | ||
/// commit whose checks let it land. | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
| @@ -384,7 +388,8 @@ pub fn allowed( | ||
state: &str, | ||
at: &str, | ||
) -> bool { | ||
let lands = repo.is_runner(actor) && matches!(state, "merged" | "closed"); | ||
// A runner's record with no state moves a pull whose base it landed. | ||
let lands = repo.is_runner(actor) && matches!(state, "merged" | "closed" | ""); | ||
maintains(repo, owner, actor, at) || lands || (actor == item_author && state != "merged") | ||
} | ||
| ||
| @@ -435,6 +440,7 @@ pub fn assemble(repo: &RepoRecord, owner: &str, entries: &[Entry]) -> Vec<Item> | ||
state: "open".into(), | ||
merged: None, | ||
merged_from: None, | ||
since: None, | ||
merged_batch: None, | ||
closed_by: None, | ||
reverts, | ||
| @@ -491,6 +497,7 @@ pub fn assemble(repo: &RepoRecord, owner: &str, entries: &[Entry]) -> Vec<Item> | ||
} | ||
if let (Some(base), Some(_)) = (&s.base, &item.base) { | ||
item.base = Some(base.clone()); | ||
item.since = s.since.clone(); | ||
} | ||
} | ||
} | ||
| @@ -1802,6 +1809,7 @@ pub async fn resolve(ctx: &Ctx, target: &RepoUrl, thread: &str, resolved: bool) | ||
from: None, | ||
batch: None, | ||
base: None, | ||
since: None, | ||
pull: None, | ||
created_at: records::now(), | ||
}; | ||
| @@ -2211,6 +2219,7 @@ pub async fn set_state(ctx: &Ctx, target: &RepoUrl, subject: &str, state: &str) | ||
from: None, | ||
batch: None, | ||
base: None, | ||
since: None, | ||
pull: None, | ||
created_at: records::now(), | ||
}; | ||
| @@ -2320,7 +2329,8 @@ pub async fn merge( | ||
// 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? { | ||
let since = since_of(git, &item, base, head).await?; | ||
let tree = match merge_tree_from(git, since.as_deref(), base, head).await? { | ||
Ok(tree) => tree, | ||
Err(files) => bail!("merging conflicts in {}", files.join(", ")), | ||
}; | ||
| @@ -2384,11 +2394,15 @@ pub async fn merge( | ||
from: Some(base.clone()), | ||
batch: None, | ||
base: None, | ||
since: None, | ||
pull: None, | ||
created_at: records::now(), | ||
}; | ||
let value = records::typed(nsid::STATE, &record); | ||
let mut writes = vec![remote::create(nsid::STATE, &keys::tid(), value)]; | ||
if let Some(gone) = &deleted { | ||
writes.extend(moved_on(&t, &item, &others, gone, head)); | ||
} | ||
let closed = closing::to_close(&t.record, t.remote.did(), &others, &item, branch, &me.did); | ||
writes.extend(closing::writes(&t, &closed, pull)); | ||
let (mine, me) = t.mine(ctx).await?; | ||
| @@ -2508,25 +2522,84 @@ pub(crate) fn refusal( | ||
| ||
/// 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, is not protected (a gateway never deletes one), and no other open pull brings it in or targets it. | ||
/// default one, is not protected (a gateway never deletes one), and no | ||
/// other open pull brings it in. Those that target it move to its target | ||
/// ([`moved_on`]). | ||
pub(crate) fn spent(t: Facts, item: &Item, items: &[Item]) -> Option<String> { | ||
let head = item.head.as_ref()?; | ||
let here = t.key; | ||
let mine = head_here(t, item).is_some(); | ||
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) | ||
other.uri != item.uri && other.state == "open" && from | ||
}; | ||
let base = item.base.as_deref() == Some(&head.branch) || head.branch == default; | ||
let protected = t.record.rule(&head.branch).is_some(); | ||
(mine && !base && !protected && !items.iter().any(named)).then(|| head.branch.clone()) | ||
} | ||
| ||
/// The records that move the open pulls based on `branch`, `item`'s head | ||
/// branch, onto its base, as that branch goes, each starting from `head`, | ||
/// the commit it was at: GitHub's retargeting, and the start keeps | ||
/// a squash's original commits out of the diff of the pull above it. | ||
pub(crate) fn moved_on( | ||
t: &Target, | ||
item: &Item, | ||
items: &[Item], | ||
branch: &str, | ||
head: &str, | ||
) -> Vec<Value> { | ||
let above = |o: &&Item| o.state == "open" && o.base.as_deref() == Some(branch); | ||
let record = |o: &Item| StateRecord { | ||
repo: t.reference(), | ||
subject: o.uri.clone(), | ||
state: None, | ||
commit: None, | ||
from: None, | ||
batch: None, | ||
base: item.base.clone(), | ||
since: Some(head.to_string()), | ||
pull: None, | ||
created_at: records::now(), | ||
}; | ||
let write = |o: &Item| { | ||
let value = records::typed(nsid::STATE, &record(o)); | ||
remote::create(nsid::STATE, &keys::tid(), value) | ||
}; | ||
items.iter().filter(above).map(write).collect() | ||
} | ||
| ||
/// Where `item`'s own changes start, when it was moved onto its base as | ||
/// the pull below it landed: that pull's head, while its branch still | ||
/// holds it and has not taken in the base since. Then, or once rebased, | ||
/// its base is the start, as for any pull. | ||
pub async fn since_of(git: &Git, item: &Item, base: &str, head: &str) -> Result<Option<String>> { | ||
since_at(git, item.since.as_deref(), base, head).await | ||
} | ||
| ||
/// [`since_of`], of the start a pull's state records gave it. | ||
pub async fn since_at( | ||
git: &Git, | ||
since: Option<&str>, | ||
base: &str, | ||
head: &str, | ||
) -> Result<Option<String>> { | ||
let Some(since) = since else { | ||
return Ok(None); | ||
}; | ||
let args = ["merge-base", "--is-ancestor", since, head]; | ||
if !git.output_of(&args).await?.status.success() { | ||
return Ok(None); | ||
} | ||
let split = git.text(&["merge-base", base, head], None).await?; | ||
let before = git.is_ancestor(&split, since).await?; | ||
Ok(before.then(|| since.to_string())) | ||
} | ||
| ||
/// Why a merge of `item` on `t` keeps its head branch rather than | ||
/// deleting it, as [`spent`] decides, `keep` saying whether it was asked | ||
/// to; or none when it deletes it. | ||
| @@ -2536,7 +2609,7 @@ pub(crate) fn kept(t: &Target, item: &Item, items: &[Item], keep: bool) -> Optio | ||
let default = t.record.default_branch.as_deref().unwrap_or("main"); | ||
let other = |o: &&Item| { | ||
let from = o.head.as_ref().is_some_and(|h| h.branch == *branch); | ||
o.uri != item.uri && o.state == "open" && (o.base.as_deref() == Some(branch) || from) | ||
o.uri != item.uri && o.state == "open" && from | ||
}; | ||
let named = items.iter().find(other).map(|o| o.uri.as_str()); | ||
let into = item.base.as_deref() == Some(branch) || branch == default; | ||
| @@ -2682,13 +2755,15 @@ pub async fn compare(ctx: &Ctx, target: &RepoUrl, pull: &str) -> Result<Comparis | ||
} | ||
let sides = sides(ctx, &t, &item).await?; | ||
let (git, base, head) = (&sides.git, &sides.base, &sides.head); | ||
let commits = crate::read::log(git, &format!("{base}..{head}"), "", 250).await?; | ||
let since = since_of(git, &item, base, head).await?; | ||
let start = since.as_deref().unwrap_or(base); | ||
let commits = crate::read::log(git, &format!("{start}..{head}"), "", 250).await?; | ||
let (mergeable, conflicts) = if git.is_ancestor(head, base).await? { | ||
("merged", Vec::new()) | ||
} else if git.is_ancestor(base, head).await? { | ||
("fast-forward", Vec::new()) | ||
} else { | ||
match merge_tree(git, base, head).await? { | ||
match merge_tree_from(git, since.as_deref(), base, head).await? { | ||
Ok(_) => ("clean", Vec::new()), | ||
Err(files) => ("conflict", files), | ||
} | ||
| @@ -2725,7 +2800,9 @@ pub async fn pull_diff(ctx: &Ctx, target: &RepoUrl, pull: &str) -> Result<String | ||
return crate::read::diff(&git, &from, &merged).await; | ||
} | ||
let sides = sides(ctx, &t, &item).await?; | ||
crate::read::diff(&sides.git, &sides.base, &sides.head).await | ||
let since = since_of(&sides.git, &item, &sides.base, &sides.head).await?; | ||
let start = since.as_deref().unwrap_or(&sides.base); | ||
crate::read::diff(&sides.git, start, &sides.head).await | ||
} | ||
| ||
/// Creates `branch` at `from`, a commit or branch, or the default branch | ||
| @@ -2902,6 +2979,7 @@ pub async fn retarget( | ||
from: None, | ||
batch: None, | ||
base: Some(base.to_string()), | ||
since: None, | ||
pull: None, | ||
created_at: records::now(), | ||
}; | ||
| @@ -3025,6 +3103,7 @@ pub async fn landed( | ||
from, | ||
batch: None, | ||
base: None, | ||
since: None, | ||
pull: None, | ||
created_at: records::now(), | ||
}; | ||
| @@ -3081,9 +3160,22 @@ pub(crate) async fn merge_tree( | ||
base: &str, | ||
head: &str, | ||
) -> Result<Result<String, Vec<String>>> { | ||
let out = git | ||
.output_of(&["merge-tree", "--write-tree", "--name-only", base, head]) | ||
.await?; | ||
merge_tree_from(git, None, base, head).await | ||
} | ||
| ||
/// [`merge_tree`], with what is under `since` taken as being in `base` | ||
/// already, as a squash of it is: only what `head` adds to it merges. | ||
pub async fn merge_tree_from( | ||
git: &Git, | ||
since: Option<&str>, | ||
base: &str, | ||
head: &str, | ||
) -> Result<Result<String, Vec<String>>> { | ||
let from = since.map(|s| format!("--merge-base={s}")); | ||
let mut args = vec!["merge-tree", "--write-tree", "--name-only"]; | ||
args.extend(from.as_deref()); | ||
args.extend([base, head]); | ||
let out = git.output_of(&args).await?; | ||
let text = String::from_utf8_lossy(&out.stdout); | ||
let mut lines = text.lines(); | ||
let tree = lines.next().unwrap_or_default().to_string(); | ||
| @@ -3103,13 +3195,14 @@ pub(crate) async fn merge_tree( | ||
/// when they do not merge, the paths that conflict. | ||
pub async fn merge_result( | ||
git: &Git, | ||
since: Option<&str>, | ||
base: &str, | ||
head: &str, | ||
) -> Result<Result<String, Vec<String>>> { | ||
if git.is_ancestor(base, head).await? { | ||
return Ok(Ok(head.to_string())); | ||
} | ||
match merge_tree(git, base, head).await? { | ||
match merge_tree_from(git, since, base, head).await? { | ||
Ok(tree) => Ok(Ok(checked_merge(git, &tree, base, head).await?)), | ||
Err(files) => Ok(Err(files)), | ||
} | ||
crates/knotless/src/landing.rs+8−2
| @@ -215,7 +215,8 @@ pub async fn gauge( | ||
found, | ||
workflows: Vec::new(), | ||
}; | ||
let tree = match forge::merge_tree(git, base, head).await? { | ||
let since = forge::since_of(git, item, base, head).await?; | ||
let tree = match forge::merge_tree_from(git, since.as_deref(), base, head).await? { | ||
Ok(tree) => tree, | ||
Err(files) => { | ||
found.state = "conflicting"; | ||
| @@ -358,7 +359,12 @@ pub async fn merged_pull( | ||
let head = forge::head_of(ctx, git, head).await?.unwrap_or_default(); | ||
let second = parents.get(1).copied().unwrap_or(head.as_str()); | ||
let on = parents.first() == Some(&from) && second == head; | ||
let built = on && forge::merge_tree(git, from, &head).await?.as_ref() == Ok(&tree); | ||
let since = match on { | ||
true => forge::since_of(git, item, from, &head).await?, | ||
false => None, | ||
}; | ||
let made = forge::merge_tree_from(git, since.as_deref(), from, &head); | ||
let built = on && made.await?.as_ref() == Ok(&tree); | ||
if to == head || built { | ||
return Ok(Some(item.uri.clone())); | ||
} | ||
crates/knotless/src/queue.rs+12−4
| @@ -207,7 +207,8 @@ async fn admit(ctx: &Ctx, t: &Target, item: &Item) -> Result<String> { | ||
let sides = forge::sides(ctx, t, item).await?; | ||
let (base, head) = (&sides.base, &sides.head); | ||
let into = sides.refname.trim_start_matches("refs/heads/"); | ||
if let Err(files) = forge::merge_tree(&sides.git, base, head).await? { | ||
let since = forge::since_of(&sides.git, item, base, head).await?; | ||
if let Err(files) = forge::merge_tree_from(&sides.git, since.as_deref(), base, head).await? { | ||
bail!( | ||
"{} does not apply on {into} at {}: it conflicts in {}. Merge {into} into it, or rebase it onto {into}, and queue it again", | ||
item.uri, | ||
| @@ -914,7 +915,8 @@ pub async fn stack(git: &Git, base: &str, pulls: &[(Item, String)], me: &Session | ||
}; | ||
for (pull, head) in pulls { | ||
let tip = stack.tip().to_string(); | ||
match forge::merge_tree(git, &tip, head).await? { | ||
let since = forge::since_of(git, pull, &tip, head).await?; | ||
match forge::merge_tree_from(git, since.as_deref(), &tip, head).await? { | ||
Ok(tree) => { | ||
let commit = forge::squash_commit(git, &tree, &tip, head, pull, me).await?; | ||
let (pull, head) = (pull.clone(), head.clone()); | ||
| @@ -979,8 +981,10 @@ pub async fn landing_refusal( | ||
} | ||
let tree = git.rev_parse(&format!("{commit}^{{tree}}")).await?; | ||
let mut found = None; | ||
for (at, (_, head)) in waiting.iter().enumerate() { | ||
if forge::merge_tree(git, &parent, head).await?.as_ref() == Ok(&tree) { | ||
for (at, (pull, head)) in waiting.iter().enumerate() { | ||
let since = forge::since_of(git, pull, &parent, head).await?; | ||
let made = forge::merge_tree_from(git, since.as_deref(), &parent, head).await?; | ||
if made.as_ref() == Ok(&tree) { | ||
found = Some(at); | ||
break; | ||
} | ||
| @@ -1062,11 +1066,15 @@ pub async fn land(ctx: &Ctx, url: &RepoUrl, git: &Git, branch: &str, stack: &Sta | ||
from: Some(std::mem::replace(&mut from, s.commit.clone())), | ||
batch: Some(stack.tip().to_string()), | ||
base: None, | ||
since: None, | ||
pull: None, | ||
created_at: records::now(), | ||
}; | ||
let value = records::typed(nsid::STATE, &record); | ||
writes.push(remote::create(nsid::STATE, &keys::tid(), value)); | ||
if let Some(gone) = forge::spent(t.facts(), &s.pull, &items) { | ||
writes.extend(forge::moved_on(&t, &s.pull, &items, &gone, &s.head)); | ||
} | ||
let owner = t.remote.did(); | ||
let closes = closing::to_close(&t.record, owner, &items, &s.pull, branch, &me.did); | ||
let closes: Vec<String> = closes.into_iter().filter(|i| !closed.contains(i)).collect(); | ||
crates/knotless/src/records.rs+4−0
| @@ -1280,6 +1280,10 @@ pub struct StateRecord { | ||
/// targeted was renamed. | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
pub base: Option<String>, | ||
/// Beside `base`, for a pull moved there as the pull it was built on | ||
/// landed: that pull's head, where this one's own changes start. | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
pub since: Option<String>, | ||
/// For `closed` on an issue, the pull whose merge closed it, as its | ||
/// title or body named the issue. | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
crates/knotless-run/src/lib.rs+8−3
| @@ -796,6 +796,8 @@ struct Due { | ||
base_sha: String, | ||
/// The generation of `base` at `base_sha`. | ||
base_generation: u64, | ||
/// Where its own changes start, if the pull below it landed. | ||
since: Option<String>, | ||
uri: String, | ||
/// Whether its author and its head's owner may both push here. | ||
trusted: bool, | ||
| @@ -1541,7 +1543,7 @@ impl Runner { | ||
let open = items | ||
.into_iter() | ||
.filter(|i| i.kind == "pull" && i.state == "open") | ||
.filter_map(|i| Some((i.uri, i.head?, i.base?))); | ||
.filter_map(|i| Some((i.uri, i.head?, i.base?, i.since))); | ||
let first = !self.state.lock().unwrap().pulls.contains_key(&watch.id()); | ||
let main = main_ref(snap); | ||
let owner = watch.remote.did(); | ||
| @@ -1553,7 +1555,7 @@ impl Runner { | ||
// pull. | ||
let ours = (watch.remote.clone(), snap.clone()); | ||
let mut heads = HashMap::from([((owner.to_string(), watch.key.clone()), ours)]); | ||
for (uri, head, base) in open { | ||
for (uri, head, base, since) in open { | ||
let (did, _, name) = forge::parse_uri(&head.repo.uri)?; | ||
let watched = (watch.remote.did().to_string(), watch.key.clone()); | ||
self.heads | ||
| @@ -1642,6 +1644,7 @@ impl Runner { | ||
branch, | ||
base, | ||
base_sha, | ||
since, | ||
base_generation, | ||
uri: uri.clone(), | ||
trusted, | ||
| @@ -1659,7 +1662,9 @@ impl Runner { | ||
for d in due { | ||
let wants = std::slice::from_ref(&d.sha); | ||
fetch::fetch(&d.remote, &d.key, git, wants, &d.snap).await?; | ||
let merged = forge::merge_result(git, &d.base_sha, &d.sha).await?; | ||
let since = forge::since_at(git, d.since.as_deref(), &d.base_sha, &d.sha).await?; | ||
let merged = | ||
forge::merge_result(git, since.as_deref(), &d.base_sha, &d.sha).await?; | ||
// One that conflicts fails whatever it changes. | ||
let changed = match &merged { | ||
Ok(merged) => changes(git, &d.base_sha, merged).await, | ||
crates/knotless-run/src/queue.rs+2−1
| @@ -694,7 +694,8 @@ impl Runner { | ||
tip: &str, | ||
only: BTreeSet<String>, | ||
) -> Result<bool> { | ||
let merged = forge::merge_result(on.git, tip, head).await?; | ||
let since = forge::since_of(on.git, item, tip, head).await?; | ||
let merged = forge::merge_result(on.git, since.as_deref(), tip, head).await?; | ||
let Ok(merge) = &merged else { | ||
return Ok(false); | ||
}; | ||
crates/knotless-serve/src/web.rs+3−1
| @@ -2948,7 +2948,9 @@ async fn pull_range( | ||
handle: handle(app, did).await, | ||
name, | ||
}; | ||
(git, start, tip, at) | ||
let since = forge::since_of(&git, item, &start, &tip).await; | ||
let since = since.map_err(not_found)?; | ||
(git, since.unwrap_or(start), tip, at) | ||
} | ||
}) | ||
} | ||
crates/knotless-serve/tests/forge.rs+54−5
| @@ -393,8 +393,9 @@ fn forks_pulls_reviews_and_merges() { | ||
assert_eq!(ok(&owner, &["branch", "create", &url, "pinned", &two]), two); | ||
ok(&owner, &["branch", "delete", &url, "pinned"]); | ||
| ||
// A branch in the repository itself goes with its merged pull, unless | ||
// another open pull still names it, or it is asked to stay. | ||
// A branch in the repository itself goes with its merged pull, and | ||
// pulls based on it move to its base, unless another open pull still | ||
// brings it in, 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"); | ||
| @@ -416,18 +417,21 @@ fn forks_pulls_reviews_and_merges() { | ||
listed(&owner, &url, &atop); | ||
listed(&owner, &url, &kept); | ||
let merged = ok(&owner, &["pull", "merge", &url, &tidy]); | ||
assert!(!merged.contains("deleted"), "{merged}"); | ||
assert!(merged.ends_with("\ndeleted tidy"), "{merged}"); | ||
// Its base gone, it merges only for having moved to main. | ||
let merged = ok(&owner, &["pull", "merge", &url, &atop]); | ||
assert!(merged.ends_with("\ndeleted atop"), "{merged}"); | ||
owner.git(&work, &["fetch", "-q", &url, "main"]); | ||
owner.git(&work, &["cat-file", "-e", "FETCH_HEAD:h"]); | ||
fails(&owner, &["pull", "merge", &url, &kept, "--bogus"]); | ||
let merged = ok(&owner, &["pull", "merge", &url, &kept, "--keep-branch"]); | ||
assert!(!merged.contains("deleted"), "{merged}"); | ||
let (_, refs) = owner.cli(&["repo", "refs", &url], b""); | ||
assert!(refs.contains("refs/heads/kept"), "{refs}"); | ||
assert!( | ||
refs.contains("refs/heads/tidy") && refs.contains("refs/heads/kept"), | ||
!refs.contains("refs/heads/tidy") && !refs.contains("refs/heads/atop"), | ||
"{refs}" | ||
); | ||
assert!(!refs.contains("refs/heads/atop"), "{refs}"); | ||
// A protected head branch is not deleted, as a gateway would refuse it. | ||
owner.git(&work, &["push", "-q", &url, "atop:guarded"]); | ||
ok(&owner, &["branch", "protect", &url, "guarded"]); | ||
| @@ -441,6 +445,51 @@ fn forks_pulls_reviews_and_merges() { | ||
let (_, refs) = owner.cli(&["repo", "refs", &url], b""); | ||
assert!(refs.contains("refs/heads/guarded"), "{refs}"); | ||
| ||
// On a linear branch, squashing the pull below leaves the one above | ||
// holding its original commits, which stay out of what it changes. | ||
owner.git(&work, &["pull", "-q", &url, "main"]); | ||
owner.git(&work, &["checkout", "-q", "-b", "trunk"]); | ||
owner.git(&work, &["checkout", "-q", "-b", "low"]); | ||
owner.commit_file(&work, "low", "low\n"); | ||
owner.git(&work, &["checkout", "-q", "-b", "mid"]); | ||
owner.commit_file(&work, "mid", "mid\n"); | ||
owner.git(&work, &["checkout", "-q", "-b", "top"]); | ||
owner.commit_file(&work, "top", "top\n"); | ||
owner.git(&work, &["push", "-q", &url, "trunk", "low", "mid", "top"]); | ||
ok(&owner, &["branch", "protect", &url, "trunk"]); | ||
let open = | ||
|base: &str, head: &str| ok(&owner, &["pull", "create", &url, base, &url, head, head]); | ||
let (low, mid, top) = (open("trunk", "low"), open("low", "mid"), open("mid", "top")); | ||
listed(&owner, &url, &top); | ||
let merged = ok(&owner, &["pull", "merge", &url, &low]); | ||
assert!(merged.ends_with("\ndeleted low"), "{merged}"); | ||
let diff = ok(&owner, &["pull", "diff", &url, &mid]); | ||
assert!(diff.contains("b/mid") && !diff.contains("b/low"), "{diff}"); | ||
// The base taken into its branch: the base is where it starts again. | ||
owner.git(&work, &["checkout", "-q", "mid"]); | ||
owner.git(&work, &["fetch", "-q", &url, "trunk"]); | ||
owner.git(&work, &["merge", "-q", "--no-edit", "FETCH_HEAD"]); | ||
owner.git(&work, &["push", "-q", &url, "mid"]); | ||
let diff = ok(&owner, &["pull", "diff", &url, &mid]); | ||
assert!(diff.contains("b/mid") && !diff.contains("b/low"), "{diff}"); | ||
let merged = ok(&owner, &["pull", "merge", &url, &mid]); | ||
assert!(merged.ends_with("\ndeleted mid"), "{merged}"); | ||
// Rebased, its start is no longer under it: so too. | ||
owner.git(&work, &["fetch", "-q", &url, "trunk"]); | ||
owner.git( | ||
&work, | ||
&["rebase", "-q", "--onto", "FETCH_HEAD", "mid", "top"], | ||
); | ||
owner.git(&work, &["push", "-q", "-f", &url, "top"]); | ||
let diff = ok(&owner, &["pull", "diff", &url, &top]); | ||
assert!(diff.contains("b/top") && !diff.contains("b/mid"), "{diff}"); | ||
ok(&owner, &["pull", "merge", &url, &top]); | ||
owner.git(&work, &["fetch", "-q", &url, "trunk"]); | ||
for file in ["low", "mid", "top"] { | ||
let at = format!("FETCH_HEAD:{file}"); | ||
owner.git(&work, &["cat-file", "-e", &at]); | ||
} | ||
| ||
// 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); | ||