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::{FetchMode, PushMode, UpdateRefsMode};
7use crate::commands::Run;
8use crate::{git, settings, stack, style};
9
10/// Amend staged fixes into the commits that introduced the lines they touch.
11///
12/// Each hunk is routed to the commit a `git blame` attributes its lines to;
13/// hunks that cannot be attributed are left in place.
14#[derive(Debug, clap::Args)]
15pub struct Absorb {
16    /// Show the hunk -> commit routing without changing anything.
17    #[arg(long, short = 'n', action = ArgAction::SetTrue)]
18    dry_run: bool,
19    /// Also absorb unstaged tracked changes, not just staged ones, overriding
20    /// stk.absorbIncludeUnstaged.
21    #[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(&current)?;
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(&current, routes)
52    }
53}
54
55/// Fold the attributed hunks into their target commits and settle the stack.
56/// Atomic: if the rewrite hits a conflict it is aborted and rolled back so
57/// the working tree is left exactly as it was.
58fn apply(current: &str, routes: Vec<Route>) -> Result<()> {
59    let path = stack::path_from_root(current)?;
60
61    // Branches that fork off the path keep their old parents until they are
62    // restacked after the fold. Note them now to decide whether that second
63    // pass is needed.
64    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    // Phase 1: commit each target's hunks as a fixup! of its commit, then an
86    // autosquash rebase folds them in. `--update-refs` carries the path's
87    // branch refs. Atomic: a conflict here rolls back, nothing changed.
88    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    // Unattributed hunks stay in the worktree; stash them so the rebase runs
98    // on a clean tree, and restore them afterward.
99    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    // Phase 2: the fold rewrote the path's commits, so any branch forking off
133    // it still points at the old ones. Restack settles those onto the
134    // rewritten parents (and prints the push hint for the whole stack). A
135    // conflict here is resumable - `git stk continue`/`abort`, and `git stk
136    // undo` reverts the whole absorb.
137    if forked {
138        stack::restack(
139            FetchMode::Disabled,
140            UpdateRefsMode::Enabled,
141            PushMode::Disabled,
142            false,
143        )
144    } else {
145        // The base is in `path` as the branch the line sits on, but nothing
146        // rewrote it and it is not ours to move - so it must not appear in a
147        // force-push hint.
148        report_push_hint(&stack::stacked_layers(&path)?)
149    }
150}
151
152/// The commit each target absorbs into, with the hunks bound for it, oldest
153/// commit first.
154fn 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
174/// Reassemble a patch for one target: each file's header once, then its
175/// hunks, in file order.
176fn 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
216/// The commit `base..HEAD` rebases onto: the bottom branch's parent, or its
217/// recorded fork point when the bottom is rootless.
218fn absorb_base(path: &[String]) -> Result<String> {
219    let Some(bottom) = path.first() else {
220        bail!("current branch is not in a stack");
221    };
222    // A stack rooted off the trunk sits on its base: that is where the replay
223    // starts, and the base's own commits stay out of range. Without this the
224    // bottom looks rootless and absorb cannot place the stack at all.
225    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
237/// Map every stack commit (current branch and below) to the branch that owns
238/// it, so a blamed sha resolves to a branch. Commits outside this map - the
239/// trunk's, or older - are not absorbable.
240fn commit_owners(current: &str) -> Result<BTreeMap<String, String>> {
241    let path = stack::path_from_root(current)?; // bottom -> current, parent-first
242    let mut owners = BTreeMap::new();
243
244    for (index, branch) in path.iter().enumerate() {
245        // The base owns nothing absorbable: its commits are not the stack's.
246        // Skipping it outright, rather than letting a `None` parent fall
247        // through, matters because `repair` writes `stkBase` alongside the
248        // stray `stkParent` - and that fork point would put the base's own
249        // commits back in the map.
250        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
272/// A diff for one file: its header lines (verbatim, for re-applying) and its
273/// hunks.
274struct 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    /// Attribute each hunk to a commit (or a reason it cannot be).
289    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
353/// Parse a `git diff --unified=0` into per-file diffs.
354fn 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
397/// `a/foo`, `b/foo`, or `/dev/null` -> the bare path.
398fn 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
405/// From a hunk header body like "-12,3 +12,2 @@ ...", read the pre-image
406/// `(start, len)`. A missing length means one line.
407fn 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
445/// The push hint for a single line of rewritten branches. (When the stack
446/// forks, the phase-2 restack prints its own hint covering every branch.)
447fn 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}