Skip to main content

git_stk/commands/
split.rs

1use std::collections::BTreeSet;
2use std::io::IsTerminal;
3
4use anyhow::{Result, anyhow, bail};
5use clap::ArgAction;
6use dialoguer::theme::ColorfulTheme;
7use dialoguer::{Input, MultiSelect};
8
9use crate::commands::Run;
10use crate::git;
11use crate::stack;
12use crate::style;
13
14/// Split the current branch's commits into a stack of branches, bottom-up.
15///
16/// The current branch is reused as the leaf (it keeps its name and tip); new
17/// branches are created beneath it for the commits below. Non-destructive: the
18/// new branches point at the existing commits, so nothing is rewritten.
19#[derive(Debug, clap::Args)]
20pub struct Split {
21    /// One branch per commit, named from each commit's subject; skips the
22    /// interactive grouping picker.
23    #[arg(long, action = ArgAction::SetTrue)]
24    per_commit: bool,
25    /// Print the plan without creating branches or writing metadata.
26    #[arg(long, short = 'n', action = ArgAction::SetTrue)]
27    dry_run: bool,
28}
29
30impl Run for Split {
31    fn run(self) -> Result<()> {
32        if self.per_commit {
33            split_per_commit(self.dry_run)
34        } else {
35            split_interactive(self.dry_run)
36        }
37    }
38}
39
40/// A planned branch: its name and the commit it should point at.
41struct Plan {
42    name: String,
43    sha: String,
44}
45
46fn split_per_commit(dry_run: bool) -> Result<()> {
47    let branch = git::current_branch()?;
48    let base = base_of(&branch)?;
49
50    // Commits on the branch above its base, oldest first.
51    let mut commits = git::rev_list(&format!("{base}..{branch}"))?;
52    commits.reverse();
53    if commits.len() < 2 {
54        bail!(
55            "{branch} has {} commit(s) above {base}; need at least 2 to split",
56            commits.len()
57        );
58    }
59
60    // One branch per commit, bottom-up. The top commit keeps the original
61    // branch name (the leaf); the rest get names slugged from their subjects.
62    let existing: std::collections::BTreeSet<String> = git::local_branches()?.into_iter().collect();
63    let mut used: std::collections::BTreeSet<String> = existing.clone();
64    let mut plan: Vec<Plan> = Vec::new();
65    let last = commits.len() - 1;
66    for (index, sha) in commits.iter().enumerate() {
67        let name = if index == last {
68            branch.clone()
69        } else {
70            let subject = git::commit_subject(sha)?;
71            unique_name(&slugify(&subject), &mut used)
72        };
73        plan.push(Plan {
74            name,
75            sha: sha.clone(),
76        });
77    }
78
79    apply(&branch, &base, &plan, dry_run)
80}
81
82/// Interactive split: pick which commits start a branch, then name each one.
83/// The top commit always stays on the original branch (the leaf).
84fn split_interactive(dry_run: bool) -> Result<()> {
85    if !std::io::stdin().is_terminal() {
86        bail!(
87            "the interactive split needs a terminal; pass --per-commit for a non-interactive split"
88        );
89    }
90    let branch = git::current_branch()?;
91    let base = base_of(&branch)?;
92
93    let mut commits = git::rev_list(&format!("{base}..{branch}"))?;
94    commits.reverse();
95    if commits.len() < 2 {
96        bail!(
97            "{branch} has {} commit(s) above {base}; need at least 2 to split",
98            commits.len()
99        );
100    }
101    let subjects: Vec<String> = commits
102        .iter()
103        .map(|sha| git::commit_subject(sha))
104        .collect::<Result<_>>()?;
105
106    // Phase 1: which commits start a new branch. The top commit is always the
107    // leaf, so only the commits below it are offered; default is one each.
108    let below = commits.len() - 1;
109    let labels: Vec<String> = (0..below)
110        .map(|i| format!("{}  {}", &commits[i][..8], subjects[i]))
111        .collect();
112    let theme = ColorfulTheme::default();
113    let checked = MultiSelect::with_theme(&theme)
114        .with_prompt(format!(
115            "Each checked commit starts a new branch (unchecked folds into the one below); {branch} stays the leaf"
116        ))
117        .items(&labels)
118        .defaults(&vec![true; below])
119        .interact()?;
120    let starts = group_starts(&checked, below);
121
122    // Phase 2: name each new branch (bottom-up), defaulting to a slug of the
123    // group's bottom commit. The leaf reuses the original branch name.
124    let mut taken: BTreeSet<String> = git::local_branches()?.into_iter().collect();
125    let mut plan: Vec<Plan> = Vec::new();
126    for (group, &start) in starts.iter().enumerate() {
127        let end = starts.get(group + 1).copied().unwrap_or(below);
128        let default = unique_name(&slugify(&subjects[start]), &mut taken.clone());
129        let name: String = Input::with_theme(&theme)
130            .with_prompt(format!(
131                "Name for new branch {}/{}",
132                group + 1,
133                starts.len()
134            ))
135            .default(default)
136            .interact_text()?;
137        if !stack::is_safe_ref_name(&name) {
138            bail!("{name:?} is not a valid branch name");
139        }
140        if name == branch {
141            bail!("a new branch cannot reuse the leaf's name {branch}");
142        }
143        if !taken.insert(name.clone()) {
144            bail!("branch name {name:?} is already taken");
145        }
146        plan.push(Plan {
147            name,
148            sha: commits[end - 1].clone(),
149        });
150    }
151    plan.push(Plan {
152        name: branch.clone(),
153        sha: commits[commits.len() - 1].clone(),
154    });
155
156    apply(&branch, &base, &plan, dry_run)
157}
158
159/// The commit indices that begin a branch: always the bottom (0) plus each one
160/// the user checked, sorted and deduped. Consecutive commits between two starts
161/// form one branch.
162fn group_starts(checked: &[usize], below: usize) -> Vec<usize> {
163    let mut starts: Vec<usize> = std::iter::once(0)
164        .chain(checked.iter().copied().filter(|&index| index < below))
165        .collect();
166    starts.sort_unstable();
167    starts.dedup();
168    starts
169}
170
171/// The branch's base: its recorded stack parent, or the trunk.
172fn base_of(branch: &str) -> Result<String> {
173    if let Some(parent) = stack::parent_of(branch)? {
174        return Ok(parent);
175    }
176    stack::trunk_branch(&git::local_branches()?)
177        .filter(|trunk| trunk != branch)
178        .ok_or_else(|| {
179            anyhow!("could not determine a base for {branch}; adopt it onto a parent first")
180        })
181}
182
183/// Create a branch per plan entry (bottom-up), reusing the original branch as
184/// the leaf. Each branch's parent is the one below it; the bottom's is `base`.
185fn apply(branch: &str, base: &str, plan: &[Plan], dry_run: bool) -> Result<()> {
186    if !dry_run {
187        stack::snapshot("split");
188    }
189    for (index, entry) in plan.iter().enumerate() {
190        let parent = if index == 0 {
191            base
192        } else {
193            &plan[index - 1].name
194        };
195        let leaf = index == plan.len() - 1;
196        if leaf {
197            anstream::println!(
198                "{} {} {} onto {}",
199                verb(dry_run),
200                style::branch(&entry.name),
201                style::dim("(leaf)"),
202                style::branch(parent)
203            );
204        } else {
205            anstream::println!(
206                "{} {} at {} onto {}",
207                verb(dry_run),
208                style::branch(&entry.name),
209                style::dim(&entry.sha[..8]),
210                style::branch(parent)
211            );
212        }
213        if dry_run {
214            continue;
215        }
216        if !leaf {
217            git::create_branch_at(&entry.name, &entry.sha)?;
218        }
219        stack::set_parent(&entry.name, parent)?;
220        stack::record_base(&entry.name, parent);
221    }
222    if !dry_run {
223        anstream::println!(
224            "{}",
225            style::success(&format!("split {branch} into {} branches", plan.len()))
226        );
227    }
228    Ok(())
229}
230
231fn verb(dry_run: bool) -> &'static str {
232    if dry_run { "would create" } else { "created" }
233}
234
235/// A branch-name slug from a commit subject: lowercase, non-alphanumeric runs
236/// collapsed to a single dash, trimmed, capped in length. Empty input (e.g. an
237/// all-punctuation subject) falls back to "branch".
238fn slugify(subject: &str) -> String {
239    let mut slug = String::new();
240    let mut pending_dash = false;
241    for ch in subject.chars() {
242        if ch.is_ascii_alphanumeric() {
243            if pending_dash {
244                slug.push('-');
245                pending_dash = false;
246            }
247            slug.push(ch.to_ascii_lowercase());
248        } else if !slug.is_empty() {
249            pending_dash = true;
250        }
251        if slug.len() >= 50 {
252            break;
253        }
254    }
255    if slug.is_empty() {
256        "branch".to_owned()
257    } else {
258        slug
259    }
260}
261
262/// Make `base` unique against names already taken, appending -2, -3, ... and
263/// recording the result so later calls avoid it too.
264fn unique_name(base: &str, used: &mut std::collections::BTreeSet<String>) -> String {
265    if used.insert(base.to_owned()) {
266        return base.to_owned();
267    }
268    let mut suffix = 2;
269    loop {
270        let candidate = format!("{base}-{suffix}");
271        if used.insert(candidate.clone()) {
272            return candidate;
273        }
274        suffix += 1;
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn slugify_lowercases_and_dashes() {
284        assert_eq!(slugify("Fix the thing"), "fix-the-thing");
285        assert_eq!(slugify("Add API endpoint (v2)"), "add-api-endpoint-v2");
286        assert_eq!(slugify("  leading/trailing  "), "leading-trailing");
287    }
288
289    #[test]
290    fn slugify_falls_back_when_empty() {
291        assert_eq!(slugify("!!!"), "branch");
292        assert_eq!(slugify(""), "branch");
293    }
294
295    #[test]
296    fn group_starts_always_includes_the_bottom_and_dedups() {
297        assert_eq!(group_starts(&[1, 2], 3), vec![0, 1, 2]); // all checked -> one per commit
298        assert_eq!(group_starts(&[1], 3), vec![0, 1]); // the top commit folds into group 2
299        assert_eq!(group_starts(&[], 3), vec![0]); // everything folds into one branch
300        assert_eq!(group_starts(&[0, 2], 3), vec![0, 2]); // checking the bottom is moot
301    }
302
303    #[test]
304    fn unique_name_appends_a_counter_on_collision() {
305        let mut used = std::collections::BTreeSet::new();
306        assert_eq!(unique_name("fix", &mut used), "fix");
307        assert_eq!(unique_name("fix", &mut used), "fix-2");
308        assert_eq!(unique_name("fix", &mut used), "fix-3");
309    }
310}