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