Skip to main content

spar/
split.rs

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