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            },
184        )
185        .await;
186        let seat_name = seat.key.clone();
187        state.seats.insert(seat.key.clone(), seat);
188
189        let mut error = None;
190        let mut quota = false;
191        match out {
192            Ok(o) if o.quota_exhausted() => {
193                state.quota.push(QuotaLoss {
194                    seat: seat_name,
195                    node: "rebase".to_owned(),
196                    at: Timestamp::now(),
197                    reset: o.quota.as_ref().and_then(|q| q.reset.clone()),
198                });
199                error = Some("rate limited (quota); the fixer could not run".to_owned());
200                quota = true;
201            }
202            Ok(o) if !o.usable() => {
203                error = Some(format!(
204                    "the fixer produced nothing usable (exit {:?}, timed out: {})",
205                    o.exit_code, o.timed_out
206                ));
207            }
208            Ok(_) => {}
209            Err(e) => error = Some(format!("{e:#}")),
210        }
211
212        let finished = !git::rebase_in_progress(scratch).await;
213        if let Some(r) = state.rebase_fixes.last_mut() {
214            r.finished = finished;
215            r.error = error.clone();
216        }
217        state.save()?;
218
219        if quota {
220            // A retry now fails the same way; do not burn the rest.
221            let paths = git::unmerged_paths(scratch).await.unwrap_or_default();
222            let why = reason(
223                state.rebase_fixes.len(),
224                cap,
225                &paths,
226                &said,
227                "the fixer hit its rate limit",
228            );
229            abandon(&repo, scratch, branch, &orig).await;
230            return Ok(Rebased::Stopped(why));
231        }
232    }
233}
234
235/// Judge a rebase that is no longer in progress by what git says.
236async fn finish(
237    state: &mut RunState,
238    scratch: &Path,
239    branch: &str,
240    orig: &str,
241    onto_sha: &str,
242    touched: &[String],
243    said: &str,
244) -> Result<Rebased> {
245    let repo = state.repo.clone();
246    let spent = state.rebase_fixes.len();
247    let cap = state.config.graph.review_rounds;
248    let unmerged = git::unmerged_paths(scratch).await.unwrap_or_default();
249    let head = git::rev_parse(scratch, "HEAD").await.unwrap_or_default();
250    // Not only the paths seen unmerged at a round's start: one round may
251    // continue through several commits, and a later commit's conflict never
252    // shows up at a loop head. Everything the result changes relative to the
253    // base is checked.
254    let mut candidates: Vec<String> = touched.to_vec();
255    if let Ok(changed) = git::git(scratch, &["diff", "--name-only", onto_sha, "HEAD"]).await {
256        for p in changed.lines().map(str::trim).filter(|l| !l.is_empty()) {
257            if !candidates.iter().any(|c| c == p) {
258                candidates.push(p.to_owned());
259            }
260        }
261    }
262    let marked: Vec<String> = candidates
263        .into_iter()
264        .filter(|p| has_markers(scratch, p))
265        .collect();
266
267    // Skipping every conflicting commit leaves the branch equal to the base:
268    // the ancestry test below passes and the branch's work is gone. Empty is
269    // only acceptable when every original commit already has a patch twin on
270    // the base.
271    let emptied = head == onto_sha
272        && git::cherry(&repo, onto_sha, orig)
273            .await
274            .map_or(true, |(unmatched, _)| !unmatched.is_empty());
275
276    let problem = if !unmerged.is_empty() {
277        Some(("paths are still unmerged", unmerged))
278    } else if !marked.is_empty() {
279        Some(("conflict markers were left in the tree", marked))
280    } else if emptied {
281        Some((
282            "the rebase ended with none of the branch's commits applied (all skipped)",
283            touched.to_vec(),
284        ))
285    } else if head.is_empty() || !git::is_ancestor(&repo, onto_sha, &head).await {
286        Some((
287            "the rebase ended without the base in the result (abandoned or skipped)",
288            touched.to_vec(),
289        ))
290    } else {
291        None
292    };
293    match problem {
294        None => {
295            git::worktree_remove(&repo, scratch).await.ok();
296            state.event(
297                "rebase",
298                format!("{branch} rebased after {spent} fixer round(s)"),
299            );
300            state.save()?;
301            Ok(Rebased::Applied)
302        }
303        Some((what, paths)) => {
304            let why = reason(spent, cap, &paths, said, what);
305            abandon(&repo, scratch, branch, orig).await;
306            Ok(Rebased::Stopped(why))
307        }
308    }
309}
310
311/// Give up: abort whatever is standing, drop the worktree and put the branch
312/// ref back where it was, whatever a fixer did to it.
313async fn abandon(repo: &Path, scratch: &Path, branch: &str, orig: &str) {
314    git::rebase_abort(repo, scratch).await;
315    let full = format!("refs/heads/{branch}");
316    if git::rev_parse(repo, &full).await.ok().as_deref() != Some(orig) {
317        git::git_raw(repo, &["update-ref", &full, orig]).await.ok();
318    }
319}
320
321/// The reason a caller records and the conductor quotes: it says what was
322/// tried, so nobody has to open the run directory to find out.
323fn reason(spent: usize, cap: usize, paths: &[String], said: &str, what: &str) -> String {
324    let shown: Vec<&str> = paths
325        .iter()
326        .take(PATHS_IN_REASON)
327        .map(String::as_str)
328        .collect();
329    let mut list = shown.join(", ");
330    if paths.len() > shown.len() {
331        list.push_str(&format!(" and {} more", paths.len() - shown.len()));
332    }
333    if list.is_empty() {
334        list.push_str("none recorded");
335    }
336    let mut s = format!(
337        "conflict not resolved after {spent} of {cap} fixer round(s) ({what}); remaining \
338         conflicted path(s): {list}"
339    );
340    let said = said.trim();
341    if !said.is_empty() {
342        s.push_str("; git said: ");
343        s.extend(said.chars().take(250));
344    }
345    s
346}
347
348/// Commit subjects in `range`, newest first, capped.
349async fn subjects(worktree: &Path, range: &str) -> Vec<String> {
350    let n = format!("-n{SUBJECTS}");
351    git::git(worktree, &["log", "--format=%s", &n, range])
352        .await
353        .map(|o| o.lines().map(str::to_owned).collect())
354        .unwrap_or_default()
355}
356
357fn has_markers(worktree: &Path, path: &str) -> bool {
358    std::fs::read_to_string(worktree.join(path)).is_ok_and(|t| {
359        t.lines().any(|l| l.starts_with("<<<<<<< ")) && t.lines().any(|l| l.starts_with(">>>>>>> "))
360    })
361}
362
363/// The conflicted regions of `paths`, marker lines included, truncated. This
364/// only *shows* the conflict; nothing here decides how to resolve it.
365fn hunks(worktree: &Path, paths: &[String]) -> String {
366    let mut out = String::new();
367    for p in paths {
368        if out.len() >= HUNK_TOTAL {
369            out.push_str("\n(more conflicted files omitted)\n");
370            break;
371        }
372        out.push_str(&format!("=== {p} ===\n"));
373        let Ok(text) = std::fs::read_to_string(worktree.join(p)) else {
374            out.push_str("(not readable as text; use git to inspect it)\n");
375            continue;
376        };
377        let mut file = String::new();
378        let mut inside = false;
379        for line in text.lines() {
380            if line.starts_with("<<<<<<< ") {
381                inside = true;
382            }
383            if inside {
384                file.push_str(line);
385                file.push('\n');
386            }
387            if line.starts_with(">>>>>>> ") {
388                inside = false;
389            }
390        }
391        if file.len() > HUNK_PER_FILE {
392            file = file.chars().take(HUNK_PER_FILE).collect();
393            file.push_str("\n(truncated)\n");
394        }
395        out.push_str(&file);
396    }
397    out
398}