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 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
504pub 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 let mut listed: Vec<(String, i64)> = Vec::new();
600 for (i, part) in decision.parts.iter().enumerate() {
601 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 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 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 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
768pub 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 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 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 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
1070fn proposed_leftover(changed: &[String], decision: &Decision) -> Vec<String> {
1073 leftover(changed, decision.parts.iter().map(|p| p.files.as_slice()))
1074}
1075
1076struct 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 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
1180struct Made {
1183 index: usize,
1184 title: String,
1185 url: String,
1186 files: Vec<String>,
1187}
1188
1189struct Parent<'a> {
1191 number: i64,
1192 base: &'a str,
1194 head_ref: &'a str,
1196 head_oid: &'a str,
1198 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 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 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 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
1362fn 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#[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 if let Err(e) = additive(branch, parent.head_branch, &repo.branch_prefix) {
1597 return failed_part_edit(repo, dir, &slice_head, e);
1598 }
1599 if let Err(e) = repo.rewrite_commits_if_needed(dir, against, 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 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
1699pub fn uncommitted(repo: &Repo, dir: &Path) -> bool {
1708 repo.has_uncommitted_changes(dir).unwrap_or(true)
1709}
1710
1711pub 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 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
1756fn 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".into(),
1773 "--no-renames".into(),
1774 format!("--output={}", patch.display()),
1777 from.to_string(),
1778 head_ref.to_string(),
1779 "--".into(),
1780 ];
1781 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
1798fn 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
1818fn 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
1863fn 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
1937fn 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
1957fn 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 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
2004fn 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
2042fn 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
2078fn 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
2501 fn an_open_fence_is_closed_by_one_that_actually_closes_it() {
2502 for original in [
2503 "Here:\n\n~~~\nfn unfinished() {}",
2505 "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 #[test]
2522 fn a_closed_fence_is_left_alone() {
2523 for original in [
2524 "Here:\n\n```rust\nfn done() {}\n```",
2525 "Here:\n\n```\n~~~\n```",
2528 "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 #[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 #[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 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 #[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 #[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 #[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 #[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 assert!(overlapping(&made, &second, true).is_empty());
2644 }
2645
2646 #[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 #[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 assert!(already_split(&comment), "{comment}");
2691 }
2692
2693 #[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}