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