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