git_stk/commands/
split.rs1use 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#[derive(Debug, clap::Args)]
20pub struct Split {
21 #[arg(long, action = ArgAction::SetTrue)]
24 per_commit: bool,
25 #[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
40struct 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 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 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
82fn 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 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 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
159fn 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
171fn 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
183fn 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
235fn 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
262fn 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]); assert_eq!(group_starts(&[1], 3), vec![0, 1]); assert_eq!(group_starts(&[], 3), vec![0]); assert_eq!(group_starts(&[0, 2], 3), vec![0, 2]); }
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}