Skip to main content

git_stk/commands/
unstack.rs

1use anyhow::{Result, bail};
2use clap::ArgAction;
3
4use crate::commands::Run;
5use crate::prompt::confirm;
6use crate::providers::detect_review_provider;
7use crate::stack;
8use crate::style;
9
10/// Dissolve the platform's own stack for the current stack, leaving its
11/// reviews open and standalone.
12///
13/// Only GitHub keeps stacks. Registering one is opt-in via `stk.githubStacks`;
14/// dissolving one is not, because a stack outlives the setting that created it
15/// and may have been made outside git-stk entirely.
16#[derive(Debug, clap::Args)]
17pub struct Unstack {
18    /// Look the stack up and print what would be dissolved, without
19    /// dissolving it.
20    #[arg(long, short = 'n', action = ArgAction::SetTrue)]
21    dry_run: bool,
22    /// Skip the confirmation prompt.
23    #[arg(long, short = 'y', action = ArgAction::SetTrue)]
24    yes: bool,
25}
26
27impl Run for Unstack {
28    fn run(self) -> Result<()> {
29        let current = crate::git::current_branch()?;
30        let line = stack::stack_line(&current)?;
31        if line.is_empty() {
32            bail!("not on a stacked branch; nothing to unstack");
33        }
34
35        // The whole line, not just the layers git-stk records a parent for:
36        // a stack need not begin where your local line does, one made outside
37        // git-stk need not align with it at all or be adopted here, and the
38        // line's own base can be a layer of the platform's. A failed lookup is
39        // an error here, not "no stack" - this is the whole command, so
40        // answering "already gone" for an expired token would be a lie.
41        let (_, review_provider) = detect_review_provider()?;
42        let found = review_provider.native_stacks_covering(&line)?;
43        if found.is_empty() {
44            anstream::println!(
45                "{}",
46                style::dim("no platform stack recorded for this stack; nothing to dissolve")
47            );
48            return Ok(());
49        }
50
51        // What is about to happen, before any of it does. A stack is dissolved
52        // whole, several can cover one line, and the line reaches the whole
53        // subtree above you - so this can take apart reviews that are nowhere
54        // on screen. There is no undo: `undo` restores local metadata, and
55        // this is a `POST`.
56        for stack in &found {
57            anstream::println!(
58                "{} dissolve stack {} ({})",
59                if self.dry_run { "would" } else { "will" },
60                stack.number,
61                stack
62                    .layers
63                    .iter()
64                    .map(|layer| layer.id.as_str())
65                    .collect::<Vec<_>>()
66                    .join(" ")
67            );
68        }
69        if self.dry_run {
70            return Ok(());
71        }
72
73        let reviews: usize = found.iter().map(|stack| stack.layers.len()).sum();
74        if !self.yes
75            && !confirm(&format!(
76                "dissolve {} stack{}, leaving {reviews} review{} standalone? [y/N] ",
77                found.len(),
78                if found.len() == 1 { "" } else { "s" },
79                if reviews == 1 { "" } else { "s" }
80            ))?
81        {
82            anstream::println!("unstack cancelled");
83            return Ok(());
84        }
85
86        // Keep going after a failure rather than leaving the rest of the line
87        // silently still stacked: which ones survived is the whole answer for
88        // a command someone reaches for to get unstuck.
89        let mut failed: Vec<(u64, anyhow::Error)> = Vec::new();
90        for stack in &found {
91            match review_provider.unstack_reviews(stack) {
92                Ok(Some(report)) => anstream::println!("{report}"),
93                Ok(None) => anstream::println!(
94                    "{}",
95                    style::dim("this provider does not keep stacks; nothing to dissolve")
96                ),
97                Err(error) => failed.push((stack.number, error)),
98            }
99        }
100
101        // Every failure reported the same way, and the summary - not an
102        // arbitrary one of them - is the error. Promoting one would make the
103        // headline depend on iteration order: a 404 for a stack a teammate
104        // already dissolved would outrank the expired token that is the real
105        // reason the rest did not go.
106        if !failed.is_empty() {
107            for (_, error) in &failed {
108                anstream::eprintln!("{}", style::warn(&format!("{error:#}")));
109            }
110            bail!(
111                "{} stack{} still registered: {}",
112                failed.len(),
113                if failed.len() == 1 { "" } else { "s" },
114                failed
115                    .iter()
116                    .map(|(number, _)| number.to_string())
117                    .collect::<Vec<_>>()
118                    .join(", ")
119            );
120        }
121        Ok(())
122    }
123}