1use std::collections::BTreeMap;
2
3use anyhow::{Result, bail};
4use clap::ArgAction;
5
6use crate::cli::{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::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(UpdateRefsMode::Enabled, PushMode::Disabled, false)
139 } else {
140 report_push_hint(&path)
141 }
142}
143
144fn group_targets(routes: &[Route]) -> Vec<(String, Vec<&Route>)> {
147 let mut order = Vec::new();
148 let mut by_sha: BTreeMap<String, Vec<&Route>> = BTreeMap::new();
149 for route in routes {
150 if let Route::Absorb { sha, .. } = route {
151 if !by_sha.contains_key(sha) {
152 order.push(sha.clone());
153 }
154 by_sha.entry(sha.clone()).or_default().push(route);
155 }
156 }
157 order
158 .into_iter()
159 .map(|sha| {
160 let hunks = by_sha.remove(&sha).unwrap_or_default();
161 (sha, hunks)
162 })
163 .collect()
164}
165
166fn build_patch(hunks: &[&Route]) -> String {
169 struct FilePatch<'a> {
170 file: &'a str,
171 header: &'a [String],
172 bodies: Vec<&'a [String]>,
173 }
174
175 let mut by_file: Vec<FilePatch> = Vec::new();
176 for route in hunks {
177 if let Route::Absorb {
178 file, header, body, ..
179 } = route
180 {
181 match by_file.iter_mut().find(|patch| patch.file == file) {
182 Some(patch) => patch.bodies.push(body),
183 None => by_file.push(FilePatch {
184 file,
185 header,
186 bodies: vec![body],
187 }),
188 }
189 }
190 }
191
192 let mut patch = String::new();
193 for file in by_file {
194 for line in file.header {
195 patch.push_str(line);
196 patch.push('\n');
197 }
198 for body in file.bodies {
199 for line in body {
200 patch.push_str(line);
201 patch.push('\n');
202 }
203 }
204 }
205 patch
206}
207
208fn absorb_base(path: &[String]) -> Result<String> {
211 let Some(bottom) = path.first() else {
212 bail!("current branch is not in a stack");
213 };
214 if let Some(parent) = stack::parent_of(bottom)? {
215 return Ok(parent);
216 }
217 if let Some(base) = stack::base_of(bottom)? {
218 return Ok(base);
219 }
220 bail!("could not determine the stack base for {bottom}")
221}
222
223fn commit_owners(current: &str) -> Result<BTreeMap<String, String>> {
227 let path = stack::path_from_root(current)?; let mut owners = BTreeMap::new();
229
230 for (index, branch) in path.iter().enumerate() {
231 let parent = if index == 0 {
232 stack::parent_of(branch)?
233 } else {
234 Some(path[index - 1].clone())
235 };
236 let range = match parent {
237 Some(parent) => format!("{parent}..{branch}"),
238 None => match stack::base_of(branch)? {
239 Some(base) => format!("{base}..{branch}"),
240 None => continue,
241 },
242 };
243 for sha in git::rev_list(&range)? {
244 owners.entry(sha).or_insert_with(|| branch.clone());
245 }
246 }
247 Ok(owners)
248}
249
250struct FileDiff {
253 path: String,
254 from_path: String,
255 header: Vec<String>,
256 hunks: Vec<RawHunk>,
257}
258
259struct RawHunk {
260 pre_start: usize,
261 pre_len: usize,
262 body: Vec<String>,
263}
264
265impl FileDiff {
266 fn into_routes(self, owners: &BTreeMap<String, String>) -> Vec<Result<Route>> {
268 let file = self.path;
269 let header = self.header;
270 self.hunks
271 .into_iter()
272 .map(|hunk| route_hunk(&file, &header, hunk, owners))
273 .collect()
274 }
275}
276
277enum Route {
278 Absorb {
279 file: String,
280 line: usize,
281 header: Vec<String>,
282 body: Vec<String>,
283 branch: String,
284 sha: String,
285 subject: String,
286 },
287 Skip {
288 file: String,
289 line: usize,
290 reason: String,
291 },
292}
293
294fn route_hunk(
295 file: &str,
296 header: &[String],
297 hunk: RawHunk,
298 owners: &BTreeMap<String, String>,
299) -> Result<Route> {
300 let skip = |reason: &str| {
301 Ok(Route::Skip {
302 file: file.to_owned(),
303 line: hunk.pre_start,
304 reason: reason.to_owned(),
305 })
306 };
307
308 if hunk.pre_len == 0 {
309 return skip("added lines - no commit to attribute");
310 }
311
312 let shas = git::blame_line_shas(file, hunk.pre_start, hunk.pre_len)?;
313 match shas.as_slice() {
314 [] => skip("could not attribute"),
315 [sha] => match owners.get(sha) {
316 Some(branch) => Ok(Route::Absorb {
317 file: file.to_owned(),
318 line: hunk.pre_start,
319 header: header.to_vec(),
320 body: hunk.body,
321 branch: branch.clone(),
322 sha: sha.clone(),
323 subject: git::commit_subject(sha)?,
324 }),
325 None => skip("owned by a commit outside the stack"),
326 },
327 _ => skip("spans multiple commits"),
328 }
329}
330
331fn parse_diff(diff: &str) -> Vec<FileDiff> {
333 let mut files: Vec<FileDiff> = Vec::new();
334
335 for line in diff.lines() {
336 if line.starts_with("diff --git ") {
337 files.push(FileDiff {
338 path: String::new(),
339 from_path: String::new(),
340 header: vec![line.to_owned()],
341 hunks: Vec::new(),
342 });
343 continue;
344 }
345 let Some(file) = files.last_mut() else {
346 continue;
347 };
348
349 if let Some(path) = line.strip_prefix("--- ") {
350 file.from_path = strip_diff_prefix(path);
351 file.header.push(line.to_owned());
352 } else if let Some(path) = line.strip_prefix("+++ ") {
353 file.path = match strip_diff_prefix(path).as_str() {
354 "/dev/null" => file.from_path.clone(),
355 resolved => resolved.to_owned(),
356 };
357 file.header.push(line.to_owned());
358 } else if let Some(rest) = line.strip_prefix("@@ ") {
359 if let Some((pre_start, pre_len)) = parse_pre_image(rest) {
360 file.hunks.push(RawHunk {
361 pre_start,
362 pre_len,
363 body: vec![line.to_owned()],
364 });
365 }
366 } else if let Some(hunk) = file.hunks.last_mut() {
367 hunk.body.push(line.to_owned());
368 } else {
369 file.header.push(line.to_owned());
370 }
371 }
372 files
373}
374
375fn strip_diff_prefix(path: &str) -> String {
377 path.strip_prefix("a/")
378 .or_else(|| path.strip_prefix("b/"))
379 .unwrap_or(path)
380 .to_owned()
381}
382
383fn parse_pre_image(rest: &str) -> Option<(usize, usize)> {
386 let token = rest.split_whitespace().next()?.strip_prefix('-')?;
387 let (start, len) = match token.split_once(',') {
388 Some((start, len)) => (start.parse().ok()?, len.parse().ok()?),
389 None => (token.parse().ok()?, 1),
390 };
391 Some((start, len))
392}
393
394fn print_plan(routes: &[Route]) {
395 let absorbed = routes
396 .iter()
397 .filter(|route| matches!(route, Route::Absorb { .. }))
398 .count();
399 anstream::println!(
400 "absorb plan ({absorbed} of {} hunk{})",
401 routes.len(),
402 if routes.len() == 1 { "" } else { "s" }
403 );
404 print_absorb_lines(routes);
405 print_skips(routes);
406}
407
408fn report_absorbed(targets: &[(String, Vec<&Route>)], routes: &[Route]) {
409 let hunks: usize = targets.iter().map(|(_, hunks)| hunks.len()).sum();
410 anstream::println!(
411 "{}",
412 style::success(&format!(
413 "absorbed {hunks} hunk{} into {} commit{}",
414 if hunks == 1 { "" } else { "s" },
415 targets.len(),
416 if targets.len() == 1 { "" } else { "s" }
417 ))
418 );
419 print_absorb_lines(routes);
420 print_skips(routes);
421}
422
423fn report_push_hint(branches: &[String]) -> Result<()> {
426 let remote = settings::remote()?;
427 anstream::println!("remote branches may be stale; push them with:");
428 anstream::println!(
429 "{}",
430 style::dim(&format!(
431 " git push --force-with-lease {remote} {}",
432 branches.join(" ")
433 ))
434 );
435 Ok(())
436}
437
438fn print_absorb_lines(routes: &[Route]) {
439 for route in routes {
440 if let Route::Absorb {
441 file,
442 line,
443 branch,
444 sha,
445 subject,
446 ..
447 } = route
448 {
449 anstream::println!(
450 " {file}:{line} -> {} {}",
451 style::branch(branch),
452 style::dim(&format!("{} {subject}", &sha[..7.min(sha.len())]))
453 );
454 }
455 }
456}
457
458fn print_skips(routes: &[Route]) {
459 let skipped: Vec<&Route> = routes
460 .iter()
461 .filter(|route| matches!(route, Route::Skip { .. }))
462 .collect();
463 if skipped.is_empty() {
464 return;
465 }
466 anstream::println!("{}", style::dim("unabsorbed (left in place):"));
467 for route in skipped {
468 if let Route::Skip { file, line, reason } = route {
469 anstream::println!(" {file}:{line} {}", style::dim(reason));
470 }
471 }
472}