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.
172///
173/// Refuses a recorded stack base outright. `split` rewrites nothing - it
174/// creates branches at existing commits - but it does stamp a `stkParent` on
175/// the branch it splits, and a base has no parent by design: the marker would
176/// outrank that metadata everywhere else, leaving the two disagreeing.
177fn base_of(branch: &str) -> Result<String> {
178    if stack::is_floor(branch)? {
179        bail!(
180            "{branch} is a stack's base, so splitting it would give it a stack \
181             parent it should not have; if it is not a base, \
182             `git stk adopt {branch} --parent <parent>` says so and keeps the \
183             parent that split bases on, where `git stk detach {branch}` \
184             clears that too"
185        );
186    }
187    if let Some(parent) = stack::stacked_parent_of(branch)? {
188        return Ok(parent);
189    }
190    stack::trunk_branch(&git::local_branches()?)
191        .filter(|trunk| trunk != branch)
192        .ok_or_else(|| {
193            anyhow!("could not determine a base for {branch}; adopt it onto a parent first")
194        })
195}
196
197/// Create a branch per plan entry (bottom-up), reusing the original branch as
198/// the leaf. Each branch's parent is the one below it; the bottom's is `base`.
199fn apply(branch: &str, base: &str, plan: &[Plan], dry_run: bool) -> Result<()> {
200    if !dry_run {
201        stack::snapshot("split");
202    }
203    for (index, entry) in plan.iter().enumerate() {
204        let parent = if index == 0 {
205            base
206        } else {
207            &plan[index - 1].name
208        };
209        let leaf = index == plan.len() - 1;
210        if leaf {
211            anstream::println!(
212                "{} {} {} onto {}",
213                verb(dry_run),
214                style::branch(&entry.name),
215                style::dim("(leaf)"),
216                style::branch(parent)
217            );
218        } else {
219            anstream::println!(
220                "{} {} at {} onto {}",
221                verb(dry_run),
222                style::branch(&entry.name),
223                style::dim(&entry.sha[..8]),
224                style::branch(parent)
225            );
226        }
227        if dry_run {
228            continue;
229        }
230        if !leaf {
231            git::create_branch_at(&entry.name, &entry.sha)?;
232        }
233        stack::set_parent(&entry.name, parent)?;
234        stack::record_base(&entry.name, parent);
235    }
236    if !dry_run {
237        anstream::println!(
238            "{}",
239            style::success(&format!("split {branch} into {} branches", plan.len()))
240        );
241    }
242    Ok(())
243}
244
245fn verb(dry_run: bool) -> &'static str {
246    if dry_run { "would create" } else { "created" }
247}
248
249/// A branch-name slug from a commit subject: lowercase, non-alphanumeric runs
250/// collapsed to a single dash, trimmed, capped in length. Empty input (e.g. an
251/// all-punctuation subject) falls back to "branch".
252fn slugify(subject: &str) -> String {
253    let mut slug = String::new();
254    let mut pending_dash = false;
255    for ch in subject.chars() {
256        if ch.is_ascii_alphanumeric() {
257            if pending_dash {
258                slug.push('-');
259                pending_dash = false;
260            }
261            slug.push(ch.to_ascii_lowercase());
262        } else if !slug.is_empty() {
263            pending_dash = true;
264        }
265        if slug.len() >= 50 {
266            break;
267        }
268    }
269    if slug.is_empty() {
270        "branch".to_owned()
271    } else {
272        slug
273    }
274}
275
276/// Make `base` unique against names already taken, appending -2, -3, ... and
277/// recording the result so later calls avoid it too.
278fn unique_name(base: &str, used: &mut std::collections::BTreeSet<String>) -> String {
279    if used.insert(base.to_owned()) {
280        return base.to_owned();
281    }
282    let mut suffix = 2;
283    loop {
284        let candidate = format!("{base}-{suffix}");
285        if used.insert(candidate.clone()) {
286            return candidate;
287        }
288        suffix += 1;
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn slugify_lowercases_and_dashes() {
298        assert_eq!(slugify("Fix the thing"), "fix-the-thing");
299        assert_eq!(slugify("Add API endpoint (v2)"), "add-api-endpoint-v2");
300        assert_eq!(slugify("  leading/trailing  "), "leading-trailing");
301    }
302
303    #[test]
304    fn slugify_falls_back_when_empty() {
305        assert_eq!(slugify("!!!"), "branch");
306        assert_eq!(slugify(""), "branch");
307    }
308
309    #[test]
310    fn group_starts_always_includes_the_bottom_and_dedups() {
311        assert_eq!(group_starts(&[1, 2], 3), vec![0, 1, 2]); // all checked -> one per commit
312        assert_eq!(group_starts(&[1], 3), vec![0, 1]); // the top commit folds into group 2
313        assert_eq!(group_starts(&[], 3), vec![0]); // everything folds into one branch
314        assert_eq!(group_starts(&[0, 2], 3), vec![0, 2]); // checking the bottom is moot
315    }
316
317    #[test]
318    fn unique_name_appends_a_counter_on_collision() {
319        let mut used = std::collections::BTreeSet::new();
320        assert_eq!(unique_name("fix", &mut used), "fix");
321        assert_eq!(unique_name("fix", &mut used), "fix-2");
322        assert_eq!(unique_name("fix", &mut used), "fix-3");
323    }
324}