Skip to main content

git_stk/commands/
absorb.rs

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/// Amend staged fixes into the stack commits that introduced the lines they
11/// touch. Each hunk is routed to the commit a `git blame` attributes its
12/// lines to; hunks that cannot be attributed are left in place.
13#[derive(Debug, clap::Args)]
14pub struct Absorb {
15    /// Show the hunk -> commit routing without changing anything.
16    #[arg(long, action = ArgAction::SetTrue)]
17    dry_run: bool,
18    /// Also absorb unstaged tracked changes, not just staged ones
19    /// (overrides stk.absorbIncludeUnstaged).
20    #[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(&current)?;
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(&current, routes)
51    }
52}
53
54/// Fold the attributed hunks into their target commits and settle the stack.
55/// Atomic: if the rewrite hits a conflict it is aborted and rolled back so
56/// the working tree is left exactly as it was.
57fn apply(current: &str, routes: Vec<Route>) -> Result<()> {
58    let path = stack::path_from_root(current)?;
59
60    // Branches that fork off the path keep their old parents until they are
61    // restacked after the fold. Note them now to decide whether that second
62    // pass is needed.
63    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    // Phase 1: commit each target's hunks as a fixup! of its commit, then an
85    // autosquash rebase folds them in. `--update-refs` carries the path's
86    // branch refs. Atomic: a conflict here rolls back, nothing changed.
87    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    // Unattributed hunks stay in the worktree; stash them so the rebase runs
97    // on a clean tree, and restore them afterward.
98    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    // Phase 2: the fold rewrote the path's commits, so any branch forking off
132    // it still points at the old ones. Restack settles those onto the
133    // rewritten parents (and prints the push hint for the whole stack). A
134    // conflict here is resumable - `git stk continue`/`abort`, and `git stk
135    // undo` reverts the whole absorb.
136    if forked {
137        stack::restack(UpdateRefsMode::Enabled, PushMode::Disabled, false)
138    } else {
139        report_push_hint(&path)
140    }
141}
142
143/// The commit each target absorbs into, with the hunks bound for it, oldest
144/// commit first.
145fn 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
165/// Reassemble a patch for one target: each file's header once, then its
166/// hunks, in file order.
167fn 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
207/// The commit `base..HEAD` rebases onto: the bottom branch's parent, or its
208/// recorded fork point when the bottom is rootless.
209fn 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
222/// Map every stack commit (current branch and below) to the branch that owns
223/// it, so a blamed sha resolves to a branch. Commits outside this map - the
224/// trunk's, or older - are not absorbable.
225fn commit_owners(current: &str) -> Result<BTreeMap<String, String>> {
226    let path = stack::path_from_root(current)?; // bottom -> current, parent-first
227    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
249/// A diff for one file: its header lines (verbatim, for re-applying) and its
250/// hunks.
251struct 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    /// Attribute each hunk to a commit (or a reason it cannot be).
266    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
330/// Parse a `git diff --unified=0` into per-file diffs.
331fn 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
374/// `a/foo`, `b/foo`, or `/dev/null` -> the bare path.
375fn 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
382/// From a hunk header body like "-12,3 +12,2 @@ ...", read the pre-image
383/// `(start, len)`. A missing length means one line.
384fn 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
422/// The push hint for a single line of rewritten branches. (When the stack
423/// forks, the phase-2 restack prints its own hint covering every branch.)
424fn 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}