1use std::collections::BTreeMap;
2
3use anyhow::{Result, bail};
4use clap::ArgAction;
5
6use crate::commands::Run;
7use crate::{git, settings, stack, style};
8
9#[derive(Debug, clap::Args)]
13pub struct Absorb {
14 #[arg(long, action = ArgAction::SetTrue)]
16 dry_run: bool,
17 #[arg(long, action = ArgAction::SetTrue)]
20 include_unstaged: bool,
21}
22
23impl Run for Absorb {
24 fn run(self) -> Result<()> {
25 let include_unstaged =
26 self.include_unstaged || settings::bool_setting(settings::ABSORB_INCLUDE_UNSTAGED_KEY)?;
27 let cached = !include_unstaged;
28
29 let diff = git::diff_against_head(cached)?;
30 if diff.trim().is_empty() {
31 bail!(
32 "no {} changes to absorb",
33 if cached { "staged" } else { "tracked" }
34 );
35 }
36
37 let current = git::current_branch()?;
38 let owners = commit_owners(¤t)?;
39 let routes: Vec<Route> = parse_diff(&diff)
40 .into_iter()
41 .flat_map(|file| file.into_routes(&owners))
42 .collect::<Result<_>>()?;
43
44 if self.dry_run {
45 print_plan(&routes);
46 return Ok(());
47 }
48
49 apply(¤t, routes)
50 }
51}
52
53fn apply(current: &str, routes: Vec<Route>) -> Result<()> {
57 let path = stack::path_from_root(current)?;
61 for branch in &path {
62 for child in stack::children_for_branch(branch)? {
63 if !path.contains(&child) {
64 bail!(
65 "{branch} has a branch above it ({child}); run `git stk absorb` \
66 from the top of a single line of the stack"
67 );
68 }
69 }
70 }
71
72 let targets = group_targets(&routes);
73 if targets.is_empty() {
74 bail!("no changes could be attributed to a stack commit (try `--dry-run`)");
75 }
76 if !git::supports_rebase_update_refs()? {
77 bail!("absorb needs a Git that supports `rebase --update-refs` (2.38+)");
78 }
79
80 let base = absorb_base(&path)?;
81 stack::snapshot("absorb");
82 let orig_head = git::rev_parse("HEAD")?;
83
84 git::reset_index()?;
87 for (sha, hunks) in &targets {
88 let staged = git::apply_cached(&build_patch(hunks)).and_then(|()| git::commit_fixup(sha));
89 if let Err(error) = staged {
90 let _ = git::reset_soft(&orig_head);
91 return Err(error.context("could not stage the fixes to absorb"));
92 }
93 }
94
95 let stashed = !git::worktree_is_clean()?;
98 if stashed {
99 git::stash_push()?;
100 }
101
102 if git::rebase_autosquash(&base, true).is_err() {
103 let _ = git::rebase_abort();
104 let _ = git::reset_soft(&orig_head);
105 if stashed {
106 let _ = git::stash_pop();
107 }
108 bail!(
109 "absorb hit a conflict folding the fixes in - rolled back, nothing changed; \
110 amend those commits manually (`git stk down`, edit, `git stk restack`)"
111 );
112 }
113
114 if stashed {
115 git::stash_pop()?;
116 }
117 for (index, branch) in path.iter().enumerate() {
118 let parent = if index == 0 {
119 stack::parent_for_branch(branch)?
120 } else {
121 Some(path[index - 1].clone())
122 };
123 if let Some(parent) = parent {
124 stack::record_base(branch, &parent);
125 }
126 }
127
128 report_applied(&targets, &routes, &path)
129}
130
131fn group_targets(routes: &[Route]) -> Vec<(String, Vec<&Route>)> {
134 let mut order = Vec::new();
135 let mut by_sha: BTreeMap<String, Vec<&Route>> = BTreeMap::new();
136 for route in routes {
137 if let Route::Absorb { sha, .. } = route {
138 if !by_sha.contains_key(sha) {
139 order.push(sha.clone());
140 }
141 by_sha.entry(sha.clone()).or_default().push(route);
142 }
143 }
144 order
145 .into_iter()
146 .map(|sha| {
147 let hunks = by_sha.remove(&sha).unwrap_or_default();
148 (sha, hunks)
149 })
150 .collect()
151}
152
153fn build_patch(hunks: &[&Route]) -> String {
156 struct FilePatch<'a> {
157 file: &'a str,
158 header: &'a [String],
159 bodies: Vec<&'a [String]>,
160 }
161
162 let mut by_file: Vec<FilePatch> = Vec::new();
163 for route in hunks {
164 if let Route::Absorb {
165 file, header, body, ..
166 } = route
167 {
168 match by_file.iter_mut().find(|patch| patch.file == file) {
169 Some(patch) => patch.bodies.push(body),
170 None => by_file.push(FilePatch {
171 file,
172 header,
173 bodies: vec![body],
174 }),
175 }
176 }
177 }
178
179 let mut patch = String::new();
180 for file in by_file {
181 for line in file.header {
182 patch.push_str(line);
183 patch.push('\n');
184 }
185 for body in file.bodies {
186 for line in body {
187 patch.push_str(line);
188 patch.push('\n');
189 }
190 }
191 }
192 patch
193}
194
195fn absorb_base(path: &[String]) -> Result<String> {
198 let Some(bottom) = path.first() else {
199 bail!("current branch is not in a stack");
200 };
201 if let Some(parent) = stack::parent_for_branch(bottom)? {
202 return Ok(parent);
203 }
204 if let Some(base) = stack::base_for_branch(bottom)? {
205 return Ok(base);
206 }
207 bail!("could not determine the stack base for {bottom}")
208}
209
210fn commit_owners(current: &str) -> Result<BTreeMap<String, String>> {
214 let path = stack::path_from_root(current)?; let mut owners = BTreeMap::new();
216
217 for (index, branch) in path.iter().enumerate() {
218 let parent = if index == 0 {
219 stack::parent_for_branch(branch)?
220 } else {
221 Some(path[index - 1].clone())
222 };
223 let range = match parent {
224 Some(parent) => format!("{parent}..{branch}"),
225 None => match stack::base_for_branch(branch)? {
226 Some(base) => format!("{base}..{branch}"),
227 None => continue,
228 },
229 };
230 for sha in git::rev_list(&range)? {
231 owners.entry(sha).or_insert_with(|| branch.clone());
232 }
233 }
234 Ok(owners)
235}
236
237struct FileDiff {
240 path: String,
241 from_path: String,
242 header: Vec<String>,
243 hunks: Vec<RawHunk>,
244}
245
246struct RawHunk {
247 pre_start: usize,
248 pre_len: usize,
249 body: Vec<String>,
250}
251
252impl FileDiff {
253 fn into_routes(self, owners: &BTreeMap<String, String>) -> Vec<Result<Route>> {
255 let file = self.path;
256 let header = self.header;
257 self.hunks
258 .into_iter()
259 .map(|hunk| route_hunk(&file, &header, hunk, owners))
260 .collect()
261 }
262}
263
264enum Route {
265 Absorb {
266 file: String,
267 line: usize,
268 header: Vec<String>,
269 body: Vec<String>,
270 branch: String,
271 sha: String,
272 subject: String,
273 },
274 Skip {
275 file: String,
276 line: usize,
277 reason: String,
278 },
279}
280
281fn route_hunk(
282 file: &str,
283 header: &[String],
284 hunk: RawHunk,
285 owners: &BTreeMap<String, String>,
286) -> Result<Route> {
287 let skip = |reason: &str| {
288 Ok(Route::Skip {
289 file: file.to_owned(),
290 line: hunk.pre_start,
291 reason: reason.to_owned(),
292 })
293 };
294
295 if hunk.pre_len == 0 {
296 return skip("added lines - no commit to attribute");
297 }
298
299 let shas = git::blame_line_shas(file, hunk.pre_start, hunk.pre_len)?;
300 match shas.as_slice() {
301 [] => skip("could not attribute"),
302 [sha] => match owners.get(sha) {
303 Some(branch) => Ok(Route::Absorb {
304 file: file.to_owned(),
305 line: hunk.pre_start,
306 header: header.to_vec(),
307 body: hunk.body,
308 branch: branch.clone(),
309 sha: sha.clone(),
310 subject: git::commit_subject(sha)?,
311 }),
312 None => skip("owned by a commit outside the stack"),
313 },
314 _ => skip("spans multiple commits"),
315 }
316}
317
318fn parse_diff(diff: &str) -> Vec<FileDiff> {
320 let mut files: Vec<FileDiff> = Vec::new();
321
322 for line in diff.lines() {
323 if line.starts_with("diff --git ") {
324 files.push(FileDiff {
325 path: String::new(),
326 from_path: String::new(),
327 header: vec![line.to_owned()],
328 hunks: Vec::new(),
329 });
330 continue;
331 }
332 let Some(file) = files.last_mut() else {
333 continue;
334 };
335
336 if let Some(path) = line.strip_prefix("--- ") {
337 file.from_path = strip_diff_prefix(path);
338 file.header.push(line.to_owned());
339 } else if let Some(path) = line.strip_prefix("+++ ") {
340 file.path = match strip_diff_prefix(path).as_str() {
341 "/dev/null" => file.from_path.clone(),
342 resolved => resolved.to_owned(),
343 };
344 file.header.push(line.to_owned());
345 } else if let Some(rest) = line.strip_prefix("@@ ") {
346 if let Some((pre_start, pre_len)) = parse_pre_image(rest) {
347 file.hunks.push(RawHunk {
348 pre_start,
349 pre_len,
350 body: vec![line.to_owned()],
351 });
352 }
353 } else if let Some(hunk) = file.hunks.last_mut() {
354 hunk.body.push(line.to_owned());
355 } else {
356 file.header.push(line.to_owned());
357 }
358 }
359 files
360}
361
362fn strip_diff_prefix(path: &str) -> String {
364 path.strip_prefix("a/")
365 .or_else(|| path.strip_prefix("b/"))
366 .unwrap_or(path)
367 .to_owned()
368}
369
370fn parse_pre_image(rest: &str) -> Option<(usize, usize)> {
373 let token = rest.split_whitespace().next()?.strip_prefix('-')?;
374 let (start, len) = match token.split_once(',') {
375 Some((start, len)) => (start.parse().ok()?, len.parse().ok()?),
376 None => (token.parse().ok()?, 1),
377 };
378 Some((start, len))
379}
380
381fn print_plan(routes: &[Route]) {
382 let absorbed = routes
383 .iter()
384 .filter(|route| matches!(route, Route::Absorb { .. }))
385 .count();
386 anstream::println!(
387 "absorb plan ({absorbed} of {} hunk{})",
388 routes.len(),
389 if routes.len() == 1 { "" } else { "s" }
390 );
391 print_absorb_lines(routes);
392 print_skips(routes);
393}
394
395fn report_applied(
396 targets: &[(String, Vec<&Route>)],
397 routes: &[Route],
398 path: &[String],
399) -> Result<()> {
400 let hunks: usize = targets.iter().map(|(_, hunks)| hunks.len()).sum();
401 anstream::println!(
402 "{}",
403 style::success(&format!(
404 "absorbed {hunks} hunk{} into {} commit{}",
405 if hunks == 1 { "" } else { "s" },
406 targets.len(),
407 if targets.len() == 1 { "" } else { "s" }
408 ))
409 );
410 print_absorb_lines(routes);
411 print_skips(routes);
412
413 let remote = settings::remote()?;
414 anstream::println!("remote branches may be stale; push them with:");
415 anstream::println!(
416 "{}",
417 style::dim(&format!(
418 " git push --force-with-lease {remote} {}",
419 path.join(" ")
420 ))
421 );
422 Ok(())
423}
424
425fn print_absorb_lines(routes: &[Route]) {
426 for route in routes {
427 if let Route::Absorb {
428 file,
429 line,
430 branch,
431 sha,
432 subject,
433 ..
434 } = route
435 {
436 anstream::println!(
437 " {file}:{line} -> {} {}",
438 style::branch(branch),
439 style::dim(&format!("{} {subject}", &sha[..7.min(sha.len())]))
440 );
441 }
442 }
443}
444
445fn print_skips(routes: &[Route]) {
446 let skipped: Vec<&Route> = routes
447 .iter()
448 .filter(|route| matches!(route, Route::Skip { .. }))
449 .collect();
450 if skipped.is_empty() {
451 return;
452 }
453 anstream::println!("{}", style::dim("unabsorbed (left in place):"));
454 for route in skipped {
455 if let Route::Skip { file, line, reason } = route {
456 anstream::println!(" {file}:{line} {}", style::dim(reason));
457 }
458 }
459}