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