Skip to main content

spar/
split.rs

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