Skip to content

handle having to unstack to change base - #86

Merged
slinder1 merged 1 commit into
users/slinder1/Idc736a3b078ba2982cb38652ba2246c504a660c5from
users/slinder1/I8c0390e5375d7f66a86313042e52e039230d89e8
Aug 11, 2026
Merged

handle having to unstack to change base#86
slinder1 merged 1 commit into
users/slinder1/Idc736a3b078ba2982cb38652ba2246c504a660c5from
users/slinder1/I8c0390e5375d7f66a86313042e52e039230d89e8

Conversation

@slinder1

Copy link
Copy Markdown
Owner

Change-Id: I8c0390e5375d7f66a86313042e52e039230d89e8

@slinder1
slinder1 changed the base branch from main to users/slinder1/Idc736a3b078ba2982cb38652ba2246c504a660c5 August 11, 2026 15:19
@slinder1

Copy link
Copy Markdown
Owner Author
🛠️ Initial changes (click to expand):
diff --git b/src/gh.rs a/src/gh.rs
@@ -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,
@@ -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(())
     }
 
@@ -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"),
@@ -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()? {
diff --git b/src/praddle.rs a/src/praddle.rs
@@ -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)
@@ -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 {
@@ -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",

@slinder1
slinder1 marked this pull request as ready for review August 11, 2026 15:19
@slinder1
slinder1 marked this pull request as draft August 11, 2026 15:28
@slinder1
slinder1 changed the base branch from users/slinder1/Idc736a3b078ba2982cb38652ba2246c504a660c5 to main August 11, 2026 15:29
@slinder1
slinder1 force-pushed the users/slinder1/I8c0390e5375d7f66a86313042e52e039230d89e8 branch from 2d248f8 to 9a64c3c Compare August 11, 2026 15:29
@slinder1
slinder1 changed the base branch from main to users/slinder1/Idc736a3b078ba2982cb38652ba2246c504a660c5 August 11, 2026 15:29
@slinder1 slinder1 changed the title dev handle having to unstack to change base Aug 11, 2026
@slinder1
slinder1 marked this pull request as ready for review August 11, 2026 15:29
@slinder1
slinder1 marked this pull request as draft August 11, 2026 16:05
@slinder1
slinder1 changed the base branch from users/slinder1/Idc736a3b078ba2982cb38652ba2246c504a660c5 to main August 11, 2026 16:05
@slinder1
slinder1 changed the base branch from main to users/slinder1/Idc736a3b078ba2982cb38652ba2246c504a660c5 August 11, 2026 16:05
@slinder1
slinder1 marked this pull request as ready for review August 11, 2026 16:05
@slinder1
slinder1 force-pushed the users/slinder1/I8c0390e5375d7f66a86313042e52e039230d89e8 branch from 9a64c3c to 28bda3a Compare August 11, 2026 16:09
@slinder1
slinder1 marked this pull request as draft August 11, 2026 16:29
@slinder1
slinder1 changed the base branch from users/slinder1/Idc736a3b078ba2982cb38652ba2246c504a660c5 to main August 11, 2026 16:29
@slinder1
slinder1 force-pushed the users/slinder1/I8c0390e5375d7f66a86313042e52e039230d89e8 branch from 28bda3a to c11ed81 Compare August 11, 2026 16:29
@slinder1
slinder1 changed the base branch from main to users/slinder1/Idc736a3b078ba2982cb38652ba2246c504a660c5 August 11, 2026 16:29
@slinder1
slinder1 marked this pull request as ready for review August 11, 2026 16:30
@slinder1
slinder1 force-pushed the users/slinder1/I8c0390e5375d7f66a86313042e52e039230d89e8 branch 2 times, most recently from 869beaf to 94bd1d6 Compare August 11, 2026 17:09
Change-Id: I8c0390e5375d7f66a86313042e52e039230d89e8
@slinder1
slinder1 force-pushed the users/slinder1/I8c0390e5375d7f66a86313042e52e039230d89e8 branch from 94bd1d6 to 232c423 Compare August 11, 2026 17:20
@slinder1
slinder1 merged commit 277db17 into main Aug 11, 2026
3 checks passed
@slinder1
slinder1 deleted the users/slinder1/I8c0390e5375d7f66a86313042e52e039230d89e8 branch August 11, 2026 17:24
slinder1 added a commit that referenced this pull request Aug 12, 2026
Change-Id: I8c0390e5375d7f66a86313042e52e039230d89e8
Assisted-by: opencode
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant