Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 76 additions & 3 deletions src/gh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,37 @@ fn rest_api_empty(method: &str, endpoint: String, body: Option<&[u64]>) -> Resul
Ok(())
}

fn unstack(endpoint: String) -> Result<()> {
let mut cmd = gh();
cmd.args([
"api",
endpoint.as_str(),
"--method",
"POST",
"--header",
"Accept: application/vnd.github+json",
"--header",
"X-GitHub-Api-Version: 2026-03-10",
"--include",
]);
if env::get().dry_run() {
eprintln!("would-exec: {:?}", cmd);
return Ok(());
}
let output = exec!(env::get(), cmd);
let status = String::from_utf8_lossy(output.stdout.as_ref())
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|status| status.parse::<u16>().ok())
.context("gh api unstack response did not contain an HTTP status")?;
match status {
204 => Ok(()),
200 => bail!("unstacking {endpoint} left pull requests in the stack"),
_ => bail!("unexpected HTTP status from unstacking {endpoint}: {status}"),
}
}

fn graphql_search<T, K, F>(gql_query: &str, search_query: &str, key: F) -> Result<Vec<T>>
where
T: DeserializeOwned,
Expand Down Expand Up @@ -310,11 +341,16 @@ impl Pr {
Ok(())
}

pub fn set_base(&self, base: &str) -> Result<()> {
pub fn set_base(&mut self, base: &str) -> Result<()> {
let mut cmd = gh();
let args = self.args_for("edit", [format!("--base={base}")])?;
cmd.args(args);
exec!(env::get(), dry_return = (), cmd);
if env::get().dry_run() {
eprintln!("would-exec: {:?}", cmd);
} else {
exec!(env::get(), cmd);
}
self.base_ref_name = base.to_owned();
Ok(())
}

Expand Down Expand Up @@ -496,7 +532,7 @@ pub fn reconcile_stack(prs: &[&Pr]) -> Result<()> {
rest_api_empty("POST", endpoint("/add"), Some(additions))?;
}
} else {
rest_api_empty("DELETE", endpoint("/remove"), Some(&existing))?;
unstack(endpoint("/unstack"))?;
rest_api_empty(
"POST",
format!("repos/{owner}/{name}/stacks"),
Expand All @@ -506,6 +542,43 @@ pub fn reconcile_stack(prs: &[&Pr]) -> Result<()> {
Ok(())
}

pub fn remove_stack(prs: &[&Pr]) -> Result<()> {
let (owner, name) = repo_name()?;
let stack_numbers: HashSet<u64> = prs
.iter()
.filter_map(|pr| pr.stack.as_ref().map(|stack| stack.number))
.collect();
if stack_numbers.len() > 1 {
bail!("local prs belong to multiple GitHub stacks: {stack_numbers:?}");
}
let Some(stack_number) = stack_numbers.iter().next().copied() else {
return Ok(());
};
let endpoint = format!("repos/{owner}/{name}/stacks/{stack_number}");
let stack = rest_stack(endpoint.clone())?;
let unmerged: Vec<u64> = stack
.pull_requests
.iter()
.filter(|pr| pr.merged_at.is_none())
.map(|pr| pr.number)
.collect();
let local_count = prs
.iter()
.filter(|pr| {
pr.stack
.as_ref()
.is_some_and(|stack| stack.number == stack_number)
})
.count();
if local_count != unmerged.len() {
bail!("GitHub stack {stack_number} contains unmerged prs outside the local stack");
}
if !unmerged.is_empty() {
unstack(format!("{endpoint}/unstack"))?;
}
Ok(())
}

pub fn prs_by_change_id() -> Result<HashMap<String, Pr>> {
let mut by_id = HashMap::new();
for pr in prs()? {
Expand Down
39 changes: 30 additions & 9 deletions src/praddle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,25 @@ fn push(args: &cli::Push) -> Result<()> {
});
}
let has_cycles = detect_cycles(&any_changes);
if has_cycles {
let stacked_prs: Vec<&Pr> = any_changes
.iter()
.filter_map(|any_change| match any_change {
AnyChange::Change(change) => Some(&change.pr),
AnyChange::LocalChange(_) => None,
})
.collect();
gh::remove_stack(&stacked_prs).context("could not remove pr stack for rebase")?;
for any_change in any_changes.iter_mut() {
if let AnyChange::Change(change) = any_change {
change.pr.stack = None;
change.pr.stack_entry = None;
}
}
}
if has_cycles || args.draft {
any_changes
.par_iter()
.par_iter_mut()
.filter_map(|ac| {
if let AnyChange::Change(c) = ac {
Some(c)
Expand Down Expand Up @@ -108,7 +124,7 @@ fn push(args: &cli::Push) -> Result<()> {
// FIXME: Should try to restore the original branch contents if we fail from this point on. It
// would be at least an attempt at being "atomic" about the push, and it would mean we don't
// lose the interdiff in a future re-run.
let changes = any_changes
let mut changes = any_changes
.into_par_iter()
.map(|any_change| {
let change = match any_change {
Expand All @@ -123,17 +139,22 @@ fn push(args: &cli::Push) -> Result<()> {
})
.collect::<Result<Vec<_>>>()
.context("could not create new prs")?;
changes
.par_iter()
let bases: Vec<String> = changes
.iter()
.enumerate()
.map(|(i, c)| {
let parents = &changes[i + 1..];
let base = parents
.map(|(i, _)| {
changes[i + 1..]
.iter()
.next()
.map(|p| p.local_change.remote_branch())
.unwrap_or_else(|| env.base_branch().to_owned());
if c.pr.base_ref_name != base {
.unwrap_or_else(|| env.base_branch().to_owned())
})
.collect();
changes
.par_iter_mut()
.zip(bases.par_iter())
.map(|(c, base)| {
if c.pr.base_ref_name != base.as_str() {
if c.pr.stack.is_some() {
bail!(
"cannot retarget pr {} while it is part of a stack",
Expand Down
Loading