1use 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#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum Rebased {
43 Applied,
46 Stopped(String),
50}
51
52const HUNK_PER_FILE: usize = 4_000;
54const HUNK_TOTAL: usize = 16_000;
55const SUBJECTS: usize = 20;
57const PATHS_IN_REASON: usize = 8;
59
60pub 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 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 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 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
236async 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 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 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
312async 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
322fn 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
349async 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
364fn 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}