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::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        report_push_hint(&path)
146    }
147}
148
149/// The commit each target absorbs into, with the hunks bound for it, oldest
150/// commit first.
151fn 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
171/// Reassemble a patch for one target: each file's header once, then its
172/// hunks, in file order.
173fn 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
213/// The commit `base..HEAD` rebases onto: the bottom branch's parent, or its
214/// recorded fork point when the bottom is rootless.
215fn 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
228/// Map every stack commit (current branch and below) to the branch that owns
229/// it, so a blamed sha resolves to a branch. Commits outside this map - the
230/// trunk's, or older - are not absorbable.
231fn commit_owners(current: &str) -> Result<BTreeMap<String, String>> {
232    let path = stack::path_from_root(current)?; // bottom -> current, parent-first
233    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
255/// A diff for one file: its header lines (verbatim, for re-applying) and its
256/// hunks.
257struct 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    /// Attribute each hunk to a commit (or a reason it cannot be).
272    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
336/// Parse a `git diff --unified=0` into per-file diffs.
337fn 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
380/// `a/foo`, `b/foo`, or `/dev/null` -> the bare path.
381fn 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
388/// From a hunk header body like "-12,3 +12,2 @@ ...", read the pre-image
389/// `(start, len)`. A missing length means one line.
390fn 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
428/// The push hint for a single line of rewritten branches. (When the stack
429/// forks, the phase-2 restack prints its own hint covering every branch.)
430fn 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}