Skip to main content

git_stk/commands/
absorb.rs

1use std::collections::BTreeMap;
2
3use anyhow::{Result, bail};
4use clap::ArgAction;
5
6use crate::commands::Run;
7use crate::{git, settings, stack, style};
8
9/// Amend staged fixes into the stack commits that introduced the lines they
10/// touch. Each hunk is routed to the commit a `git blame` attributes its
11/// lines to; hunks that cannot be attributed are left in place.
12#[derive(Debug, clap::Args)]
13pub struct Absorb {
14    /// Show the hunk -> commit routing without changing anything.
15    #[arg(long, action = ArgAction::SetTrue)]
16    dry_run: bool,
17    /// Also absorb unstaged tracked changes, not just staged ones
18    /// (overrides stk.absorbIncludeUnstaged).
19    #[arg(long, action = ArgAction::SetTrue)]
20    include_unstaged: bool,
21}
22
23impl Run for Absorb {
24    fn run(self) -> Result<()> {
25        let include_unstaged =
26            self.include_unstaged || settings::bool_setting(settings::ABSORB_INCLUDE_UNSTAGED_KEY)?;
27        let cached = !include_unstaged;
28
29        let diff = git::diff_against_head(cached)?;
30        if diff.trim().is_empty() {
31            bail!(
32                "no {} changes to absorb",
33                if cached { "staged" } else { "tracked" }
34            );
35        }
36
37        let current = git::current_branch()?;
38        let owners = commit_owners(&current)?;
39        let routes: Vec<Route> = parse_diff(&diff)
40            .into_iter()
41            .flat_map(|file| file.into_routes(&owners))
42            .collect::<Result<_>>()?;
43
44        if self.dry_run {
45            print_plan(&routes);
46            return Ok(());
47        }
48
49        apply(&current, routes)
50    }
51}
52
53/// Fold the attributed hunks into their target commits and settle the stack.
54/// Atomic: if the rewrite hits a conflict it is aborted and rolled back so
55/// the working tree is left exactly as it was.
56fn apply(current: &str, routes: Vec<Route>) -> Result<()> {
57    // The rewrite leans on `rebase --update-refs` to carry every affected
58    // branch ref, so it must cover the whole line: run from a leaf, with no
59    // branch forking off the path below it.
60    let path = stack::path_from_root(current)?;
61    for branch in &path {
62        for child in stack::children_for_branch(branch)? {
63            if !path.contains(&child) {
64                bail!(
65                    "{branch} has a branch above it ({child}); run `git stk absorb` \
66                     from the top of a single line of the stack"
67                );
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    // Move every change to the worktree, then restage and commit each
85    // target's hunks as a fixup! of its commit.
86    git::reset_index()?;
87    for (sha, hunks) in &targets {
88        let staged = git::apply_cached(&build_patch(hunks)).and_then(|()| git::commit_fixup(sha));
89        if let Err(error) = staged {
90            let _ = git::reset_soft(&orig_head);
91            return Err(error.context("could not stage the fixes to absorb"));
92        }
93    }
94
95    // Unattributed hunks stay in the worktree; stash them so the rebase runs
96    // on a clean tree, and restore them afterward.
97    let stashed = !git::worktree_is_clean()?;
98    if stashed {
99        git::stash_push()?;
100    }
101
102    if git::rebase_autosquash(&base, true).is_err() {
103        let _ = git::rebase_abort();
104        let _ = git::reset_soft(&orig_head);
105        if stashed {
106            let _ = git::stash_pop();
107        }
108        bail!(
109            "absorb hit a conflict folding the fixes in - rolled back, nothing changed; \
110             amend those commits manually (`git stk down`, edit, `git stk restack`)"
111        );
112    }
113
114    if stashed {
115        git::stash_pop()?;
116    }
117    for (index, branch) in path.iter().enumerate() {
118        let parent = if index == 0 {
119            stack::parent_for_branch(branch)?
120        } else {
121            Some(path[index - 1].clone())
122        };
123        if let Some(parent) = parent {
124            stack::record_base(branch, &parent);
125        }
126    }
127
128    report_applied(&targets, &routes, &path)
129}
130
131/// The commit each target absorbs into, with the hunks bound for it, oldest
132/// commit first.
133fn group_targets(routes: &[Route]) -> Vec<(String, Vec<&Route>)> {
134    let mut order = Vec::new();
135    let mut by_sha: BTreeMap<String, Vec<&Route>> = BTreeMap::new();
136    for route in routes {
137        if let Route::Absorb { sha, .. } = route {
138            if !by_sha.contains_key(sha) {
139                order.push(sha.clone());
140            }
141            by_sha.entry(sha.clone()).or_default().push(route);
142        }
143    }
144    order
145        .into_iter()
146        .map(|sha| {
147            let hunks = by_sha.remove(&sha).unwrap_or_default();
148            (sha, hunks)
149        })
150        .collect()
151}
152
153/// Reassemble a patch for one target: each file's header once, then its
154/// hunks, in file order.
155fn build_patch(hunks: &[&Route]) -> String {
156    struct FilePatch<'a> {
157        file: &'a str,
158        header: &'a [String],
159        bodies: Vec<&'a [String]>,
160    }
161
162    let mut by_file: Vec<FilePatch> = Vec::new();
163    for route in hunks {
164        if let Route::Absorb {
165            file, header, body, ..
166        } = route
167        {
168            match by_file.iter_mut().find(|patch| patch.file == file) {
169                Some(patch) => patch.bodies.push(body),
170                None => by_file.push(FilePatch {
171                    file,
172                    header,
173                    bodies: vec![body],
174                }),
175            }
176        }
177    }
178
179    let mut patch = String::new();
180    for file in by_file {
181        for line in file.header {
182            patch.push_str(line);
183            patch.push('\n');
184        }
185        for body in file.bodies {
186            for line in body {
187                patch.push_str(line);
188                patch.push('\n');
189            }
190        }
191    }
192    patch
193}
194
195/// The commit `base..HEAD` rebases onto: the bottom branch's parent, or its
196/// recorded fork point when the bottom is rootless.
197fn absorb_base(path: &[String]) -> Result<String> {
198    let Some(bottom) = path.first() else {
199        bail!("current branch is not in a stack");
200    };
201    if let Some(parent) = stack::parent_for_branch(bottom)? {
202        return Ok(parent);
203    }
204    if let Some(base) = stack::base_for_branch(bottom)? {
205        return Ok(base);
206    }
207    bail!("could not determine the stack base for {bottom}")
208}
209
210/// Map every stack commit (current branch and below) to the branch that owns
211/// it, so a blamed sha resolves to a branch. Commits outside this map - the
212/// trunk's, or older - are not absorbable.
213fn commit_owners(current: &str) -> Result<BTreeMap<String, String>> {
214    let path = stack::path_from_root(current)?; // bottom -> current, parent-first
215    let mut owners = BTreeMap::new();
216
217    for (index, branch) in path.iter().enumerate() {
218        let parent = if index == 0 {
219            stack::parent_for_branch(branch)?
220        } else {
221            Some(path[index - 1].clone())
222        };
223        let range = match parent {
224            Some(parent) => format!("{parent}..{branch}"),
225            None => match stack::base_for_branch(branch)? {
226                Some(base) => format!("{base}..{branch}"),
227                None => continue,
228            },
229        };
230        for sha in git::rev_list(&range)? {
231            owners.entry(sha).or_insert_with(|| branch.clone());
232        }
233    }
234    Ok(owners)
235}
236
237/// A diff for one file: its header lines (verbatim, for re-applying) and its
238/// hunks.
239struct FileDiff {
240    path: String,
241    from_path: String,
242    header: Vec<String>,
243    hunks: Vec<RawHunk>,
244}
245
246struct RawHunk {
247    pre_start: usize,
248    pre_len: usize,
249    body: Vec<String>,
250}
251
252impl FileDiff {
253    /// Attribute each hunk to a commit (or a reason it cannot be).
254    fn into_routes(self, owners: &BTreeMap<String, String>) -> Vec<Result<Route>> {
255        let file = self.path;
256        let header = self.header;
257        self.hunks
258            .into_iter()
259            .map(|hunk| route_hunk(&file, &header, hunk, owners))
260            .collect()
261    }
262}
263
264enum Route {
265    Absorb {
266        file: String,
267        line: usize,
268        header: Vec<String>,
269        body: Vec<String>,
270        branch: String,
271        sha: String,
272        subject: String,
273    },
274    Skip {
275        file: String,
276        line: usize,
277        reason: String,
278    },
279}
280
281fn route_hunk(
282    file: &str,
283    header: &[String],
284    hunk: RawHunk,
285    owners: &BTreeMap<String, String>,
286) -> Result<Route> {
287    let skip = |reason: &str| {
288        Ok(Route::Skip {
289            file: file.to_owned(),
290            line: hunk.pre_start,
291            reason: reason.to_owned(),
292        })
293    };
294
295    if hunk.pre_len == 0 {
296        return skip("added lines - no commit to attribute");
297    }
298
299    let shas = git::blame_line_shas(file, hunk.pre_start, hunk.pre_len)?;
300    match shas.as_slice() {
301        [] => skip("could not attribute"),
302        [sha] => match owners.get(sha) {
303            Some(branch) => Ok(Route::Absorb {
304                file: file.to_owned(),
305                line: hunk.pre_start,
306                header: header.to_vec(),
307                body: hunk.body,
308                branch: branch.clone(),
309                sha: sha.clone(),
310                subject: git::commit_subject(sha)?,
311            }),
312            None => skip("owned by a commit outside the stack"),
313        },
314        _ => skip("spans multiple commits"),
315    }
316}
317
318/// Parse a `git diff --unified=0` into per-file diffs.
319fn parse_diff(diff: &str) -> Vec<FileDiff> {
320    let mut files: Vec<FileDiff> = Vec::new();
321
322    for line in diff.lines() {
323        if line.starts_with("diff --git ") {
324            files.push(FileDiff {
325                path: String::new(),
326                from_path: String::new(),
327                header: vec![line.to_owned()],
328                hunks: Vec::new(),
329            });
330            continue;
331        }
332        let Some(file) = files.last_mut() else {
333            continue;
334        };
335
336        if let Some(path) = line.strip_prefix("--- ") {
337            file.from_path = strip_diff_prefix(path);
338            file.header.push(line.to_owned());
339        } else if let Some(path) = line.strip_prefix("+++ ") {
340            file.path = match strip_diff_prefix(path).as_str() {
341                "/dev/null" => file.from_path.clone(),
342                resolved => resolved.to_owned(),
343            };
344            file.header.push(line.to_owned());
345        } else if let Some(rest) = line.strip_prefix("@@ ") {
346            if let Some((pre_start, pre_len)) = parse_pre_image(rest) {
347                file.hunks.push(RawHunk {
348                    pre_start,
349                    pre_len,
350                    body: vec![line.to_owned()],
351                });
352            }
353        } else if let Some(hunk) = file.hunks.last_mut() {
354            hunk.body.push(line.to_owned());
355        } else {
356            file.header.push(line.to_owned());
357        }
358    }
359    files
360}
361
362/// `a/foo`, `b/foo`, or `/dev/null` -> the bare path.
363fn strip_diff_prefix(path: &str) -> String {
364    path.strip_prefix("a/")
365        .or_else(|| path.strip_prefix("b/"))
366        .unwrap_or(path)
367        .to_owned()
368}
369
370/// From a hunk header body like "-12,3 +12,2 @@ ...", read the pre-image
371/// `(start, len)`. A missing length means one line.
372fn parse_pre_image(rest: &str) -> Option<(usize, usize)> {
373    let token = rest.split_whitespace().next()?.strip_prefix('-')?;
374    let (start, len) = match token.split_once(',') {
375        Some((start, len)) => (start.parse().ok()?, len.parse().ok()?),
376        None => (token.parse().ok()?, 1),
377    };
378    Some((start, len))
379}
380
381fn print_plan(routes: &[Route]) {
382    let absorbed = routes
383        .iter()
384        .filter(|route| matches!(route, Route::Absorb { .. }))
385        .count();
386    anstream::println!(
387        "absorb plan ({absorbed} of {} hunk{})",
388        routes.len(),
389        if routes.len() == 1 { "" } else { "s" }
390    );
391    print_absorb_lines(routes);
392    print_skips(routes);
393}
394
395fn report_applied(
396    targets: &[(String, Vec<&Route>)],
397    routes: &[Route],
398    path: &[String],
399) -> Result<()> {
400    let hunks: usize = targets.iter().map(|(_, hunks)| hunks.len()).sum();
401    anstream::println!(
402        "{}",
403        style::success(&format!(
404            "absorbed {hunks} hunk{} into {} commit{}",
405            if hunks == 1 { "" } else { "s" },
406            targets.len(),
407            if targets.len() == 1 { "" } else { "s" }
408        ))
409    );
410    print_absorb_lines(routes);
411    print_skips(routes);
412
413    let remote = settings::remote()?;
414    anstream::println!("remote branches may be stale; push them with:");
415    anstream::println!(
416        "{}",
417        style::dim(&format!(
418            "  git push --force-with-lease {remote} {}",
419            path.join(" ")
420        ))
421    );
422    Ok(())
423}
424
425fn print_absorb_lines(routes: &[Route]) {
426    for route in routes {
427        if let Route::Absorb {
428            file,
429            line,
430            branch,
431            sha,
432            subject,
433            ..
434        } = route
435        {
436            anstream::println!(
437                "  {file}:{line} -> {} {}",
438                style::branch(branch),
439                style::dim(&format!("{} {subject}", &sha[..7.min(sha.len())]))
440            );
441        }
442    }
443}
444
445fn print_skips(routes: &[Route]) {
446    let skipped: Vec<&Route> = routes
447        .iter()
448        .filter(|route| matches!(route, Route::Skip { .. }))
449        .collect();
450    if skipped.is_empty() {
451        return;
452    }
453    anstream::println!("{}", style::dim("unabsorbed (left in place):"));
454    for route in skipped {
455        if let Route::Skip { file, line, reason } = route {
456            anstream::println!("  {file}:{line} {}", style::dim(reason));
457        }
458    }
459}