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