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