Skip to main content

spar/
split.rs

1//! Breaking one issue or one pull request into smaller ones.
2//!
3//! The unit of work was fixed: one issue became one branch and one pull
4//! request whatever its size, and the only thing that changed it was somebody
5//! filing the smaller issues by hand. The review loop is the product and its
6//! quality falls off with diff size, so the size of the unit is the biggest
7//! lever there is on how well spar works, and it was the one a person could not
8//! pull.
9//!
10//! `split` decomposes and stops. It never triages, never implements a child,
11//! and never merges: it produces smaller units and hands them to the commands
12//! that already exist. That keeps one invocation cheap and comprehensible, and
13//! it keeps the blast radius of a wrong split to some issues and some branches
14//! rather than to hours of implementation.
15//!
16//! Two agents, asymmetrically. One proposes the parts with the code open, the
17//! other rules on the proposal: accept, reject, or accept with named parts
18//! struck. Not two independent proposals, because two decompositions of one
19//! thing cannot be reconciled mechanically and reconciling them is a third
20//! judgement nobody asked for. Disagreement resolves toward not splitting, on
21//! the same asymmetry `checkin` runs on: getting a decline wrong costs one
22//! person one read of something that stays as it was, and getting the write
23//! wrong costs them a queue to clean up.
24//!
25//! **spar never rewrites the branch behind somebody's pull request.** Splitting
26//! a pull request is purely additive: new branches, new pull requests, one
27//! comment. No existing branch is moved, nothing is closed, and nothing is
28//! rebased under anybody. Removing half of a pull request in place is destroying
29//! work in place, and two models agreeing does not make that reversible for the
30//! person who wrote it. `additive` is that invariant as code.
31
32use std::collections::BTreeSet;
33use std::path::{Path, PathBuf};
34
35use crate::agent::{self, Agent};
36use crate::config::{Call, Config};
37use crate::error::{ErrorKind, Result, SparError};
38use crate::model::{
39    Implementation, Issue, IssueRun, ItemKind, PrRow, PrView, SplitCheck, SplitPart, SplitProposal,
40    SplitScreen, SplitScreenDoc, Status,
41};
42use crate::repo::{PushTarget, Repo, SplitPushError, WorktreeCheckpoint};
43use crate::style::{self, Style};
44use crate::{bail, log, logdim, logwarn, schema, spar_err};
45
46// ---------------------------------------------------------------------------
47// Prompts
48// ---------------------------------------------------------------------------
49
50const SCREEN_PROMPT: &str = "\
51Below are the open issues and pull requests on this repository. For each, say
52whether it is worth splitting into smaller pieces.
53
54Read the code in your working directory before judging. Do not modify anything.
55
56Split is for something that is plainly several separate pieces of work, and
57would be reviewed better as several: three unrelated fixes filed as one issue, a
58pull request that grew a refactor while it was being corrected. Size alone is not
59the test. One change across forty files is one piece of work; a mess across three
60files can be three.
61
62Say false when you are unsure. No is the common answer, and a split proposed on a
63whim is a proposal somebody now has to read.
64
65Items:
66";
67
68const PROPOSE_ISSUE_PROMPT: &str = "\
69Issue #{number}: {title}
70{url}
71
72Somebody has read this and decided it is too big to work in one piece. Read the
73code in your working directory before you decide anything. Do not modify,
74commit, or push anything.
75
76Say whether it really is several separate pieces of work, and if it is, what they
77are. Each part becomes its own issue, implemented and reviewed on its own branch,
78so a part has to be worth a pull request by itself: implementable without the
79others and reviewable without them.
80
81Being large is not the test. One change that touches forty files is one part.
82
83Set should_split=false if this is one piece of work. That is a fine answer and it
84is the common one. Set files to null: there is no diff here to partition.
85
86The issue:
87{body}";
88
89const PROPOSE_PR_PROMPT: &str = "\
90Pull request #{number} against `{base}`: {title}
91
92Somebody has read this and decided it is too big to review in one piece. Your
93checkout is the head of that pull request, detached and read only. Do not modify,
94commit, or push anything.
95
96Say whether the change is several separate pieces, and if it is, which files each
97piece carries. Every part is then built on its own branch, carrying only its own
98files, and has to build and pass there. A part that cannot do that is not a part:
99fold its files into another part, or leave them out.
100
101Use only paths from the list below, copied exactly. A path belongs to at most one
102part, and leaving a path out is allowed: what no part carries is reported as left
103over on the original pull request, which stays open.
104
105Set should_split=false if this is one change, however large.
106
107The {count} file(s) this pull request changes:
108{files}";
109
110const CHECK_PROMPT: &str = "\
111Another agent read {what} and proposed splitting it into the parts below. You did
112not make this call.
113
114Go to the code and rule on it. Do not defer to them, and do not agree to be
115agreeable. Getting a rejection wrong costs one person one read of something that
116stays as it was. Getting an acceptance wrong costs them issues to close, a
117checklist to strip out of somebody's body, and branches and pull requests to
118delete.
119
120Reject the proposal outright, or accept it with the parts that do not hold
121struck. Striking so many that fewer than two remain means nothing is split, which
122is the right answer when that is what you think.
123
124Their reason: {reason}
125They say the parts are {shape}.
126
127The parts:
128{parts}";
129
130const STAND_ALONE_PROMPT: &str = "\
131This branch holds one part of pull request #{parent}, split out of it. The other
132parts are not here and are not coming.
133
134Make this part stand on its own against `{base}`. Read what is here, add whatever
135it needs to build and to pass its tests without the rest, and leave those edits
136uncommitted for the harness.
137
138If it cannot stand on its own, set not_worth_doing=true and give the reason. Make
139no changes in that case, and the part is dropped rather than pushed
140broken. The whole value of splitting is that each part can be reviewed and merged
141independently, and a part that does not build has none of it.
142
143Write summary, problem, changes and testing for this part alone. They become the
144body of its own pull request, read by somebody who has not seen the parent.
145
146Part {index} of {total}: {title}
147
148{body}
149
150The files it carries:
151{files}";
152
153// ---------------------------------------------------------------------------
154// What says a split already happened
155// ---------------------------------------------------------------------------
156
157/// Written into a split parent's body and into the comment left on a split
158/// pull request. An HTML comment, so GitHub renders it as nothing.
159pub const SPLIT_MARKER: &str = "<!-- spar:split -->";
160
161/// Whether this text already carries a checklist or a comment spar wrote.
162///
163/// The marker rather than a shape, for the reason `FOLLOWUP_MARKER` exists: a
164/// checklist is exactly what somebody would write by hand, so its shape can
165/// never establish who wrote it.
166pub fn already_split(text: &str) -> bool {
167    text.contains(SPLIT_MARKER)
168}
169
170/// A split parent's body: what it said, then the checklist of its parts.
171///
172/// The original text comes through untouched, byte for byte, and the checklist
173/// is appended after it. This is the one place spar rewrites prose somebody
174/// else wrote, and appending is the only shape of that which cannot lose any
175/// of it.
176///
177/// Each line carries its `#N`, which is what makes the parent an ordinary
178/// tracker: a checklist whose items are issue numbers.
179pub fn tracker_body(original: &str, parts: &[(String, i64)]) -> String {
180    let mut out = original.to_string();
181    if !out.is_empty() {
182        if let Some(fence) = unclosed_fence(&out) {
183            // A fence somebody left open swallows everything after it, so the
184            // checklist would render as code rather than as a checklist.
185            end_line(&mut out);
186            out.push_str(&fence);
187        }
188        separate(&mut out);
189    }
190    out.push_str(SPLIT_MARKER);
191    out.push_str("\n\n## Parts\n\nThis is now a tracker. Each part below is its own issue.\n\n");
192    for (title, number) in parts {
193        out.push_str(&format!("- [ ] #{number} {}\n", title.trim()));
194    }
195    out
196}
197
198fn end_line(text: &mut String) {
199    if !text.ends_with('\n') {
200        text.push('\n');
201    }
202}
203
204fn separate(text: &mut String) {
205    end_line(text);
206    if !text.ends_with("\n\n") {
207        text.push('\n');
208    }
209}
210
211/// The fence that closes this text, when it ends inside a fenced code block.
212///
213/// The opener is carried rather than counted, because only a fence of the same
214/// character and at least the same length closes it: three backticks inside a
215/// block opened with four are content, and no number of backticks closes a
216/// block opened with tildes. Anything else would answer with a fence that does
217/// not close the block, and the checklist stays inside it either way.
218fn unclosed_fence(text: &str) -> Option<String> {
219    let mut open: Option<(char, usize)> = None;
220    for line in text.lines() {
221        let start = line.trim_start();
222        let Some(ch @ ('`' | '~')) = start.chars().next() else {
223            continue;
224        };
225        let run = start.chars().take_while(|c| *c == ch).count();
226        if run < 3 {
227            continue;
228        }
229        match open {
230            None => open = Some((ch, run)),
231            // A closing fence is the same character, no shorter than the one
232            // that opened the block, and carries no info string.
233            Some((open_ch, len))
234                if open_ch == ch && run >= len && start.trim_end().chars().all(|c| c == ch) =>
235            {
236                open = None;
237            }
238            Some(_) => {}
239        }
240    }
241    open.map(|(ch, len)| ch.to_string().repeat(len))
242}
243
244// ---------------------------------------------------------------------------
245// The invariant
246// ---------------------------------------------------------------------------
247
248/// Refuse any branch a split did not itself create.
249///
250/// **spar never rewrites the branch behind somebody's pull request.** This is
251/// the one write path a pull request split has, and it is worth an assertion
252/// rather than a paragraph in a prompt: everything else here adds objects, and
253/// a push to the wrong name is the single change that would destroy work in
254/// place.
255pub fn additive(branch: &str, parent_head: &str, prefix: &str) -> Result<()> {
256    let wanted = format!("{prefix}split-");
257    if !branch.starts_with(&wanted) {
258        bail!("refusing to push to {branch}: a split only ever writes {wanted}* branches");
259    }
260    if branch == parent_head.trim() {
261        bail!("refusing to push to {branch}: it is the branch behind the pull request being split");
262    }
263    Ok(())
264}
265
266// ---------------------------------------------------------------------------
267// Deciding
268// ---------------------------------------------------------------------------
269
270/// Where a run of `spar split` stops.
271#[derive(Debug, Clone, Copy)]
272pub struct Mode {
273    /// Print the proposal and write nothing.
274    pub dry_run: bool,
275    /// Split something that already carries a split spar made.
276    pub again: bool,
277}
278
279/// What survived the proposal, the check, and the cap.
280#[derive(Debug, Clone, Default)]
281pub struct Decision {
282    pub parts: Vec<SplitPart>,
283    /// Each part branched off the one before it, rather than off the base.
284    pub stacked: bool,
285    /// Why nothing is being split, when nothing is.
286    pub declined: Option<String>,
287    /// Parts that were proposed and are not being made, said out loud because a
288    /// cap that fires silently is a cap nobody can raise.
289    pub dropped: Vec<String>,
290}
291
292impl Decision {
293    pub fn splits(&self) -> bool {
294        self.declined.is_none() && self.parts.len() > 1
295    }
296}
297
298/// Rule on a proposal, given the check and the cap.
299///
300/// Every path out of disagreement is "do not split". The proposing agent
301/// declining, the checking agent rejecting, and the checking agent striking
302/// enough parts that one is left all mean the same thing here, because a split
303/// into one part is not a split.
304pub fn decide(proposal: &SplitProposal, check: &SplitCheck, cap: usize) -> Decision {
305    let mut out = Decision {
306        // Stacked whenever either agent says so. The two shapes are not equally
307        // safe to be wrong about: parts built independently out of a change that
308        // is really sequential do not build, and a part that does not build is
309        // dropped. A stacked part always applies on its predecessor.
310        stacked: proposal.stacked || check.stacked,
311        ..Decision::default()
312    };
313
314    if !proposal.should_split {
315        out.declined = Some(reason_or(&proposal.reason, "it is one piece of work"));
316        return out;
317    }
318    if !check.accept {
319        out.declined = Some(reason_or(
320            &check.reasoning,
321            "the second agent did not accept the split",
322        ));
323        return out;
324    }
325
326    let struck: BTreeSet<i64> = check.strike.iter().copied().collect();
327    for (i, part) in proposal.parts.iter().enumerate() {
328        let number = i as i64 + 1;
329        if struck.contains(&number) {
330            out.dropped
331                .push(format!("{} (struck by the second agent)", label(part)));
332            continue;
333        }
334        if part.title.trim().is_empty() {
335            out.dropped.push("a part with no title".to_string());
336            continue;
337        }
338        out.parts.push(part.clone());
339    }
340
341    if out.parts.len() < 2 {
342        out.declined = Some(reason_or(
343            &check.reasoning,
344            "fewer than two parts survived, and a split into one part is not a split",
345        ));
346        out.parts.clear();
347        return out;
348    }
349
350    if out.parts.len() > cap {
351        for part in out.parts.split_off(cap) {
352            out.dropped
353                .push(format!("{} (over the max_split_parts cap)", label(&part)));
354        }
355    }
356    out
357}
358
359fn label(part: &SplitPart) -> String {
360    style::clip(part.title.trim(), 80)
361}
362
363fn reason_or(text: &str, fallback: &str) -> String {
364    let trimmed = text.trim();
365    if trimmed.is_empty() {
366        fallback.to_string()
367    } else {
368        trimmed.to_string()
369    }
370}
371
372/// The files a change touches that none of `carried` holds.
373///
374/// Reported on the original, which stays open, so that nothing goes missing
375/// without somebody being told where it went. Takes the file lists rather than
376/// the parts, because what a proposal claims and what was actually built are
377/// different answers and the record has to be the second one.
378pub fn leftover<'a>(
379    changed: &[String],
380    carried: impl IntoIterator<Item = &'a [String]>,
381) -> Vec<String> {
382    let taken: BTreeSet<&str> = carried.into_iter().flatten().map(String::as_str).collect();
383    changed
384        .iter()
385        .filter(|path| !taken.contains(path.as_str()))
386        .cloned()
387        .collect()
388}
389
390/// Hold every part to the paths the change actually touches.
391///
392/// A path the model invented carries nothing and would be checked out of the
393/// pull request head as a failure, and a path with `..` in it is one that would
394/// be written outside the worktree. The list came from spar, so anything not in
395/// it is a paraphrase.
396fn confine(parts: &mut [SplitPart], changed: &[String]) -> Vec<String> {
397    let known: BTreeSet<&str> = changed.iter().map(String::as_str).collect();
398    let mut unknown = Vec::new();
399    let mut claimed: BTreeSet<String> = BTreeSet::new();
400    for part in parts.iter_mut() {
401        part.files.retain(|path| {
402            if !known.contains(path.as_str()) {
403                unknown.push(path.clone());
404                return false;
405            }
406            // A path in two parts would be carried twice and reviewed twice.
407            claimed.insert(path.clone())
408        });
409    }
410    unknown
411}
412
413// ---------------------------------------------------------------------------
414// Screening a whole queue
415// ---------------------------------------------------------------------------
416
417/// One open item the bare `spar split` is considering.
418#[derive(Debug, Clone)]
419pub struct Candidate {
420    pub number: i64,
421    pub kind: ItemKind,
422    pub title: String,
423    /// The issue body, or the pull request's size.
424    pub detail: String,
425}
426
427impl Candidate {
428    pub fn from_issue(issue: &Issue) -> Self {
429        Self {
430            number: issue.number,
431            kind: ItemKind::Issue,
432            title: issue.title.clone(),
433            detail: issue.body_text().trim().to_string(),
434        }
435    }
436
437    pub fn from_pr(row: &PrRow) -> Self {
438        Self {
439            number: row.number,
440            kind: ItemKind::Pr,
441            title: row.title.clone(),
442            detail: row.size(),
443        }
444    }
445}
446
447/// The queue as one prompt carries it, and what did not fit.
448fn render(items: &[Candidate], cfg: &Config) -> (String, usize) {
449    let mut parts: Vec<String> = Vec::new();
450    let mut total = 0usize;
451    let mut deferred = 0usize;
452    for item in items {
453        if deferred > 0 {
454            deferred += 1;
455            continue;
456        }
457        let block = format!(
458            "{} #{}: {}\n{}",
459            item.kind, item.number, item.title, item.detail
460        );
461        let len = block.chars().count();
462        // The first item goes in whatever its size, for the reason
463        // `followups::render` does it: a queue of one that does not fit is a
464        // command that does nothing, forever.
465        if !parts.is_empty() && total + len > cfg.loop_cfg.max_triage_chars {
466            deferred += 1;
467            continue;
468        }
469        total += len;
470        parts.push(block);
471    }
472    (parts.join("\n\n"), deferred)
473}
474
475/// One agent's verdict on the whole queue in one call.
476///
477/// The asymmetry here is the opposite of the follow-up screen's. There the
478/// screen says keep when unsure because what survives still faces two agent
479/// triage. Here what survives faces propose and check, which is also two
480/// agents, so a permissive screen costs money rather than damage. The prompt
481/// still says not to split when unsure: no is the common answer, and a split
482/// proposed on a whim is a proposal somebody now has to read.
483pub fn screen(
484    agent: &Agent,
485    cfg: &Config,
486    repo: &Repo,
487    items: &[Candidate],
488) -> Result<Vec<SplitScreen>> {
489    let (text, deferred) = render(items, cfg);
490    if deferred > 0 {
491        logwarn!(
492            "{deferred} item(s) did not fit in one screening prompt and were left for a later run"
493        );
494    }
495    let checkout = repo.read_checkout("split screen")?;
496    let answer: SplitScreenDoc = agent.ask_json(
497        &format!("{SCREEN_PROMPT}{text}"),
498        &schema::split_screen(),
499        checkout.path(),
500        &agent.effort(cfg, Call::Split(1)),
501    )?;
502    Ok(answer.items)
503}
504
505// ---------------------------------------------------------------------------
506// Splitting an issue
507// ---------------------------------------------------------------------------
508
509pub fn split_issue(
510    agents: &[Agent],
511    cfg: &Config,
512    repo: &Repo,
513    number: i64,
514    mode: &Mode,
515) -> IssueRun {
516    match issue_inner(agents, cfg, repo, number, mode) {
517        Ok(state) => state,
518        Err(e) => failed(number, format!("#{number}"), e),
519    }
520}
521
522fn failed(number: i64, label: String, e: crate::error::SparError) -> IssueRun {
523    log!("{label} split failed: {e}");
524    let mut state = IssueRun::new(number, label);
525    state.status = Status::Error;
526    state.notes.push(e.to_string());
527    state
528}
529
530fn issue_inner(
531    agents: &[Agent],
532    cfg: &Config,
533    repo: &Repo,
534    number: i64,
535    mode: &Mode,
536) -> Result<IssueRun> {
537    let issue = repo
538        .fetch_issues(&[number])?
539        .into_iter()
540        .next()
541        .ok_or_else(|| spar_err!("#{number} is closed"))?;
542    let mut state = IssueRun::new(number, issue.title.clone());
543
544    if already_split(issue.body_text()) && !mode.again {
545        log!("#{number} already carries a checklist spar wrote. --again splits it anyway.");
546        state.status = Status::Whole;
547        state.notes.push("already split".into());
548        return Ok(state);
549    }
550
551    let (body, shortened) = issue.body_for_prompt(cfg.loop_cfg.max_issue_chars);
552    if shortened {
553        logwarn!(
554            "#{number}: the issue body was shortened to fit the prompt. Raise max_issue_chars if \
555             the rest matters."
556        );
557    }
558    let prompt = PROPOSE_ISSUE_PROMPT
559        .replace("{number}", &number.to_string())
560        .replace("{title}", &issue.title)
561        .replace("{url}", &issue.url)
562        .replace("{body}", &body);
563
564    let checkout = repo.read_checkout(&format!("split of #{number}"))?;
565    let decision = propose_and_check(
566        agents,
567        cfg,
568        checkout.path(),
569        &format!("#{number}"),
570        &prompt,
571        &format!("issue #{number}"),
572    )?;
573    drop(checkout);
574
575    for note in &decision.dropped {
576        log!("  dropped {note}");
577        state.notes.push(format!("dropped {note}"));
578    }
579    if !decision.splits() {
580        let why = decision
581            .declined
582            .unwrap_or_else(|| "nothing survived the check".to_string());
583        log!("#{number} left whole: {why}");
584        state.status = Status::Whole;
585        state.notes.push(why);
586        return Ok(state);
587    }
588
589    if mode.dry_run {
590        print_proposal(number, "issue", &decision, &[]);
591        state.status = Status::Whole;
592        state.notes.push(format!(
593            "dry run: {} part(s) proposed",
594            decision.parts.len()
595        ));
596        return Ok(state);
597    }
598
599    // Filed first, the parent rewritten after. An issue filed with no checklist
600    // yet is findable and says where it came from; a checklist pointing at
601    // issues that were never filed is not recoverable by reading it.
602    let mut listed: Vec<(String, i64)> = Vec::new();
603    for (i, part) in decision.parts.iter().enumerate() {
604        // Cleaned here rather than only inside `file_as_issue`, because the
605        // same string also goes into the parent's checklist, and that write
606        // deliberately does not run the body through the style gate.
607        let title = match repo.clean_nonempty_title_for_write(&part.title) {
608            Ok(title) => title,
609            Err(_) => {
610                logwarn!("nothing left of '{}' after cleaning it", label(part));
611                state.notes.push(format!(
612                    "dropped {}: its title would not clean",
613                    label(part)
614                ));
615                continue;
616            }
617        };
618        let body = format!(
619            "{}\n\nPart {} of {}, split out of #{number}.",
620            part.body.trim(),
621            i + 1,
622            decision.parts.len()
623        );
624        match crate::review::file_as_issue_apart_from(repo, &title, &body, Some(number)) {
625            Ok(filed) => {
626                log!("  {}", filed.describe(&title));
627                if let Some(url) = filed.url() {
628                    state.filed.push(url.to_string());
629                }
630                // `file_as_issue` answers with an existing issue when the part
631                // is already filed, and what a part resembles most is the issue
632                // it came out of. A parent listed as its own child is a tracker
633                // pointing at itself, and two parts that resolved to one issue
634                // are one part twice.
635                match filed.number() {
636                    Some(n) if n == number => {
637                        logwarn!("'{title}' came back as #{number} itself, so it is not a part");
638                        state
639                            .notes
640                            .push(format!("dropped {title}: it matched #{number} itself"));
641                    }
642                    Some(n) if listed.iter().any(|(_, listed)| *listed == n) => {
643                        logwarn!("'{title}' came back as #{n}, which another part already is");
644                        state
645                            .notes
646                            .push(format!("dropped {title}: #{n} is already a part"));
647                    }
648                    Some(n) => listed.push((title, n)),
649                    None => state.notes.push(format!("{title}: {}", filed.note())),
650                }
651            }
652            Err(e) => {
653                logwarn!("could not file '{title}': {e}");
654                if e.kind() == ErrorKind::UncertainWrite {
655                    record_uncertain_issue_part(
656                        &mut state,
657                        number,
658                        &title,
659                        &listed,
660                        &e.to_string(),
661                    );
662                    return Ok(state);
663                }
664                state.notes.push(format!("could not file {title}: {e}"));
665            }
666        }
667    }
668
669    if listed.len() < 2 {
670        // One child is still an external write even though it is not a useful
671        // decomposition. Leaving it unrecorded and calling this whole invites
672        // a later run to make another one.
673        if !listed.is_empty() {
674            record_partial_issue_split(&mut state, number, &listed);
675            return Ok(state);
676        }
677        log!("#{number} left whole: no parts were filed");
678        state.status = Status::Whole;
679        return Ok(state);
680    }
681
682    let original = issue.body_text();
683    let wanted = tracker_body(original, &listed);
684    let inserted = &wanted[original.len()..];
685    match repo.edit_issue_body(number, original, &wanted, inserted) {
686        Ok(()) => {
687            log!("#{number} is now a tracker for {} part(s)", listed.len());
688            state.status = Status::Split;
689        }
690        Err(e) => {
691            // The parts exist and the parent does not point at them, so `run`
692            // would work the parent as a whole again. That needs somebody, so
693            // it is an error rather than a note on a success.
694            record_issue_tracker_failure(&mut state, number, &listed, &e);
695        }
696    }
697    Ok(state)
698}
699
700fn checklist(parts: &[(String, i64)]) -> String {
701    parts
702        .iter()
703        .map(|(title, n)| format!("- [ ] #{n} {}", title.trim()))
704        .collect::<Vec<_>>()
705        .join("\n")
706}
707
708fn record_issue_tracker_failure(
709    state: &mut IssueRun,
710    number: i64,
711    parts: &[(String, i64)],
712    error: &SparError,
713) {
714    state.status = Status::Error;
715    let recovery = if error.kind() == ErrorKind::UncertainWrite {
716        format!(
717            "The parent write may already have landed. Do not rerun this split. Inspect the \
718             current body of #{number} for the split marker `{SPLIT_MARKER}` and every line in \
719             this exact checklist. If the marker and every line are present, do not add them \
720             again. Otherwise add only the missing marker or child links by hand:\n{}",
721            checklist(parts)
722        )
723    } else {
724        format!(
725            "The child issues were filed. Do not rerun this split. Add them to #{number} by \
726             hand:\n{}",
727            checklist(parts)
728        )
729    };
730    state.notes.push(format!("{error}\n{recovery}"));
731}
732
733fn record_partial_issue_split(state: &mut IssueRun, number: i64, parts: &[(String, i64)]) {
734    state.status = Status::Error;
735    state.notes.push(format!(
736        "Only one child issue survived, so #{number} was not rewritten into a tracker. Do not \
737         rerun this split while the child is unrecorded. Link it from #{number} by hand and decide \
738         which issue owns the work, or close it first if it was newly created and should not \
739         remain:\n{}",
740        checklist(parts)
741    ));
742}
743
744fn record_uncertain_issue_part(
745    state: &mut IssueRun,
746    number: i64,
747    title: &str,
748    listed: &[(String, i64)],
749    reason: &str,
750) {
751    state.status = Status::Error;
752    let recovery = if listed.is_empty() {
753        format!(
754            "Inspect recent issues for an exact `{title}` child before doing anything else. If it \
755             exists, add it to #{number} by hand. If it does not exist, this split can be run \
756             again."
757        )
758    } else {
759        format!(
760            "These earlier child issues were filed:\n{}\nInspect recent issues for an exact \
761             `{title}` child, then complete the tracker on #{number} by hand. Do not rerun this \
762             split while these partial results exist.",
763            checklist(listed)
764        )
765    };
766    state.notes.push(format!(
767        "{reason}\nWhether that child write landed is unknown. {recovery}"
768    ));
769}
770
771// ---------------------------------------------------------------------------
772// Splitting a pull request
773// ---------------------------------------------------------------------------
774
775pub fn split_pr(agents: &[Agent], cfg: &Config, repo: &Repo, number: i64, mode: &Mode) -> IssueRun {
776    match pr_inner(agents, cfg, repo, number, mode) {
777        Ok(state) => state,
778        Err(e) => failed(number, format!("PR #{number}"), e),
779    }
780}
781
782fn pr_inner(
783    agents: &[Agent],
784    cfg: &Config,
785    repo: &Repo,
786    number: i64,
787    mode: &Mode,
788) -> Result<IssueRun> {
789    let pr: PrView = repo.pr_view(number)?;
790    if !pr.is_open() {
791        bail!("PR #{number} is {}", pr.state.to_lowercase());
792    }
793    let mut state = IssueRun::new(number, pr.title.clone());
794    state.pr = Some(pr.url.clone());
795
796    if !mode.again {
797        match prior_split(repo, number)? {
798            PriorSplit::None => {}
799            PriorSplit::Recorded => {
800                log!("PR #{number} already has parts spar made. --again splits it again.");
801                state.status = Status::Whole;
802                state.notes.push("already split".into());
803                return Ok(state);
804            }
805            PriorSplit::RetainedBranches => {
806                log!("PR #{number} has retained branches from an incomplete split");
807                state.status = Status::Error;
808                state.notes.push(retained_branches_note(number));
809                return Ok(state);
810            }
811        }
812    }
813
814    let base = if pr.base_ref_name.trim().is_empty() {
815        cfg.base_branch().to_string()
816    } else {
817        pr.base_ref_name.clone()
818    };
819    repo.git_try(&["fetch", "origin", &base]);
820
821    let head_ref = crate::repo::review_ref(number);
822    let read_only = repo.worktree_for_pr_head(number)?;
823    let checkpoint = repo.worktree_checkpoint(&read_only)?;
824    let head_oid = repo
825        .git_at(Some(&read_only), &["rev-parse", "HEAD"])?
826        .trim()
827        .to_string();
828    if head_oid.is_empty() {
829        bail!("could not read the fetched head of PR #{number}");
830    }
831    let outcome = split_pr_inner(
832        agents,
833        cfg,
834        repo,
835        &pr,
836        &base,
837        &head_ref,
838        &head_oid,
839        &read_only,
840        &checkpoint,
841        mode,
842        &mut state,
843    );
844    outcome?;
845    if !cfg.loop_cfg.keep_worktrees {
846        repo.release_review_worktree_checked(number, &checkpoint)?;
847    }
848    Ok(state)
849}
850
851#[allow(clippy::too_many_arguments)]
852fn split_pr_inner(
853    agents: &[Agent],
854    cfg: &Config,
855    repo: &Repo,
856    pr: &PrView,
857    base: &str,
858    head_ref: &str,
859    head_oid: &str,
860    read_only: &Path,
861    checkpoint: &WorktreeCheckpoint,
862    mode: &Mode,
863    state: &mut IssueRun,
864) -> Result<()> {
865    let number = pr.number;
866    let changed = repo.changed_files(read_only, base);
867    if changed.is_empty() {
868        log!("PR #{number} changes no files, so there is nothing to split");
869        state.status = Status::Whole;
870        return Ok(());
871    }
872
873    let prompt = PROPOSE_PR_PROMPT
874        .replace("{number}", &number.to_string())
875        .replace("{title}", &pr.title)
876        .replace("{base}", &repo.base_ref(read_only, base))
877        .replace("{count}", &changed.len().to_string())
878        .replace("{files}", &listed(&changed));
879
880    let mut decision = propose_and_check(
881        agents,
882        cfg,
883        read_only,
884        &format!("PR #{number}"),
885        &prompt,
886        &format!("pull request #{number}"),
887    )?;
888    repo.require_unchanged_worktree(
889        read_only,
890        checkpoint,
891        &format!("review worktree for PR #{number}"),
892    )?;
893    for path in confine(&mut decision.parts, &changed) {
894        logdim!("PR #{number}: a part named `{path}`, which this change does not touch");
895    }
896
897    for note in &decision.dropped {
898        log!("  dropped {note}");
899        state.notes.push(format!("dropped {note}"));
900    }
901    if !decision.splits() {
902        let why = decision
903            .declined
904            .clone()
905            .unwrap_or_else(|| "nothing survived the check".to_string());
906        log!("PR #{number} left whole: {why}");
907        state.status = Status::Whole;
908        state.notes.push(why);
909        return Ok(());
910    }
911
912    let proposed_left = proposed_leftover(&changed, &decision);
913    if mode.dry_run {
914        print_proposal(number, "pull request", &decision, &proposed_left);
915        state.status = Status::Whole;
916        state.notes.push(format!(
917            "dry run: {} part(s) proposed",
918            decision.parts.len()
919        ));
920        return Ok(());
921    }
922
923    // A fork is proposed, not split. spar cannot push to the fork, and carving
924    // somebody's contribution into pull requests of your own without asking is
925    // not something to do automatically. This is how `review` already treats a
926    // fork.
927    if pr.is_cross_repository {
928        log!("PR #{number} comes from a fork, so the parts are proposed rather than made");
929        let body = proposal_comment(number, &decision, &proposed_left, &repo.style);
930        ensure_parent_head(repo, number, head_oid)?;
931        repo.comment_pr(number, &body)?;
932        state.status = Status::Whole;
933        state
934            .notes
935            .push("from a fork, so the split was proposed rather than made".into());
936        return Ok(());
937    }
938
939    let parent = Parent {
940        number,
941        base,
942        head_ref,
943        head_oid,
944        head_branch: pr.head_ref_name.trim(),
945    };
946    let built = build_parts(agents, cfg, repo, &parent, &decision, state)?;
947    let made_before_failure = built.made.len();
948    if let Some(reason) = built.failure {
949        state.status = Status::Error;
950        state.notes.push(with_partial_pr_recovery(
951            number,
952            made_before_failure,
953            &reason,
954        ));
955        return Ok(());
956    }
957    let made = built.made;
958    if made.is_empty() {
959        log!("PR #{number} left whole: no part would stand on its own");
960        state.status = Status::Whole;
961        release_part_worktrees(repo, cfg, state.status, built.worktrees);
962        return Ok(());
963    }
964
965    // From the parts that exist, not the ones that were proposed. A part
966    // dropped for carrying nothing, for failing, or for not standing on its own
967    // leaves its files on the original, and this comment is the only permanent
968    // record of where anything went.
969    let left = leftover(&changed, made.iter().map(|m| m.files.as_slice()));
970    let body = parts_comment(&made, &left, &repo.style);
971    if let Err(e) = ensure_parent_head(repo, number, head_oid) {
972        state.status = Status::Error;
973        state.notes.push(format!(
974            "{e}. The part pull requests were opened, but the parent was left uncommented."
975        ));
976        return Ok(());
977    }
978    if let Err(e) = repo.comment_pr(number, &body) {
979        logwarn!(
980            "made {} part(s) but could not say so on #{number}: {e}",
981            made.len()
982        );
983        record_parent_comment_failure(state, number, &body, &e);
984        return Ok(());
985    }
986    // A split into one part is not a split: everything that part carries is
987    // still on the original, so the two would be reviewed twice over. It was
988    // pushed before that was knowable and is named on the original for that
989    // reason, but this run did not decompose anything.
990    if made.len() < 2 {
991        log!("PR #{number} left whole: only one part stood on its own");
992        state.status = Status::Whole;
993        state
994            .notes
995            .push("only one part stood on its own, so nothing was decomposed".into());
996        release_part_worktrees(repo, cfg, state.status, built.worktrees);
997        return Ok(());
998    }
999    state.status = Status::Split;
1000    release_part_worktrees(repo, cfg, state.status, built.worktrees);
1001    Ok(())
1002}
1003
1004fn ensure_parent_head(repo: &Repo, number: i64, expected: &str) -> Result<()> {
1005    let checked = repo
1006        .pr_head_oid(number)
1007        .and_then(|live| same_parent_head(number, expected, &live));
1008    repo.record_failed_write(checked)
1009}
1010
1011fn same_parent_head(number: i64, expected: &str, live: &str) -> Result<()> {
1012    if expected == live {
1013        return Ok(());
1014    }
1015    bail!(
1016        "PR #{number} changed from {expected} to {live} while it was being split; refusing to \
1017         write parts from an unread head"
1018    )
1019}
1020
1021fn release_part_worktrees(
1022    repo: &Repo,
1023    cfg: &Config,
1024    status: Status,
1025    worktrees: Vec<(PathBuf, String)>,
1026) {
1027    release_part_worktrees_with(
1028        cfg.loop_cfg.keep_worktrees,
1029        status,
1030        worktrees,
1031        |dir, branch| repo.release_split_worktree(dir, branch),
1032    );
1033}
1034
1035fn release_part_worktrees_with(
1036    configured: bool,
1037    status: Status,
1038    worktrees: Vec<(PathBuf, String)>,
1039    mut release: impl FnMut(&Path, &str),
1040) {
1041    if keep_part_worktrees(configured, status) {
1042        return;
1043    }
1044    for (dir, branch) in worktrees {
1045        release(&dir, &branch);
1046    }
1047}
1048
1049fn keep_part_worktrees(configured: bool, status: Status) -> bool {
1050    configured || status == Status::Error
1051}
1052
1053fn record_parent_comment_failure(state: &mut IssueRun, number: i64, body: &str, error: &SparError) {
1054    state.status = Status::Error;
1055    let recovery = if error.kind() == ErrorKind::UncertainWrite {
1056        format!(
1057            "The comment may already have landed. The part branches stop an automatic retry. \
1058             Inspect every top-level comment on #{number} for the exact body below. If it is \
1059             present, do not post it again. If it is absent, post it once by hand to finish \
1060             recording the split:\n{body}"
1061        )
1062    } else {
1063        format!(
1064            "The part branches stop an automatic retry. Post this comment by hand to finish \
1065             recording the split. A normal rerun stops while those branches exist:\n{body}"
1066        )
1067    };
1068    state.notes.push(format!(
1069        "could not comment on #{number}: {error}\n{recovery}"
1070    ));
1071}
1072
1073/// What no proposed part claims. For the two paths that build nothing: a dry
1074/// run and a fork, where the proposal is all there is.
1075fn proposed_leftover(changed: &[String], decision: &Decision) -> Vec<String> {
1076    leftover(changed, decision.parts.iter().map(|p| p.files.as_slice()))
1077}
1078
1079/// One part as it was actually built: its pull request, and the files its
1080/// branch really changed.
1081struct Built {
1082    pr: crate::model::PrRef,
1083    files: Vec<String>,
1084}
1085
1086enum BuildOne {
1087    Made(Built),
1088    Declined {
1089        disposable_head: String,
1090    },
1091    Halted {
1092        reason: String,
1093        /// An exact mechanical-slice tip that may be discarded. `None` means
1094        /// the worktree contains or may contain recovery work and must stay.
1095        disposable_head: Option<String>,
1096    },
1097}
1098
1099impl BuildOne {
1100    fn parent_moved(
1101        error: &crate::error::SparError,
1102        dir: &Path,
1103        disposable_head: Option<String>,
1104    ) -> Self {
1105        let reason = if disposable_head.is_none() {
1106            format!(
1107                "{error}. The stand-alone worktree was not confirmed to match its mechanical \
1108                 slice, so it was kept at {} for recovery.",
1109                dir.display()
1110            )
1111        } else {
1112            error.to_string()
1113        };
1114        Self::Halted {
1115            reason,
1116            disposable_head,
1117        }
1118    }
1119
1120    fn push_failed(
1121        branch: &str,
1122        error: &SplitPushError,
1123        mut disposable_head: Option<String>,
1124    ) -> Self {
1125        let remote_uncertain = error.retain_worktree();
1126        if remote_uncertain {
1127            disposable_head = None;
1128        }
1129        let reason = if remote_uncertain {
1130            format!(
1131                "{error}\nThe split stopped because `{branch}` may now exist on origin. Its local \
1132                 worktree and branch record were kept. Inspect the exact remote ref before \
1133                 continuing."
1134            )
1135        } else if disposable_head.is_none() {
1136            format!(
1137                "could not create the new branch `{branch}`: {error}\nThe stand-alone worktree was \
1138                 not confirmed to match its mechanical slice, so its worktree and branch record \
1139                 were kept for recovery."
1140            )
1141        } else {
1142            format!(
1143                "could not create the new branch `{branch}`: {error}\nAnother writer may have \
1144                 taken the name, so the split stopped before opening competing pull requests."
1145            )
1146        };
1147        Self::Halted {
1148            reason,
1149            disposable_head,
1150        }
1151    }
1152}
1153
1154struct BuiltParts {
1155    made: Vec<Made>,
1156    worktrees: Vec<(PathBuf, String)>,
1157    failure: Option<String>,
1158}
1159
1160fn worktree_allocation_failure(
1161    parent: i64,
1162    index: usize,
1163    made: usize,
1164    error: &crate::error::SparError,
1165) -> String {
1166    let reason = format!("could not allocate part {index}: {}", error.last_line());
1167    with_partial_pr_recovery(parent, made, &reason)
1168}
1169
1170fn with_partial_pr_recovery(parent: i64, made: usize, reason: &str) -> String {
1171    const LEAD: &str = "Earlier child pull requests and their worktrees were kept.";
1172    if made == 0 || reason.contains(LEAD) {
1173        return reason.to_string();
1174    }
1175    format!(
1176        "{reason}\n{LEAD} {made} child pull request(s) already exist. Compare them with the \
1177         current parent and record the partial result on #{parent} by hand. To start over, remove \
1178         every retained local worktree and branch, child pull request, and remote split branch \
1179         first."
1180    )
1181}
1182
1183/// One part that exists: its number, its title, its pull request, and the files
1184/// it took with it.
1185struct Made {
1186    index: usize,
1187    title: String,
1188    url: String,
1189    files: Vec<String>,
1190}
1191
1192/// The pull request being split, as the part builder needs it.
1193struct Parent<'a> {
1194    number: i64,
1195    /// What an independent part is branched from and opened against.
1196    base: &'a str,
1197    /// The fetched head, which the slices are taken out of.
1198    head_ref: &'a str,
1199    /// The exact pull request head the proposal and check read.
1200    head_oid: &'a str,
1201    /// The branch behind it, which nothing here may ever write to.
1202    head_branch: &'a str,
1203}
1204
1205fn build_parts(
1206    agents: &[Agent],
1207    cfg: &Config,
1208    repo: &Repo,
1209    parent: &Parent<'_>,
1210    decision: &Decision,
1211    state: &mut IssueRun,
1212) -> Result<BuiltParts> {
1213    let implementor = agent::find(agents, &cfg.first_implementor)?;
1214    let total = decision.parts.len();
1215    let mut made: Vec<Made> = Vec::new();
1216    let mut worktrees: Vec<(PathBuf, String)> = Vec::new();
1217
1218    // What the next part is branched from, and what its pull request is opened
1219    // against. Independent parts both stay on the base; stacked parts move to
1220    // each part that was actually made, so a dropped part does not strand the
1221    // ones after it.
1222    let mut start = format!("origin/{}", parent.base);
1223    let mut against = parent.base.to_string();
1224
1225    for (i, part) in decision.parts.iter().enumerate() {
1226        let index = i + 1;
1227        if part.files.is_empty() {
1228            log!("  part {index} carries no files, dropping it");
1229            state
1230                .notes
1231                .push(format!("dropped {}: it carried no files", label(part)));
1232            continue;
1233        }
1234        if let Err(e) = ensure_parent_head(repo, parent.number, parent.head_oid) {
1235            return Ok(BuiltParts {
1236                made,
1237                worktrees,
1238                failure: Some(e.to_string()),
1239            });
1240        }
1241
1242        let (dir, branch) = match repo.worktree_for_split(parent.number, index, &start) {
1243            Ok(worktree) => worktree,
1244            Err(e) => {
1245                return Ok(BuiltParts {
1246                    failure: Some(worktree_allocation_failure(
1247                        parent.number,
1248                        index,
1249                        made.len(),
1250                        &e,
1251                    )),
1252                    made,
1253                    worktrees,
1254                });
1255            }
1256        };
1257        let outcome = build_one(
1258            repo,
1259            implementor,
1260            cfg,
1261            parent,
1262            index,
1263            total,
1264            part,
1265            &dir,
1266            &branch,
1267            &against,
1268        );
1269        match outcome {
1270            Ok(BuildOne::Made(built)) => {
1271                log!("  part {index}: {}", built.pr.url);
1272                state.filed.push(built.pr.url.clone());
1273                // The proposal's list only as a fallback. The branch was pushed
1274                // with a commit on it, so an empty answer means the diff could
1275                // not be read, and taking it would report every file the part
1276                // carries as still being only on the original.
1277                let files = if built.files.is_empty() {
1278                    part.files.clone()
1279                } else {
1280                    built.files
1281                };
1282                for path in overlapping(&made, &files, decision.stacked) {
1283                    // Not an error: the part is pushed and its pull request is
1284                    // open. But two parts carrying one path is the thing a
1285                    // split exists to avoid, so it cannot pass unsaid.
1286                    logwarn!("  part {index} also changed `{path}`, which an earlier part carries");
1287                    state.notes.push(format!(
1288                        "part {index} and an earlier part both carry {path}"
1289                    ));
1290                }
1291                made.push(Made {
1292                    index,
1293                    title: part.title.clone(),
1294                    url: built.pr.url,
1295                    files,
1296                });
1297                worktrees.push((dir, branch.clone()));
1298                if decision.stacked {
1299                    start = branch.clone();
1300                    against = branch;
1301                }
1302            }
1303            Ok(BuildOne::Declined { disposable_head }) => {
1304                if !repo.discard_split_worktree(&dir, &branch, &disposable_head) {
1305                    let reason = format!(
1306                        "part {index} was declined, but its exact mechanical slice could not be \
1307                         discarded safely. The worktree and branch were kept at {} for recovery.",
1308                        dir.display()
1309                    );
1310                    worktrees.push((dir, branch));
1311                    return Ok(BuiltParts {
1312                        made,
1313                        worktrees,
1314                        failure: Some(reason),
1315                    });
1316                }
1317            }
1318            Ok(BuildOne::Halted {
1319                mut reason,
1320                disposable_head,
1321            }) => {
1322                match disposable_head {
1323                    Some(head) => {
1324                        if !repo.discard_split_worktree(&dir, &branch, &head) {
1325                            reason.push_str(&format!(
1326                                "\nThe exact mechanical slice could not be discarded safely. Its \
1327                                 worktree and branch were kept at {} for recovery.",
1328                                dir.display()
1329                            ));
1330                            worktrees.push((dir, branch));
1331                        }
1332                    }
1333                    None => worktrees.push((dir, branch)),
1334                }
1335                return Ok(BuiltParts {
1336                    made,
1337                    worktrees,
1338                    failure: Some(reason),
1339                });
1340            }
1341            Err(e) => {
1342                let reason = format!(
1343                    "part {index} could not be completed: {e}. Its worktree and branch were kept \
1344                     at {} for recovery.",
1345                    dir.display()
1346                );
1347                logwarn!("  {reason}");
1348                state.notes.push(format!("halted {}: {e}", label(part)));
1349                worktrees.push((dir, branch));
1350                return Ok(BuiltParts {
1351                    made,
1352                    worktrees,
1353                    failure: Some(reason),
1354                });
1355            }
1356        }
1357    }
1358    Ok(BuiltParts {
1359        made,
1360        worktrees,
1361        failure: None,
1362    })
1363}
1364
1365/// The paths this part changed that a part already made carries too.
1366///
1367/// `confine` keeps the proposal's parts disjoint, but the stand-alone pass
1368/// commits whatever the part needed to build and nothing tells it what the
1369/// other parts own, so this is only answerable once a part exists.
1370///
1371/// Nothing for a stacked split, where each part is diffed against the one
1372/// before it: changing a file an earlier part changed is what a stack is, and
1373/// the parts land in that order.
1374fn overlapping(made: &[Made], files: &[String], stacked: bool) -> Vec<String> {
1375    if stacked {
1376        return Vec::new();
1377    }
1378    let taken: BTreeSet<&str> = made
1379        .iter()
1380        .flat_map(|m| m.files.iter())
1381        .map(String::as_str)
1382        .collect();
1383    files
1384        .iter()
1385        .filter(|path| taken.contains(path.as_str()))
1386        .cloned()
1387        .collect()
1388}
1389
1390/// Build one part on its own branch, or say it will not stand.
1391///
1392/// Returns None for a part the agent declined, which is dropped rather than
1393/// pushed: a part that does not build has none of the value of splitting.
1394#[allow(clippy::too_many_arguments)]
1395fn build_one(
1396    repo: &Repo,
1397    implementor: &Agent,
1398    cfg: &Config,
1399    parent: &Parent<'_>,
1400    index: usize,
1401    total: usize,
1402    part: &SplitPart,
1403    dir: &Path,
1404    branch: &str,
1405    against: &str,
1406) -> Result<BuildOne> {
1407    let number = parent.number;
1408    log!(
1409        "PR #{number}: building part {index} of {total} on {branch} ({} file(s))",
1410        part.files.len()
1411    );
1412    if !apply_slice(repo, dir, parent.base, parent.head_ref, &part.files)? {
1413        bail!("applying its files changed nothing");
1414    }
1415    let raw_subject = format!("{} (part {index} of #{number})", part.title.trim());
1416    let mut subject = repo.clean_title(&raw_subject)?;
1417    if subject.trim().is_empty() {
1418        subject = format!("Make part {index} of #{number}");
1419    }
1420    repo.commit_staged_changes(dir, &subject)
1421        .map_err(|e| e.with_message(format!("could not commit the slice: {}", e.last_line())))?;
1422    let slice_head = repo.head_oid_checked(dir)?;
1423
1424    let prompt = STAND_ALONE_PROMPT
1425        .replace("{parent}", &number.to_string())
1426        .replace("{base}", &repo.base_ref(dir, against))
1427        .replace("{index}", &index.to_string())
1428        .replace("{total}", &total.to_string())
1429        .replace("{title}", part.title.trim())
1430        .replace("{body}", part.body.trim())
1431        .replace("{files}", &listed(&part.files));
1432    let worktree_baseline = repo.worktree_baseline(dir)?;
1433    let work: Implementation = match implementor.edit_json(
1434        &prompt,
1435        &schema::implementation(),
1436        dir,
1437        &implementor.effort(cfg, Call::Split(1)),
1438    ) {
1439        Ok(work) => work,
1440        Err(e) if e.kind() == ErrorKind::UncertainWrite => {
1441            return Ok(BuildOne::Halted {
1442                reason: format!(
1443                    "{e}. Git state could not be restored safely, so the worktree was kept at {} \
1444                     for recovery.",
1445                    dir.display()
1446                ),
1447                disposable_head: None,
1448            });
1449        }
1450        Err(e) => {
1451            if let Err(recovery) =
1452                repo.refuse_unrepresented_tracked_changes(dir, &worktree_baseline)
1453            {
1454                return Ok(BuildOne::Halted {
1455                    reason: format!(
1456                        "{e}. The editing call also changed tracked working-file bytes or modes: \
1457                         {recovery}. The worktree was kept at {} for recovery.",
1458                        dir.display()
1459                    ),
1460                    disposable_head: None,
1461                });
1462            }
1463            if let Err(ignored) = repo.refuse_failed_edit_leavings(dir, &worktree_baseline) {
1464                return Ok(BuildOne::Halted {
1465                    reason: format!(
1466                        "{e}. The editing call also left ignored work or its ignored files could \
1467                         not be checked: {ignored}. The worktree was kept at {} for recovery.",
1468                        dir.display()
1469                    ),
1470                    disposable_head: None,
1471                });
1472            }
1473            return failed_part_edit(repo, dir, &slice_head, e);
1474        }
1475    };
1476    if let Err(e) = repo.refuse_changed_attributes(dir, &worktree_baseline) {
1477        return Ok(BuildOne::Halted {
1478            reason: format!(
1479                "the stand-alone edit changed an attribute file: {e}. The worktree was kept at \
1480                 {} for recovery.",
1481                dir.display()
1482            ),
1483            disposable_head: None,
1484        });
1485    }
1486    if work.not_worth_doing {
1487        if let Err(e) = repo.refuse_changed_existing_untracked(dir, &worktree_baseline) {
1488            return Ok(BuildOne::Halted {
1489                reason: format!(
1490                    "part {index} was declined after changing existing untracked work: {e}. The \
1491                     worktree was kept at {} for recovery.",
1492                    dir.display()
1493                ),
1494                disposable_head: None,
1495            });
1496        }
1497        if let Err(e) = repo.refuse_unrepresented_tracked_changes(dir, &worktree_baseline) {
1498            return Ok(BuildOne::Halted {
1499                reason: format!(
1500                    "part {index} was declined after changing tracked working-file bytes or modes: \
1501                     {e}. The worktree was kept at {} for recovery.",
1502                    dir.display()
1503                ),
1504                disposable_head: None,
1505            });
1506        }
1507        match work_since_slice(repo, dir, &slice_head) {
1508            Ok(true) => {
1509                return Ok(BuildOne::Halted {
1510                    reason: format!(
1511                        "part {index} was declined after its worktree changed. The worktree was \
1512                         kept at {} for recovery.",
1513                        dir.display()
1514                    ),
1515                    disposable_head: None,
1516                });
1517            }
1518            Err(e) => {
1519                return Ok(BuildOne::Halted {
1520                    reason: format!(
1521                        "part {index} was declined, but its worktree state could not be verified: \
1522                         {e}. The worktree was kept at {} for recovery.",
1523                        dir.display()
1524                    ),
1525                    disposable_head: None,
1526                });
1527            }
1528            Ok(false) => {
1529                if let Err(e) = repo.refuse_new_ignored_files(dir, &worktree_baseline) {
1530                    return Ok(BuildOne::Halted {
1531                        reason: format!(
1532                            "part {index} created ignored work that could not be included: {e}. \
1533                             The worktree was kept at {} for recovery.",
1534                            dir.display()
1535                        ),
1536                        disposable_head: None,
1537                    });
1538                }
1539            }
1540        }
1541        let reason = style::sentence(&work.reason, &repo.style);
1542        log!(
1543            "  part {index} will not stand on its own, dropping it: {}",
1544            if reason.is_empty() {
1545                "no reason given"
1546            } else {
1547                &reason
1548            }
1549        );
1550        return Ok(BuildOne::Declined {
1551            disposable_head: slice_head,
1552        });
1553    }
1554
1555    let committed = repo
1556        .commit_pending_changes(
1557            dir,
1558            &worktree_baseline,
1559            &work.summary,
1560            &format!("Make part {index} of #{number} stand alone"),
1561        )
1562        .and_then(|committed| {
1563            repo.refuse_unrepresented_tracked_changes(dir, &worktree_baseline)?;
1564            Ok(committed)
1565        });
1566    if let Err(e) = committed {
1567        return failed_part_edit(repo, dir, &slice_head, e);
1568    }
1569    let stand_alone_work = match work_since_slice(repo, dir, &slice_head) {
1570        Ok(false) => {
1571            if let Err(e) = repo.refuse_new_ignored_files(dir, &worktree_baseline) {
1572                return Ok(BuildOne::Halted {
1573                    reason: format!(
1574                        "the stand-alone edit created ignored work that could not be included: \
1575                         {e}. The worktree was kept at {} for recovery.",
1576                        dir.display()
1577                    ),
1578                    disposable_head: None,
1579                });
1580            }
1581            false
1582        }
1583        Ok(true) => true,
1584        Err(e) => {
1585            return Ok(BuildOne::Halted {
1586                reason: format!(
1587                    "the stand-alone worktree could not be verified after editing: {e}. It was \
1588                     kept at {} for recovery.",
1589                    dir.display()
1590                ),
1591                disposable_head: None,
1592            });
1593        }
1594    };
1595
1596    // Every failure from here to a confirmed push is classified against the
1597    // mechanical slice. Stand-alone work is kept, while a clean slice with no
1598    // additional edit preserves the old drop behavior.
1599    if let Err(e) = additive(branch, parent.head_branch, &repo.branch_prefix) {
1600        return failed_part_edit(repo, dir, &slice_head, e);
1601    }
1602    // A part branch is built in this invocation and pushed create-only, so
1603    // every commit on it above the base is spar's own and nothing is
1604    // republished under a SHA somebody already has.
1605    if let Err(e) = repo.rewrite_commits_if_needed(dir, against, None) {
1606        return failed_part_edit(repo, dir, &slice_head, e);
1607    }
1608    let disposable_head = if stand_alone_work {
1609        None
1610    } else if repo.head_oid_checked(dir)? == slice_head {
1611        Some(slice_head.clone())
1612    } else {
1613        None
1614    };
1615    if let Err(e) = ensure_parent_head(repo, number, parent.head_oid) {
1616        return Ok(BuildOne::parent_moved(&e, dir, disposable_head));
1617    }
1618    if let Err(e) = repo.push_split_branch(dir, branch) {
1619        return Ok(BuildOne::push_failed(branch, &e, disposable_head));
1620    }
1621
1622    if let Err(e) = ensure_parent_head(repo, number, parent.head_oid) {
1623        return Ok(BuildOne::Halted {
1624            reason: format!(
1625                "{e}. Branch `{branch}` was pushed and its worktree was kept. Compare it with \
1626                 the new parent head. If it is still valid, open its pull request by hand and \
1627                 record it on #{number}. To start over, remove every retained local worktree and \
1628                 branch, child pull request, and remote split branch first."
1629            ),
1630            disposable_head: None,
1631        });
1632    }
1633
1634    let title = format!("{} (part {index} of #{number})", part.title.trim());
1635    let body = part_body(number, index, total, part, &work, &repo.style);
1636    // The branch, not the proposal. The stand-alone pass commits whatever the
1637    // part needed to build, so what it carries is only knowable here, and this
1638    // list is what the comment on the original reports as taken.
1639    let files = repo.changed_files(dir, against);
1640    match repo.create_pr(dir, branch, &PushTarget::Origin, against, &title, &body) {
1641        Ok(pr) => Ok(BuildOne::Made(Built { pr, files })),
1642        Err(e) => Ok(BuildOne::Halted {
1643            reason: format!(
1644                "branch `{branch}` was pushed, but its pull request could not be opened: {e}. Its \
1645                 worktree and branch record were kept. Compare it with the current parent. If it \
1646                 is still valid, open `{branch}` against `{against}` by hand with title `{title}`, \
1647                 then record the new pull request on #{number}. To start over, remove every \
1648                 retained local worktree and branch, child pull request, and remote split branch \
1649                 first."
1650            ),
1651            disposable_head: None,
1652        }),
1653    }
1654}
1655
1656fn failed_part_edit(
1657    repo: &Repo,
1658    dir: &Path,
1659    slice_head: &str,
1660    error: crate::error::SparError,
1661) -> Result<BuildOne> {
1662    if error.kind() == crate::error::ErrorKind::UncertainWrite {
1663        return Ok(BuildOne::Halted {
1664            reason: format!(
1665                "{error}. Git state could not be restored safely, so the worktree was kept at {} \
1666                 for recovery.",
1667                dir.display()
1668            ),
1669            disposable_head: None,
1670        });
1671    }
1672    match work_since_slice(repo, dir, slice_head) {
1673        Ok(false) => Ok(BuildOne::Halted {
1674            reason: error.to_string(),
1675            disposable_head: Some(slice_head.to_string()),
1676        }),
1677        Ok(true) => Ok(BuildOne::Halted {
1678            reason: format!(
1679                "{error}. The editing worktree was kept at {} for recovery.",
1680                dir.display()
1681            ),
1682            disposable_head: None,
1683        }),
1684        Err(probe) => Ok(BuildOne::Halted {
1685            reason: format!(
1686                "{error}. The editing worktree could not be verified: {probe}. It was kept at {} \
1687                 for recovery.",
1688                dir.display()
1689            ),
1690            disposable_head: None,
1691        }),
1692    }
1693}
1694
1695fn work_since_slice(repo: &Repo, dir: &Path, slice_head: &str) -> Result<bool> {
1696    if repo.has_uncommitted_changes(dir)? {
1697        return Ok(true);
1698    }
1699    Ok(repo.head_oid_checked(dir)? != slice_head)
1700}
1701
1702/// Whether anything in this tree is missing from the commit, untracked files
1703/// included.
1704///
1705/// `review::snapshot` asks only about tracked files, and the shape that matters
1706/// here is a file the stand-alone pass wrote and never added: a new module a
1707/// part needs to build is exactly that, and it would be pushed missing.
1708///
1709/// Public so that it can be tested against a real repository.
1710pub fn uncommitted(repo: &Repo, dir: &Path) -> bool {
1711    repo.has_uncommitted_changes(dir).unwrap_or(true)
1712}
1713
1714/// Put one slice of the parent's change on this branch.
1715///
1716/// The parent's own diff for those paths, applied, rather than its versions of
1717/// those files copied over. The branch starts from the base as it is now, and
1718/// copying would revert whatever the base did to the same file after the pull
1719/// request was opened, which is a deletion nobody asked for in a change
1720/// advertised as one part of somebody else's.
1721///
1722/// Reports whether anything is actually staged, because a slice that changed
1723/// nothing is a part with no content.
1724///
1725/// Public so that it can be tested against a real repository, which is where it
1726/// goes wrong.
1727pub fn apply_slice(
1728    repo: &Repo,
1729    dir: &Path,
1730    base: &str,
1731    head_ref: &str,
1732    files: &[String],
1733) -> Result<bool> {
1734    let from = repo.merge_base(dir, base, head_ref)?;
1735    let patch = patch_path(dir);
1736    let written = write_patch(repo, dir, &from, head_ref, files, &patch);
1737    let outcome = written.and_then(|carries| {
1738        if !carries {
1739            return Ok(false);
1740        }
1741        // Three way so the slice still lands when the base has moved under the
1742        // hunks. A conflict is an error rather than markers left in a commit:
1743        // the part is dropped and said out loud, which is what every other way
1744        // a part fails to stand on its own already does.
1745        repo.git_at(
1746            Some(dir),
1747            &["apply", "--index", "--3way", &patch.display().to_string()],
1748        )
1749        .map_err(|e| spar_err!("could not apply its files onto {base}: {}", e.last_line()))?;
1750        Ok(!repo
1751            .git_try_at(Some(dir), &["status", "--porcelain"])
1752            .trim()
1753            .is_empty())
1754    });
1755    let _ = std::fs::remove_file(&patch);
1756    outcome
1757}
1758
1759/// The parent's diff for these paths alone, written to `patch`. False when it
1760/// is empty.
1761fn write_patch(
1762    repo: &Repo,
1763    dir: &Path,
1764    from: &str,
1765    head_ref: &str,
1766    files: &[String],
1767    patch: &Path,
1768) -> Result<bool> {
1769    let mut args: Vec<String> = vec![
1770        "diff".into(),
1771        // `--binary` so a change to an image or a fixture survives the round
1772        // trip, `--no-renames` to match `changed_files`: a part carries paths,
1773        // and a rename it holds only one end of has to stay a deletion and an
1774        // addition rather than become a patch git cannot apply.
1775        "--binary".into(),
1776        "--no-renames".into(),
1777        // Straight to a file rather than through stdout, since a diff of a
1778        // file that is not valid UTF-8 does not survive being read as a string.
1779        format!("--output={}", patch.display()),
1780        from.to_string(),
1781        head_ref.to_string(),
1782        "--".into(),
1783    ];
1784    // `:(literal)` because `--` ends the options and does nothing to pathspec
1785    // matching: a file actually named `*.txt` would otherwise pull in every
1786    // other `.txt` the change touches, including ones another part carries.
1787    args.extend(files.iter().map(|path| format!(":(literal){path}")));
1788
1789    let argv: Vec<&str> = args.iter().map(String::as_str).collect();
1790    repo.git_at(Some(dir), &argv).map_err(|e| {
1791        spar_err!(
1792            "could not read its files out of the head: {}",
1793            e.last_line()
1794        )
1795    })?;
1796    Ok(std::fs::metadata(patch)
1797        .map(|m| m.len() > 0)
1798        .unwrap_or(false))
1799}
1800
1801/// Beside the worktree rather than in it, so the patch is never something the
1802/// slice could commit and never something `--output` writes into the tree it
1803/// describes. The worktree directory is already spar's and already excluded
1804/// from git.
1805fn patch_path(dir: &Path) -> std::path::PathBuf {
1806    let name = dir
1807        .file_name()
1808        .map(|n| n.to_string_lossy().into_owned())
1809        .unwrap_or_else(|| "part".to_string());
1810    let holder = dir.parent().unwrap_or_else(|| Path::new("."));
1811    holder.join(format!("{name}.patch"))
1812}
1813
1814#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1815enum PriorSplit {
1816    None,
1817    Recorded,
1818    RetainedBranches,
1819}
1820
1821/// Whether this pull request has a complete split or retained work from one.
1822///
1823/// An error rather than `None` when either source cannot be read. These checks
1824/// are the only thing standing between a rerun and a second set of branches and
1825/// pull requests, and a failed read is not evidence that the first set is gone.
1826fn prior_split(repo: &Repo, number: i64) -> Result<PriorSplit> {
1827    let comments = repo.try_issue_comments(number).map_err(|e| {
1828        spar_err!(
1829            "could not read the comments on #{number}, so whether it has already been split is \
1830             unknown: {}",
1831            e.last_line()
1832        )
1833    })?;
1834    if comments.iter().any(|c| {
1835        c.get("body")
1836            .and_then(serde_json::Value::as_str)
1837            .is_some_and(already_split)
1838    }) {
1839        return Ok(PriorSplit::Recorded);
1840    }
1841    let retained = repo.has_remote_split_branch(number).map_err(|e| {
1842        spar_err!(
1843            "could not check whether branches for #{number} already exist, so whether it has \
1844             already been split is unknown: {}",
1845            e.last_line()
1846        )
1847    })?;
1848    Ok(if retained {
1849        PriorSplit::RetainedBranches
1850    } else {
1851        PriorSplit::None
1852    })
1853}
1854
1855fn retained_branches_note(number: i64) -> String {
1856    format!(
1857        "remote split branches for PR #{number} show that an earlier split did not finish \
1858         cleanly. Inspect the retained branches and worktrees and compare each branch with the \
1859         current parent. Open any missing child pull request only when its branch is still valid, \
1860         then post the parts summary on #{number} by hand. To start over, remove every retained \
1861         local worktree and branch, child pull request, and remote split branch first. --again \
1862         starts a separate split and does not resume these branches."
1863    )
1864}
1865
1866// ---------------------------------------------------------------------------
1867// Asking the pair
1868// ---------------------------------------------------------------------------
1869
1870/// One agent proposes with the code open, the other rules on the proposal.
1871fn propose_and_check(
1872    agents: &[Agent],
1873    cfg: &Config,
1874    work_dir: &Path,
1875    label: &str,
1876    propose_prompt: &str,
1877    what: &str,
1878) -> Result<Decision> {
1879    let proposer_name = cfg.first_implementor.clone();
1880    let proposer = agent::find(agents, &proposer_name)?;
1881    let checker_name = cfg.other(&proposer_name);
1882    let checker = agent::find(agents, &checker_name)?;
1883
1884    log!("{label}: {proposer_name} proposing a split");
1885    let proposal: SplitProposal = proposer.ask_json(
1886        propose_prompt,
1887        &schema::split_proposal(),
1888        work_dir,
1889        &proposer.effort(cfg, Call::Split(1)),
1890    )?;
1891    if !proposal.should_split {
1892        return Ok(decide(
1893            &proposal,
1894            &SplitCheck::default(),
1895            cfg.loop_cfg.max_split_parts,
1896        ));
1897    }
1898
1899    log!(
1900        "{label}: {checker_name} checking {} proposed part(s)",
1901        proposal.parts.len()
1902    );
1903    let check: SplitCheck = checker.ask_json(
1904        &CHECK_PROMPT
1905            .replace("{what}", what)
1906            .replace("{reason}", proposal.reason.trim())
1907            .replace(
1908                "{shape}",
1909                if proposal.stacked {
1910                    "stacked, each needing the one before it"
1911                } else {
1912                    "independent of each other"
1913                },
1914            )
1915            .replace("{parts}", &render_parts(&proposal.parts)),
1916        &schema::split_check(),
1917        work_dir,
1918        &checker.effort(cfg, Call::Split(2)),
1919    )?;
1920
1921    Ok(decide(&proposal, &check, cfg.loop_cfg.max_split_parts))
1922}
1923
1924fn render_parts(parts: &[SplitPart]) -> String {
1925    parts
1926        .iter()
1927        .enumerate()
1928        .map(|(i, p)| {
1929            let files = if p.files.is_empty() {
1930                String::new()
1931            } else {
1932                format!("\n   files: {}", p.files.join(", "))
1933            };
1934            format!("{}. {}\n   {}{files}", i + 1, p.title.trim(), p.body.trim())
1935        })
1936        .collect::<Vec<_>>()
1937        .join("\n\n")
1938}
1939
1940// ---------------------------------------------------------------------------
1941// What a person reads
1942// ---------------------------------------------------------------------------
1943
1944fn listed(paths: &[String]) -> String {
1945    paths
1946        .iter()
1947        .map(|p| format!("- {p}"))
1948        .collect::<Vec<_>>()
1949        .join("\n")
1950}
1951
1952fn bullets(lines: &[String]) -> String {
1953    lines
1954        .iter()
1955        .map(|line| format!("- {line}"))
1956        .collect::<Vec<_>>()
1957        .join("\n")
1958}
1959
1960/// The body of one part's pull request.
1961///
1962/// It says which pull request it came out of, because that is the parent record:
1963/// parts get no issues of their own, and the comment on the original is the
1964/// list of them.
1965fn part_body(
1966    parent: i64,
1967    index: usize,
1968    total: usize,
1969    part: &SplitPart,
1970    work: &Implementation,
1971    style: &Style,
1972) -> String {
1973    let mut out = vec![format!("Part {index} of {total}, split out of #{parent}.")];
1974    for lead in [&work.summary, &work.problem] {
1975        let text = style::sentence(lead, style);
1976        if !text.is_empty() {
1977            out.push(text);
1978        }
1979    }
1980    if out.len() == 1 {
1981        // Nothing was said about the change, so say what the part is for.
1982        let text = style::sentence(&part.body, style);
1983        if !text.is_empty() {
1984            out.push(text);
1985        }
1986    }
1987    for (heading, lines) in [
1988        ("What changed", &work.changes),
1989        ("How to test", &work.testing),
1990    ] {
1991        let items: Vec<String> = lines
1992            .iter()
1993            .map(|line| style::summary(line, style))
1994            .filter(|line| !line.is_empty())
1995            .collect();
1996        if !items.is_empty() {
1997            out.push(format!("## {heading}\n\n{}", bullets(&items)));
1998        }
1999    }
2000    let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
2001    if !notes.is_empty() {
2002        out.push(format!("## Notes\n\n{notes}"));
2003    }
2004    style::body(&out.join("\n\n"), style)
2005}
2006
2007/// The comment left on a pull request that has been split.
2008///
2009/// It says what was made and what was left over, and it says that the pull
2010/// request itself was not touched, because that is the thing somebody opening
2011/// this wants to know first.
2012///
2013/// One part is a separate sentence rather than a count of one. Every other part
2014/// was dropped, so nothing was decomposed and the one that exists duplicates a
2015/// piece of this pull request. Somebody has to be told that plainly, because
2016/// the only thing to do with it is close it or close this.
2017fn parts_comment(made: &[Made], left: &[String], style: &Style) -> String {
2018    let listed: Vec<String> = made
2019        .iter()
2020        .map(|m| format!("part {}: {} {}", m.index, m.url, m.title.trim()))
2021        .collect();
2022    let lead = if made.len() < 2 {
2023        "Only one part of this stood on its own, so it has not been split. That part was already \
2024         opened as its own pull request, and it carries files this one still carries:"
2025            .to_string()
2026    } else {
2027        format!("Split into {} pull request(s):", made.len())
2028    };
2029    let mut out = vec![SPLIT_MARKER.to_string(), lead, bullets(&listed)];
2030    if !left.is_empty() {
2031        out.push(format!(
2032            "{} file(s) are in no part, and are still only here:\n{}",
2033            left.len(),
2034            bullets(left)
2035        ));
2036    }
2037    out.push(
2038        "This pull request has not been changed. Its branch, its commits, and its own review are \
2039         untouched, and it is still open."
2040            .to_string(),
2041    );
2042    style::body(&out.join("\n\n"), style)
2043}
2044
2045/// The comment left on a fork's pull request, which is proposed and not split.
2046fn proposal_comment(number: i64, decision: &Decision, left: &[String], style: &Style) -> String {
2047    let listed: Vec<String> = decision
2048        .parts
2049        .iter()
2050        .map(|p| {
2051            if p.files.is_empty() {
2052                p.title.trim().to_string()
2053            } else {
2054                format!("{} ({})", p.title.trim(), p.files.join(", "))
2055            }
2056        })
2057        .collect();
2058    let mut out = vec![
2059        SPLIT_MARKER.to_string(),
2060        format!(
2061            "#{number} comes from a fork, so this is a proposal rather than a change. Two agents \
2062             read it and agreed it would review better as {} pieces:",
2063            decision.parts.len()
2064        ),
2065        bullets(&listed),
2066    ];
2067    if decision.stacked {
2068        out.push("They have to land in that order.".to_string());
2069    }
2070    if !left.is_empty() {
2071        out.push(format!(
2072            "{} file(s) are in none of them:\n{}",
2073            left.len(),
2074            bullets(left)
2075        ));
2076    }
2077    out.push("Nothing has been changed here.".to_string());
2078    style::body(&out.join("\n\n"), style)
2079}
2080
2081/// What `--dry-run` prints. `println!`, not `log!`: it is the whole output of
2082/// the command in that mode.
2083fn print_proposal(number: i64, kind: &str, decision: &Decision, left: &[String]) {
2084    println!(
2085        "\n{kind} #{number} would be split into {} part(s):",
2086        decision.parts.len()
2087    );
2088    for (i, part) in decision.parts.iter().enumerate() {
2089        println!("  {}. {}", i + 1, style::clip(part.title.trim(), 90));
2090        if !part.files.is_empty() {
2091            println!("     {}", part.files.join(", "));
2092        }
2093    }
2094    if decision.stacked {
2095        println!("  each part is based on the one before it");
2096    }
2097    if !left.is_empty() {
2098        println!("  left over: {}", left.join(", "));
2099    }
2100    for note in &decision.dropped {
2101        println!("  dropped {note}");
2102    }
2103    println!("Nothing was written.");
2104}
2105
2106#[cfg(test)]
2107mod tests {
2108    use super::*;
2109
2110    fn part(title: &str, files: &[&str]) -> SplitPart {
2111        SplitPart {
2112            title: title.into(),
2113            body: format!("what {title} is"),
2114            files: files.iter().map(|f| f.to_string()).collect(),
2115        }
2116    }
2117
2118    fn proposal(parts: Vec<SplitPart>) -> SplitProposal {
2119        SplitProposal {
2120            should_split: true,
2121            reason: "three things".into(),
2122            stacked: false,
2123            parts,
2124        }
2125    }
2126
2127    fn accept() -> SplitCheck {
2128        SplitCheck {
2129            accept: true,
2130            stacked: false,
2131            strike: vec![],
2132            reasoning: "read it".into(),
2133        }
2134    }
2135
2136    /// The multiplication guard. One command that turns twenty open items into
2137    /// sixty is a queue nobody asked for, and this codebase has been burned by
2138    /// exactly that once already.
2139    #[test]
2140    fn the_cap_holds_and_says_what_it_held_back() {
2141        let parts = vec![
2142            part("one", &[]),
2143            part("two", &[]),
2144            part("three", &[]),
2145            part("four", &[]),
2146        ];
2147        let out = decide(&proposal(parts), &accept(), 2);
2148        assert_eq!(2, out.parts.len());
2149        assert!(out.splits());
2150        assert_eq!(2, out.dropped.len(), "{:?}", out.dropped);
2151        assert!(out.dropped[0].contains("three"), "{:?}", out.dropped);
2152        assert!(out.dropped[0].contains("cap"), "{:?}", out.dropped);
2153    }
2154
2155    /// The proposing agent declining is the cheapest way out, and it must not
2156    /// cost a second call or produce parts.
2157    #[test]
2158    fn a_proposal_that_declines_splits_nothing() {
2159        let mut p = proposal(vec![part("one", &[]), part("two", &[])]);
2160        p.should_split = false;
2161        p.reason = "it is one change".into();
2162        let out = decide(&p, &accept(), 4);
2163        assert!(!out.splits());
2164        assert_eq!(Some("it is one change".to_string()), out.declined);
2165        assert!(out.parts.is_empty());
2166    }
2167
2168    /// Disagreement resolves toward not splitting.
2169    #[test]
2170    fn a_rejected_proposal_splits_nothing() {
2171        let check = SplitCheck {
2172            accept: false,
2173            reasoning: "these are the same change".into(),
2174            ..accept()
2175        };
2176        let out = decide(
2177            &proposal(vec![part("one", &[]), part("two", &[])]),
2178            &check,
2179            4,
2180        );
2181        assert!(!out.splits());
2182        assert!(out.declined.unwrap().contains("same change"));
2183    }
2184
2185    #[test]
2186    fn a_struck_part_is_not_made() {
2187        let parts = vec![part("one", &[]), part("two", &[]), part("three", &[])];
2188        let check = SplitCheck {
2189            strike: vec![2],
2190            ..accept()
2191        };
2192        let out = decide(&proposal(parts), &check, 4);
2193        assert_eq!(2, out.parts.len());
2194        assert_eq!(vec!["one", "three"], titles(&out));
2195        assert!(out.dropped[0].contains("two"), "{:?}", out.dropped);
2196    }
2197
2198    /// A split into one part is not a split, so striking enough parts is a way
2199    /// of saying no.
2200    #[test]
2201    fn striking_all_but_one_part_leaves_it_whole() {
2202        let parts = vec![part("one", &[]), part("two", &[])];
2203        let check = SplitCheck {
2204            strike: vec![1],
2205            reasoning: "only one of these stands alone".into(),
2206            ..accept()
2207        };
2208        let out = decide(&proposal(parts), &check, 4);
2209        assert!(!out.splits());
2210        assert!(out.parts.is_empty());
2211        assert!(out.declined.unwrap().contains("stands alone"));
2212    }
2213
2214    /// Whether the parts are stacked is a property of the change, and being
2215    /// wrong about it is not symmetric: parts built independently out of a
2216    /// sequential change do not build, and a part that does not build is
2217    /// dropped.
2218    #[test]
2219    fn either_agent_calling_it_stacked_makes_it_stacked() {
2220        let mut p = proposal(vec![part("one", &[]), part("two", &[])]);
2221        assert!(!decide(&p, &accept(), 4).stacked);
2222
2223        p.stacked = true;
2224        assert!(decide(&p, &accept(), 4).stacked);
2225
2226        p.stacked = false;
2227        let check = SplitCheck {
2228            stacked: true,
2229            ..accept()
2230        };
2231        assert!(decide(&p, &check, 4).stacked);
2232    }
2233
2234    fn titles(d: &Decision) -> Vec<String> {
2235        d.parts.iter().map(|p| p.title.clone()).collect()
2236    }
2237
2238    /// The whole text somebody wrote comes through, and the checklist is added
2239    /// after it. This is the one place spar rewrites prose it did not write.
2240    #[test]
2241    fn a_tracker_body_keeps_every_byte_of_the_original() {
2242        let original =
2243            "The retry loop spins.\n\n```rust\nfn go() {}\n```\n\n## Impact\n\nBad.  \n\n";
2244        let out = tracker_body(original, &[("First".into(), 101), ("Second".into(), 102)]);
2245        assert!(out.starts_with(original), "{out}");
2246        assert_eq!(original.as_bytes(), &out.as_bytes()[..original.len()]);
2247        assert!(out.contains("- [ ] #101 First"), "{out}");
2248        assert!(out.contains("- [ ] #102 Second"), "{out}");
2249    }
2250
2251    #[test]
2252    fn a_parent_head_must_still_be_the_one_the_agents_read() {
2253        assert!(same_parent_head(34, "abc123", "abc123").is_ok());
2254        let error = same_parent_head(34, "abc123", "def456").unwrap_err();
2255        assert!(error.to_string().contains("unread head"), "{error}");
2256    }
2257
2258    #[test]
2259    fn filed_child_issues_require_manual_tracker_recovery() {
2260        let mut state = IssueRun::new(34, "split this");
2261        let parts = vec![("first".to_string(), 101), ("second".to_string(), 102)];
2262        let error = SparError::new("parent changed");
2263        record_issue_tracker_failure(&mut state, 34, &parts, &error);
2264
2265        assert_eq!(Status::Error, state.status);
2266        let note = state.notes.join("\n");
2267        assert!(note.contains("Do not rerun this split"), "{note}");
2268        assert!(note.contains("Add them to #34 by hand"), "{note}");
2269        assert!(note.contains("#101 first"), "{note}");
2270        assert!(note.contains("#102 second"), "{note}");
2271    }
2272
2273    #[test]
2274    fn an_uncertain_tracker_write_requires_inspection_before_editing() {
2275        let mut state = IssueRun::new(34, "split this");
2276        let parts = vec![("first".to_string(), 101), ("second".to_string(), 102)];
2277        let error = SparError::uncertain_write("the parent could not be reread");
2278        record_issue_tracker_failure(&mut state, 34, &parts, &error);
2279
2280        assert_eq!(Status::Error, state.status);
2281        let note = state.notes.join("\n");
2282        assert!(note.contains("write may already have landed"), "{note}");
2283        assert!(note.contains("current body of #34"), "{note}");
2284        assert!(note.contains(SPLIT_MARKER), "{note}");
2285        assert!(note.contains("do not add them again"), "{note}");
2286        assert!(
2287            note.contains("only the missing marker or child links"),
2288            "{note}"
2289        );
2290        assert!(!note.contains("Add them to #34 by hand"), "{note}");
2291        assert!(note.contains("#101 first"), "{note}");
2292        assert!(note.contains("#102 second"), "{note}");
2293    }
2294
2295    #[test]
2296    fn one_confirmed_child_is_an_error_until_it_is_recorded_or_closed() {
2297        let mut state = IssueRun::new(34, "split this");
2298        let parts = vec![("first".to_string(), 101)];
2299        record_partial_issue_split(&mut state, 34, &parts);
2300
2301        assert_eq!(Status::Error, state.status);
2302        let note = state.notes.join("\n");
2303        assert!(note.contains("Do not rerun this split"), "{note}");
2304        assert!(note.contains("Link it from #34 by hand"), "{note}");
2305        assert!(note.contains("close it first"), "{note}");
2306        assert!(note.contains("#101 first"), "{note}");
2307    }
2308
2309    #[test]
2310    fn an_uncertain_child_write_stops_before_rewriting_the_parent() {
2311        let mut state = IssueRun::new(34, "split this");
2312        let listed = vec![("first".to_string(), 101)];
2313        record_uncertain_issue_part(
2314            &mut state,
2315            34,
2316            "second",
2317            &listed,
2318            "the result could not be verified",
2319        );
2320
2321        assert_eq!(Status::Error, state.status);
2322        let note = state.notes.join("\n");
2323        assert!(note.contains("write landed is unknown"), "{note}");
2324        assert!(note.contains("#101 first"), "{note}");
2325        assert!(
2326            note.contains("complete the tracker on #34 by hand"),
2327            "{note}"
2328        );
2329        assert!(note.contains("Do not rerun this split"), "{note}");
2330    }
2331
2332    #[test]
2333    fn a_later_allocation_failure_preserves_partial_recovery_guidance() {
2334        let error = crate::error::SparError::new("no free branch name");
2335        let note = worktree_allocation_failure(34, 3, 2, &error);
2336        assert!(note.contains("2 child pull request"), "{note}");
2337        assert!(note.contains("worktrees were kept"), "{note}");
2338        assert!(note.contains("record the partial result on #34"), "{note}");
2339        assert!(note.contains("remove every retained"), "{note}");
2340    }
2341
2342    #[test]
2343    fn successful_stacked_worktrees_are_all_released_unless_kept() {
2344        let worktrees = vec![
2345            (PathBuf::from("one"), "split-34-1".to_string()),
2346            (PathBuf::from("two"), "split-34-2".to_string()),
2347        ];
2348        let mut released = Vec::new();
2349        release_part_worktrees_with(false, Status::Split, worktrees.clone(), |dir, branch| {
2350            released.push((dir.to_path_buf(), branch.to_string()))
2351        });
2352        assert_eq!(worktrees, released);
2353
2354        for (configured, status) in [(true, Status::Split), (false, Status::Error)] {
2355            let mut released = Vec::new();
2356            release_part_worktrees_with(configured, status, worktrees.clone(), |dir, branch| {
2357                released.push((dir.to_path_buf(), branch.to_string()))
2358            });
2359            assert!(released.is_empty(), "worktrees were not retained");
2360        }
2361    }
2362
2363    #[test]
2364    fn a_missing_parent_comment_is_an_error_with_recovery_text() {
2365        let mut state = IssueRun::new(34, "split this");
2366        let error = SparError::new("offline");
2367        record_parent_comment_failure(&mut state, 34, "the summary", &error);
2368
2369        assert_eq!(Status::Error, state.status);
2370        let note = state.notes.join("\n");
2371        assert!(note.contains("branches stop an automatic retry"), "{note}");
2372        assert!(note.contains("finish recording the split"), "{note}");
2373        assert!(note.contains("the summary"), "{note}");
2374    }
2375
2376    #[test]
2377    fn an_uncertain_parent_comment_requires_inspection_before_posting() {
2378        let mut state = IssueRun::new(34, "split this");
2379        let error = SparError::uncertain_write("the comments could not be reread");
2380        record_parent_comment_failure(&mut state, 34, "the summary", &error);
2381
2382        assert_eq!(Status::Error, state.status);
2383        let note = state.notes.join("\n");
2384        assert!(note.contains("comment may already have landed"), "{note}");
2385        assert!(
2386            note.contains("Inspect every top-level comment on #34"),
2387            "{note}"
2388        );
2389        assert!(note.contains("do not post it again"), "{note}");
2390        assert!(note.contains("If it is absent, post it once"), "{note}");
2391        assert!(!note.contains("Post this comment by hand"), "{note}");
2392        assert!(note.contains("the summary"), "{note}");
2393    }
2394
2395    #[test]
2396    fn retained_branches_explain_manual_recovery_and_again() {
2397        let note = retained_branches_note(34);
2398        assert!(
2399            note.contains("Open any missing child pull request"),
2400            "{note}"
2401        );
2402        assert!(note.contains("current parent"), "{note}");
2403        assert!(note.contains("local worktree and branch"), "{note}");
2404        assert!(note.contains("remove every retained"), "{note}");
2405        assert!(note.contains("does not resume"), "{note}");
2406    }
2407
2408    #[test]
2409    fn a_push_collision_halts_instead_of_dropping_one_part() {
2410        let error = SplitPushError::new("the create-only lease was rejected", false);
2411        match BuildOne::push_failed("split-34-1", &error, Some("slice".into())) {
2412            BuildOne::Halted {
2413                reason,
2414                disposable_head,
2415            } => {
2416                assert_eq!(Some("slice".to_string()), disposable_head);
2417                assert!(reason.contains("stopped"), "{reason}");
2418                assert!(reason.contains("competing pull requests"), "{reason}");
2419            }
2420            _ => panic!("a push collision did not halt the split"),
2421        }
2422    }
2423
2424    #[test]
2425    fn an_unverified_push_halts_and_keeps_the_worktree() {
2426        let error = SplitPushError::new("origin could not be read", true);
2427        match BuildOne::push_failed("split-34-1", &error, Some("slice".into())) {
2428            BuildOne::Halted {
2429                reason,
2430                disposable_head,
2431            } => {
2432                assert!(disposable_head.is_none());
2433                assert!(reason.contains("may now exist on origin"), "{reason}");
2434                assert!(
2435                    reason.contains("worktree and branch record were kept"),
2436                    "{reason}"
2437                );
2438            }
2439            _ => panic!("an unverified push did not halt the split"),
2440        }
2441    }
2442
2443    #[test]
2444    fn a_definite_push_refusal_keeps_stand_alone_edit_work() {
2445        let error = SplitPushError::new("origin is known not to contain the branch", false);
2446        match BuildOne::push_failed("split-34-1", &error, None) {
2447            BuildOne::Halted {
2448                reason,
2449                disposable_head,
2450            } => {
2451                assert!(disposable_head.is_none());
2452                assert!(
2453                    reason.contains("not confirmed to match its mechanical slice"),
2454                    "{reason}"
2455                );
2456                assert!(reason.contains("kept for recovery"), "{reason}");
2457            }
2458            _ => panic!("a local edit was dropped after the push refusal"),
2459        }
2460    }
2461
2462    #[test]
2463    fn a_parent_move_keeps_stand_alone_edit_work() {
2464        let error = SparError::new("the parent head moved before the push");
2465        match BuildOne::parent_moved(&error, Path::new("/tmp/split-part"), None) {
2466            BuildOne::Halted {
2467                reason,
2468                disposable_head,
2469            } => {
2470                assert!(disposable_head.is_none());
2471                assert!(reason.contains("parent head moved"), "{reason}");
2472                assert!(reason.contains("/tmp/split-part"), "{reason}");
2473                assert!(reason.contains("kept"), "{reason}");
2474            }
2475            _ => panic!("a local edit was dropped after the parent moved"),
2476        }
2477    }
2478
2479    /// The parent is what #29 consumes, so what is written has to be what is
2480    /// recognised.
2481    #[test]
2482    fn a_body_this_wrote_reads_back_as_already_split() {
2483        assert!(!already_split("just an issue"));
2484        let out = tracker_body("just an issue", &[("a".into(), 1), ("b".into(), 2)]);
2485        assert!(already_split(&out), "{out}");
2486    }
2487
2488    /// A fence somebody left open would otherwise swallow the checklist, which
2489    /// then renders as code while `already_split` still says the parent is a
2490    /// tracker: it looks untouched and no later run comes back to it.
2491    #[test]
2492    fn a_fence_left_open_is_closed_before_the_checklist() {
2493        let out = tracker_body("Here:\n\n```rust\nfn unfinished() {}", &[("a".into(), 1)]);
2494        assert!(out.contains("fn unfinished"), "{out}");
2495        let after = out.split("fn unfinished() {}").nth(1).unwrap();
2496        assert!(after.trim_start().starts_with("```"), "{out}");
2497        assert!(already_split(&out), "{out}");
2498    }
2499
2500    /// The closer has to match the opener, or the checklist stays inside the
2501    /// block while `already_split` reports the parent as a tracker: it looks
2502    /// untouched and no later run comes back to it.
2503    #[test]
2504    fn an_open_fence_is_closed_by_one_that_actually_closes_it() {
2505        for original in [
2506            // Backticks close nothing here.
2507            "Here:\n\n~~~\nfn unfinished() {}",
2508            // Three backticks are content inside a block opened with four.
2509            "Here:\n\n````\n```\nfn unfinished() {}",
2510        ] {
2511            let out = tracker_body(original, &[("a".into(), 1)]);
2512            let marker = out.split(SPLIT_MARKER).next().unwrap();
2513            assert!(
2514                unclosed_fence(marker).is_none(),
2515                "the checklist is inside a code block: {out}"
2516            );
2517            assert!(already_split(&out), "{out}");
2518        }
2519    }
2520
2521    /// The other half of that: a body whose fences are balanced must not have
2522    /// one opened for it, which would put the checklist inside a code block
2523    /// this wrote.
2524    #[test]
2525    fn a_closed_fence_is_left_alone() {
2526        for original in [
2527            "Here:\n\n```rust\nfn done() {}\n```",
2528            // A fence of the other character inside a block is content, not a
2529            // fence, so counting them would find three and close one.
2530            "Here:\n\n```\n~~~\n```",
2531            // Same for a shorter run of the same character.
2532            "Here:\n\n````\n```\n````",
2533            "no fences at all",
2534        ] {
2535            let out = tracker_body(original, &[("a".into(), 1)]);
2536            assert!(
2537                out.starts_with(&format!("{original}\n\n{SPLIT_MARKER}")),
2538                "{out}"
2539            );
2540        }
2541    }
2542
2543    /// An empty body is not a reason to write a checklist with a blank line
2544    /// above it, or to lose the marker.
2545    #[test]
2546    fn a_tracker_body_from_nothing_is_still_a_tracker() {
2547        let out = tracker_body("", &[("a".into(), 1)]);
2548        assert!(already_split(&out), "{out}");
2549        assert!(out.starts_with(SPLIT_MARKER), "{out}");
2550    }
2551
2552    /// The invariant, as code. Splitting a pull request touches code somebody
2553    /// wrote, and the only thing that makes it safe is that it is purely
2554    /// additive.
2555    #[test]
2556    fn only_a_branch_the_split_made_may_be_pushed_to() {
2557        assert!(additive("split-12-1", "pr-12", "").is_ok());
2558        assert!(additive("spar/split-12-1", "spar/pr-12", "spar/").is_ok());
2559
2560        for branch in ["main", "pr-12", "issue-12", "their-feature", "split-12-1"] {
2561            assert!(
2562                additive(branch, "main", "spar/").is_err(),
2563                "{branch} was allowed outside the split namespace"
2564            );
2565        }
2566        // Even inside the namespace, never the parent's own branch.
2567        assert!(additive("split-12-1", "split-12-1", "").is_err());
2568    }
2569
2570    fn carried(parts: &[SplitPart]) -> Vec<&[String]> {
2571        parts.iter().map(|p| p.files.as_slice()).collect()
2572    }
2573
2574    #[test]
2575    fn what_no_part_carries_is_reported_as_left_over() {
2576        let changed = vec!["a.rs".to_string(), "b.rs".into(), "c.rs".into()];
2577        let parts = vec![part("one", &["a.rs"]), part("two", &["c.rs"])];
2578        assert_eq!(
2579            vec!["b.rs".to_string()],
2580            leftover(&changed, carried(&parts))
2581        );
2582        let all = [part("all", &["a.rs", "b.rs", "c.rs"])];
2583        assert!(leftover(&changed, carried(&all)).is_empty());
2584    }
2585
2586    /// A part that was proposed and then dropped leaves its files on the
2587    /// original, and the comment on the original is the only permanent record
2588    /// of that. Counting from the proposal instead loses them silently.
2589    #[test]
2590    fn a_dropped_part_leaves_its_files_in_the_leftover_report() {
2591        let changed = vec!["a.rs".to_string(), "b.rs".into()];
2592        let made = vec![Made {
2593            index: 1,
2594            title: "one".into(),
2595            url: "https://example.invalid/pull/1".into(),
2596            files: vec!["a.rs".to_string()],
2597        }];
2598        let left = leftover(&changed, made.iter().map(|m| m.files.as_slice()));
2599        assert_eq!(vec!["b.rs".to_string()], left);
2600        assert!(
2601            parts_comment(&made, &left, &Style::default()).contains("b.rs"),
2602            "the file of the dropped part went unsaid"
2603        );
2604    }
2605
2606    /// The file list came from spar, so anything not in it is a paraphrase. A
2607    /// path with `..` in it would otherwise be written outside the worktree.
2608    #[test]
2609    fn a_part_may_only_carry_paths_the_change_actually_touches() {
2610        let changed = vec!["a.rs".to_string(), "b.rs".into()];
2611        let mut parts = vec![part("one", &["a.rs", "../../etc/passwd", "invented.rs"])];
2612        let unknown = confine(&mut parts, &changed);
2613        assert_eq!(vec!["a.rs".to_string()], parts[0].files);
2614        assert_eq!(2, unknown.len(), "{unknown:?}");
2615    }
2616
2617    /// A path in two parts would be carried twice and reviewed twice, and for a
2618    /// stacked split the second copy would conflict with the first.
2619    #[test]
2620    fn a_path_claimed_twice_stays_with_the_first_part() {
2621        let changed = vec!["a.rs".to_string(), "b.rs".into()];
2622        let mut parts = vec![part("one", &["a.rs", "b.rs"]), part("two", &["b.rs"])];
2623        confine(&mut parts, &changed);
2624        assert_eq!(vec!["a.rs".to_string(), "b.rs".to_string()], parts[0].files);
2625        assert!(parts[1].files.is_empty(), "{:?}", parts[1].files);
2626    }
2627
2628    /// What a part carries is read off its branch, so the stand-alone pass can
2629    /// put it on top of a file another part already took. `confine` cannot see
2630    /// that: it runs before either part exists.
2631    #[test]
2632    fn a_path_two_parts_ended_up_carrying_is_reported() {
2633        let made = vec![Made {
2634            index: 1,
2635            title: "one".into(),
2636            url: "https://example.invalid/pull/1".into(),
2637            files: vec!["a.rs".to_string(), "lib.rs".into()],
2638        }];
2639        let second = vec!["b.rs".to_string(), "lib.rs".into()];
2640        assert_eq!(
2641            vec!["lib.rs".to_string()],
2642            overlapping(&made, &second, false)
2643        );
2644        // A stacked part is diffed against the one before it, so a shared path
2645        // is the stack working, not two parts fighting over a file.
2646        assert!(overlapping(&made, &second, true).is_empty());
2647    }
2648
2649    /// Both comments a pull request split can leave have to be recognisable
2650    /// again, or the next run proposes the same split, and for a fork that
2651    /// means the same comment once a run forever.
2652    #[test]
2653    fn a_comment_this_wrote_reads_back_as_already_split() {
2654        let style = Style::default();
2655        let made = vec![Made {
2656            index: 1,
2657            title: "First".into(),
2658            url: "https://example.invalid/pull/201".into(),
2659            files: vec!["first.rs".to_string()],
2660        }];
2661        let comment = parts_comment(&made, &["left.rs".to_string()], &style);
2662        assert!(already_split(&comment), "{comment}");
2663        assert!(comment.contains("/pull/201"), "{comment}");
2664        assert!(comment.contains("left.rs"), "{comment}");
2665        assert!(comment.contains("has not been changed"), "{comment}");
2666
2667        let decision = decide(
2668            &proposal(vec![part("one", &["a.rs"]), part("two", &["b.rs"])]),
2669            &accept(),
2670            4,
2671        );
2672        let proposed = proposal_comment(12, &decision, &[], &style);
2673        assert!(already_split(&proposed), "{proposed}");
2674        assert!(proposed.contains("Nothing has been changed"), "{proposed}");
2675    }
2676
2677    /// One surviving part is not a decomposition: the original still carries
2678    /// everything that part carries. It exists and cannot be unmade, so it is
2679    /// still named, but the comment must not call that a split.
2680    #[test]
2681    fn one_surviving_part_is_not_announced_as_a_split() {
2682        let made = vec![Made {
2683            index: 1,
2684            title: "First".into(),
2685            url: "https://example.invalid/pull/201".into(),
2686            files: vec!["first.rs".to_string()],
2687        }];
2688        let comment = parts_comment(&made, &[], &Style::default());
2689        assert!(!comment.contains("Split into"), "{comment}");
2690        assert!(comment.contains("has not been split"), "{comment}");
2691        assert!(comment.contains("/pull/201"), "{comment}");
2692        // Still recognisable, or the next run makes a second copy of it.
2693        assert!(already_split(&comment), "{comment}");
2694    }
2695
2696    /// A part with no title cannot be filed and cannot be branched, so it is
2697    /// dropped with the rest still made.
2698    #[test]
2699    fn a_part_with_no_title_is_dropped_rather_than_filed() {
2700        let parts = vec![part("one", &[]), part("", &[]), part("three", &[])];
2701        let out = decide(&proposal(parts), &accept(), 4);
2702        assert_eq!(vec!["one", "three"], titles(&out));
2703        assert!(out.dropped[0].contains("no title"), "{:?}", out.dropped);
2704    }
2705}