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)]
19pub struct Split {
20 #[arg(long, action = ArgAction::SetTrue)]
23 per_commit: bool,
24 #[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
39struct 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 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 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
81fn 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 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 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
158fn 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
170fn 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
182fn 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
234fn 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
261fn 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]); 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]); }
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}