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