Skip to main content

magi/
rebase.rs

1//! Rebasing the winning branch onto a moved base, with a fixer for conflicts.
2//!
3//! Both `graph::Runner::sync_to_base` (before review) and `land::Step::Rebase`
4//! (after the pull request exists) used to stop on a conflict and leave it to
5//! a person. A conflict is often a chore - two `pub mod` lines on the same
6//! spot - and what follows it (a rule the base gained meanwhile) is exactly
7//! what the gate-fix round handles, so both callers now come through
8//! [`rebase_with_fixer`], which hands the standing conflict to the fixer seat.
9//!
10//! The rules, none optional:
11//!
12//! - **magi resolves nothing itself.** No taking one side, no line merging:
13//!   the agent decides. Everything here is scaffolding around that call.
14//! - **The round is judged by what git says**, never by the fixer's report:
15//!   the rebase is no longer in progress, nothing is unmerged, no conflict
16//!   marker is left in a path that conflicted, and the base is an ancestor of
17//!   the result (an agent that ran `git rebase --abort` leaves a tidy tree
18//!   that contains no base at all). Whether the tree *builds* is left to the
19//!   review and gate that follow, which already know how to fix a breakage.
20//! - **The budget is `graph.review_rounds`, counted in
21//!   `RunState::rebase_fixes`** and saved before the fixer is called, shared
22//!   by both callers, so a park or a crash cannot hand a round back. It
23//!   touches neither the task's attempts nor land's own rebase budget.
24//! - **A failure restores the branch.** Whatever the fixer left, the branch
25//!   ref goes back to where it was and the throwaway worktree is removed, so
26//!   the fallback is exactly the old "conflict, a person decides".
27
28use std::path::Path;
29use std::time::Duration;
30
31use anyhow::{Context as _, Result};
32use jiff::Timestamp;
33
34use crate::agent::{self, Invocation};
35use crate::git;
36use crate::land::seat_of;
37use crate::prompt;
38use crate::run::{QuotaLoss, RebaseFixRecord, RunState};
39
40/// How [`rebase_with_fixer`] ended.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum Rebased {
43    /// The branch now sits on top of the base (rebased cleanly or resolved by
44    /// the fixer); the throwaway worktree is gone.
45    Applied,
46    /// It did not apply. The branch is untouched, the worktree is gone, and
47    /// the string says what was tried: rounds spent, paths still conflicted,
48    /// and what git said.
49    Stopped(String),
50}
51
52/// Longest conflict excerpt shown to the fixer, per file and in total.
53const HUNK_PER_FILE: usize = 4_000;
54const HUNK_TOTAL: usize = 16_000;
55/// Commit subjects listed per side.
56const SUBJECTS: usize = 20;
57/// Paths named in a failure reason before "and N more".
58const PATHS_IN_REASON: usize = 8;
59
60/// Rebase `branch` onto `onto` in `scratch`, giving a conflict to the fixer.
61///
62/// Re-entrant: when `scratch` already holds a rebase in progress (a run that
63/// died in the middle of a round) it carries on from there instead of
64/// starting over, and the rounds already recorded still count.
65pub async fn rebase_with_fixer(
66    state: &mut RunState,
67    scratch: &Path,
68    branch: &str,
69    onto: &str,
70) -> Result<Rebased> {
71    let repo = state.repo.clone();
72    let cap = state.config.graph.review_rounds;
73    let orig = git::rev_parse(&repo, &format!("refs/heads/{branch}")).await?;
74
75    let mut said = String::new();
76    if !git::rebase_in_progress(scratch).await {
77        match git::rebase_start(&repo, scratch, branch, onto).await? {
78            git::RebaseStart::Applied => return Ok(Rebased::Applied),
79            git::RebaseStart::Failed(why) => return Ok(Rebased::Stopped(why)),
80            git::RebaseStart::Conflicted(why) => said = why,
81        }
82    }
83    let onto_sha = git::rev_parse(&repo, onto).await?;
84    // Every path that was ever unmerged: the marker check on the finished
85    // tree looks at exactly these.
86    let mut touched: Vec<String> = Vec::new();
87
88    loop {
89        if !git::rebase_in_progress(scratch).await {
90            return finish(state, scratch, branch, &orig, &onto_sha, &touched, &said).await;
91        }
92        let paths = git::unmerged_paths(scratch).await.unwrap_or_default();
93        for p in &paths {
94            if !touched.contains(p) {
95                touched.push(p.clone());
96            }
97        }
98        let spent = state.rebase_fixes.len();
99        if spent >= cap {
100            let why = reason(spent, cap, &paths, &said, "the rounds are spent");
101            abandon(&repo, scratch, branch, &orig).await;
102            return Ok(Rebased::Stopped(why));
103        }
104
105        let winner = state
106            .winner()
107            .cloned()
108            .context("resolving a rebase conflict needs a winning candidate")?;
109        let roles = state
110            .config
111            .resolve_roles()
112            .context("resolve the roster for the rebase fix")?;
113        let (spec, seat_key) = match &roles.fixer {
114            Some(f) if f.id != winner.agent => (f.clone(), "fix".to_owned()),
115            _ => (
116                state
117                    .config
118                    .agent(&winner.agent)
119                    .cloned()
120                    .unwrap_or_else(|_| roles.implementers[winner.index].clone()),
121                format!("impl-{}", winner.label),
122            ),
123        };
124
125        let round = spent + 1;
126        let branch_subjects = subjects(scratch, &format!("{onto}..{branch}")).await;
127        let onto_subjects = subjects(scratch, &format!("{branch}..{onto}")).await;
128        let hunks = hunks(scratch, &paths);
129        let prompt_text = prompt::rebase_conflict(&prompt::RebaseConflict {
130            instruction: &state.instruction,
131            worktree: scratch,
132            branch,
133            onto,
134            paths: &paths,
135            branch_subjects: &branch_subjects,
136            onto_subjects: &onto_subjects,
137            hunks: &hunks,
138            round,
139            cap,
140            language: &state.config.graph.language,
141        });
142        let prompt_text = if state.config.cache_dir().is_some() {
143            format!("{prompt_text}\n\n{}", prompt::build_cache_note("fix", true))
144        } else {
145            prompt_text
146        };
147
148        // Spent before the call, on disk: a run killed mid-round must not
149        // get the round back.
150        state.rebase_fixes.push(RebaseFixRecord {
151            agent: spec.id.clone(),
152            paths: paths.clone(),
153            from: Some(orig.clone()),
154            finished: false,
155            error: None,
156        });
157        state.event(
158            "rebase",
159            format!(
160                "{branch} conflicts with {onto} ({} path(s)); fixer round {round} of {cap}",
161                paths.len()
162            ),
163        );
164        state.save()?;
165
166        let mut seat = seat_of(state, &seat_key, &spec.id);
167        let artifacts = agent::artifacts_dir(&state.dir());
168        let out = agent::invoke(
169            &spec,
170            &mut seat,
171            &Invocation {
172                cwd: scratch,
173                prompt: &prompt_text,
174                timeout: Duration::from_secs(state.config.graph.timeout_fix),
175                allow_write: true,
176                sessions: state.config.graph.sessions,
177                artifacts: &artifacts,
178                stem: &format!("rebase-fix-{round}"),
179                run: &state.id,
180                node: "rebase",
181                cache_dir: state.config.cache_dir().as_deref(),
182                attachments: &[],
183                writable: &[],
184            },
185        )
186        .await;
187        let seat_name = seat.key.clone();
188        state.seats.insert(seat.key.clone(), seat);
189
190        let mut error = None;
191        let mut quota = false;
192        match out {
193            Ok(o) if o.quota_exhausted() => {
194                state.quota.push(QuotaLoss {
195                    seat: seat_name,
196                    node: "rebase".to_owned(),
197                    at: Timestamp::now(),
198                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
199                });
200                error = Some("rate limited (quota); the fixer could not run".to_owned());
201                quota = true;
202            }
203            Ok(o) if !o.usable() => {
204                error = Some(format!(
205                    "the fixer produced nothing usable (exit {:?}, timed out: {})",
206                    o.exit_code, o.timed_out
207                ));
208            }
209            Ok(_) => {}
210            Err(e) => error = Some(format!("{e:#}")),
211        }
212
213        let finished = !git::rebase_in_progress(scratch).await;
214        if let Some(r) = state.rebase_fixes.last_mut() {
215            r.finished = finished;
216            r.error = error.clone();
217        }
218        state.save()?;
219
220        if quota {
221            // A retry now fails the same way; do not burn the rest.
222            let paths = git::unmerged_paths(scratch).await.unwrap_or_default();
223            let why = reason(
224                state.rebase_fixes.len(),
225                cap,
226                &paths,
227                &said,
228                "the fixer hit its rate limit",
229            );
230            abandon(&repo, scratch, branch, &orig).await;
231            return Ok(Rebased::Stopped(why));
232        }
233    }
234}
235
236/// Judge a rebase that is no longer in progress by what git says.
237async fn finish(
238    state: &mut RunState,
239    scratch: &Path,
240    branch: &str,
241    orig: &str,
242    onto_sha: &str,
243    touched: &[String],
244    said: &str,
245) -> Result<Rebased> {
246    let repo = state.repo.clone();
247    let spent = state.rebase_fixes.len();
248    let cap = state.config.graph.review_rounds;
249    let unmerged = git::unmerged_paths(scratch).await.unwrap_or_default();
250    let head = git::rev_parse(scratch, "HEAD").await.unwrap_or_default();
251    // Not only the paths seen unmerged at a round's start: one round may
252    // continue through several commits, and a later commit's conflict never
253    // shows up at a loop head. Everything the result changes relative to the
254    // base is checked.
255    let mut candidates: Vec<String> = touched.to_vec();
256    if let Ok(changed) = git::git(scratch, &["diff", "--name-only", onto_sha, "HEAD"]).await {
257        for p in changed.lines().map(str::trim).filter(|l| !l.is_empty()) {
258            if !candidates.iter().any(|c| c == p) {
259                candidates.push(p.to_owned());
260            }
261        }
262    }
263    let marked: Vec<String> = candidates
264        .into_iter()
265        .filter(|p| has_markers(scratch, p))
266        .collect();
267
268    // Skipping every conflicting commit leaves the branch equal to the base:
269    // the ancestry test below passes and the branch's work is gone. Empty is
270    // only acceptable when every original commit already has a patch twin on
271    // the base.
272    let emptied = head == onto_sha
273        && git::cherry(&repo, onto_sha, orig)
274            .await
275            .map_or(true, |(unmatched, _)| !unmatched.is_empty());
276
277    let problem = if !unmerged.is_empty() {
278        Some(("paths are still unmerged", unmerged))
279    } else if !marked.is_empty() {
280        Some(("conflict markers were left in the tree", marked))
281    } else if emptied {
282        Some((
283            "the rebase ended with none of the branch's commits applied (all skipped)",
284            touched.to_vec(),
285        ))
286    } else if head.is_empty() || !git::is_ancestor(&repo, onto_sha, &head).await {
287        Some((
288            "the rebase ended without the base in the result (abandoned or skipped)",
289            touched.to_vec(),
290        ))
291    } else {
292        None
293    };
294    match problem {
295        None => {
296            git::worktree_remove(&repo, scratch).await.ok();
297            state.event(
298                "rebase",
299                format!("{branch} rebased after {spent} fixer round(s)"),
300            );
301            state.save()?;
302            Ok(Rebased::Applied)
303        }
304        Some((what, paths)) => {
305            let why = reason(spent, cap, &paths, said, what);
306            abandon(&repo, scratch, branch, orig).await;
307            Ok(Rebased::Stopped(why))
308        }
309    }
310}
311
312/// Give up: abort whatever is standing, drop the worktree and put the branch
313/// ref back where it was, whatever a fixer did to it.
314async fn abandon(repo: &Path, scratch: &Path, branch: &str, orig: &str) {
315    git::rebase_abort(repo, scratch).await;
316    let full = format!("refs/heads/{branch}");
317    if git::rev_parse(repo, &full).await.ok().as_deref() != Some(orig) {
318        git::git_raw(repo, &["update-ref", &full, orig]).await.ok();
319    }
320}
321
322/// The reason a caller records and the conductor quotes: it says what was
323/// tried, so nobody has to open the run directory to find out.
324fn reason(spent: usize, cap: usize, paths: &[String], said: &str, what: &str) -> String {
325    let shown: Vec<&str> = paths
326        .iter()
327        .take(PATHS_IN_REASON)
328        .map(String::as_str)
329        .collect();
330    let mut list = shown.join(", ");
331    if paths.len() > shown.len() {
332        list.push_str(&format!(" and {} more", paths.len() - shown.len()));
333    }
334    if list.is_empty() {
335        list.push_str("none recorded");
336    }
337    let mut s = format!(
338        "conflict not resolved after {spent} of {cap} fixer round(s) ({what}); remaining \
339         conflicted path(s): {list}"
340    );
341    let said = said.trim();
342    if !said.is_empty() {
343        s.push_str("; git said: ");
344        s.extend(said.chars().take(250));
345    }
346    s
347}
348
349/// Commit subjects in `range`, newest first, capped.
350async fn subjects(worktree: &Path, range: &str) -> Vec<String> {
351    let n = format!("-n{SUBJECTS}");
352    git::git(worktree, &["log", "--format=%s", &n, range])
353        .await
354        .map(|o| o.lines().map(str::to_owned).collect())
355        .unwrap_or_default()
356}
357
358fn has_markers(worktree: &Path, path: &str) -> bool {
359    std::fs::read_to_string(worktree.join(path)).is_ok_and(|t| {
360        t.lines().any(|l| l.starts_with("<<<<<<< ")) && t.lines().any(|l| l.starts_with(">>>>>>> "))
361    })
362}
363
364/// The conflicted regions of `paths`, marker lines included, truncated. This
365/// only *shows* the conflict; nothing here decides how to resolve it.
366fn hunks(worktree: &Path, paths: &[String]) -> String {
367    let mut out = String::new();
368    for p in paths {
369        if out.len() >= HUNK_TOTAL {
370            out.push_str("\n(more conflicted files omitted)\n");
371            break;
372        }
373        out.push_str(&format!("=== {p} ===\n"));
374        let Ok(text) = std::fs::read_to_string(worktree.join(p)) else {
375            out.push_str("(not readable as text; use git to inspect it)\n");
376            continue;
377        };
378        let mut file = String::new();
379        let mut inside = false;
380        for line in text.lines() {
381            if line.starts_with("<<<<<<< ") {
382                inside = true;
383            }
384            if inside {
385                file.push_str(line);
386                file.push('\n');
387            }
388            if line.starts_with(">>>>>>> ") {
389                inside = false;
390            }
391        }
392        if file.len() > HUNK_PER_FILE {
393            file = file.chars().take(HUNK_PER_FILE).collect();
394            file.push_str("\n(truncated)\n");
395        }
396        out.push_str(&file);
397    }
398    out
399}