1use std::collections::BTreeSet;
33use std::path::{Path, PathBuf};
34
35use crate::agent::{self, Agent};
36use crate::config::Config;
37use crate::error::{ErrorKind, Result, SparError};
38use crate::model::{
39 Implementation, Issue, IssueRun, ItemKind, PrRow, PrView, SplitCheck, SplitPart, SplitProposal,
40 SplitScreen, SplitScreenDoc, Status,
41};
42use crate::repo::{Repo, SplitPushError, WorktreeCheckpoint};
43use crate::style::{self, Style};
44use crate::{bail, log, logdim, logwarn, schema, spar_err};
45
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 cfg.effort_for_round(&agent.spec, 1).as_deref(),
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}", 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}", against)
1424 .replace("{index}", &index.to_string())
1425 .replace("{total}", &total.to_string())
1426 .replace("{title}", part.title.trim())
1427 .replace("{body}", part.body.trim())
1428 .replace("{files}", &listed(&part.files));
1429 let worktree_baseline = repo.worktree_baseline(dir)?;
1430 let work: Implementation = match implementor.edit_json(
1431 &prompt,
1432 &schema::implementation(),
1433 dir,
1434 cfg.effort_for_round(&implementor.spec, 1).as_deref(),
1435 ) {
1436 Ok(work) => work,
1437 Err(e) if e.kind() == ErrorKind::UncertainWrite => {
1438 return Ok(BuildOne::Halted {
1439 reason: format!(
1440 "{e}. Git state could not be restored safely, so the worktree was kept at {} \
1441 for recovery.",
1442 dir.display()
1443 ),
1444 disposable_head: None,
1445 });
1446 }
1447 Err(e) => {
1448 if let Err(recovery) =
1449 repo.refuse_unrepresented_tracked_changes(dir, &worktree_baseline)
1450 {
1451 return Ok(BuildOne::Halted {
1452 reason: format!(
1453 "{e}. The editing call also changed tracked working-file bytes or modes: \
1454 {recovery}. The worktree was kept at {} for recovery.",
1455 dir.display()
1456 ),
1457 disposable_head: None,
1458 });
1459 }
1460 if let Err(ignored) = repo.refuse_new_ignored_files(dir, &worktree_baseline) {
1461 return Ok(BuildOne::Halted {
1462 reason: format!(
1463 "{e}. The editing call also left ignored work or its ignored files could \
1464 not be checked: {ignored}. The worktree was kept at {} for recovery.",
1465 dir.display()
1466 ),
1467 disposable_head: None,
1468 });
1469 }
1470 return failed_part_edit(repo, dir, &slice_head, e);
1471 }
1472 };
1473 if let Err(e) = repo.refuse_changed_attributes(dir, &worktree_baseline) {
1474 return Ok(BuildOne::Halted {
1475 reason: format!(
1476 "the stand-alone edit changed an attribute file: {e}. The worktree was kept at \
1477 {} for recovery.",
1478 dir.display()
1479 ),
1480 disposable_head: None,
1481 });
1482 }
1483 if work.not_worth_doing {
1484 if let Err(e) = repo.refuse_changed_existing_untracked(dir, &worktree_baseline) {
1485 return Ok(BuildOne::Halted {
1486 reason: format!(
1487 "part {index} was declined after changing existing untracked work: {e}. The \
1488 worktree was kept at {} for recovery.",
1489 dir.display()
1490 ),
1491 disposable_head: None,
1492 });
1493 }
1494 if let Err(e) = repo.refuse_unrepresented_tracked_changes(dir, &worktree_baseline) {
1495 return Ok(BuildOne::Halted {
1496 reason: format!(
1497 "part {index} was declined after changing tracked working-file bytes or modes: \
1498 {e}. The worktree was kept at {} for recovery.",
1499 dir.display()
1500 ),
1501 disposable_head: None,
1502 });
1503 }
1504 match work_since_slice(repo, dir, &slice_head) {
1505 Ok(true) => {
1506 return Ok(BuildOne::Halted {
1507 reason: format!(
1508 "part {index} was declined after its worktree changed. The worktree was \
1509 kept at {} for recovery.",
1510 dir.display()
1511 ),
1512 disposable_head: None,
1513 });
1514 }
1515 Err(e) => {
1516 return Ok(BuildOne::Halted {
1517 reason: format!(
1518 "part {index} was declined, but its worktree state could not be verified: \
1519 {e}. The worktree was kept at {} for recovery.",
1520 dir.display()
1521 ),
1522 disposable_head: None,
1523 });
1524 }
1525 Ok(false) => {
1526 if let Err(e) = repo.refuse_new_ignored_files(dir, &worktree_baseline) {
1527 return Ok(BuildOne::Halted {
1528 reason: format!(
1529 "part {index} created ignored work that could not be included: {e}. \
1530 The worktree was kept at {} for recovery.",
1531 dir.display()
1532 ),
1533 disposable_head: None,
1534 });
1535 }
1536 }
1537 }
1538 let reason = style::sentence(&work.reason, &repo.style);
1539 log!(
1540 " part {index} will not stand on its own, dropping it: {}",
1541 if reason.is_empty() {
1542 "no reason given"
1543 } else {
1544 &reason
1545 }
1546 );
1547 return Ok(BuildOne::Declined {
1548 disposable_head: slice_head,
1549 });
1550 }
1551
1552 let committed = repo
1553 .commit_pending_changes(
1554 dir,
1555 &worktree_baseline,
1556 &work.summary,
1557 &format!("Make part {index} of #{number} stand alone"),
1558 )
1559 .and_then(|committed| {
1560 repo.refuse_unrepresented_tracked_changes(dir, &worktree_baseline)?;
1561 Ok(committed)
1562 });
1563 if let Err(e) = committed {
1564 return failed_part_edit(repo, dir, &slice_head, e);
1565 }
1566 let stand_alone_work = match work_since_slice(repo, dir, &slice_head) {
1567 Ok(false) => {
1568 if let Err(e) = repo.refuse_new_ignored_files(dir, &worktree_baseline) {
1569 return Ok(BuildOne::Halted {
1570 reason: format!(
1571 "the stand-alone edit created ignored work that could not be included: \
1572 {e}. The worktree was kept at {} for recovery.",
1573 dir.display()
1574 ),
1575 disposable_head: None,
1576 });
1577 }
1578 false
1579 }
1580 Ok(true) => true,
1581 Err(e) => {
1582 return Ok(BuildOne::Halted {
1583 reason: format!(
1584 "the stand-alone worktree could not be verified after editing: {e}. It was \
1585 kept at {} for recovery.",
1586 dir.display()
1587 ),
1588 disposable_head: None,
1589 });
1590 }
1591 };
1592
1593 if let Err(e) = additive(branch, parent.head_branch, &repo.branch_prefix) {
1597 return failed_part_edit(repo, dir, &slice_head, e);
1598 }
1599 if let Err(e) = repo.rewrite_commits_if_needed(dir, against) {
1600 return failed_part_edit(repo, dir, &slice_head, e);
1601 }
1602 let disposable_head = if stand_alone_work {
1603 None
1604 } else if repo.head_oid_checked(dir)? == slice_head {
1605 Some(slice_head.clone())
1606 } else {
1607 None
1608 };
1609 if let Err(e) = ensure_parent_head(repo, number, parent.head_oid) {
1610 return Ok(BuildOne::parent_moved(&e, dir, disposable_head));
1611 }
1612 if let Err(e) = repo.push_split_branch(dir, branch) {
1613 return Ok(BuildOne::push_failed(branch, &e, disposable_head));
1614 }
1615
1616 if let Err(e) = ensure_parent_head(repo, number, parent.head_oid) {
1617 return Ok(BuildOne::Halted {
1618 reason: format!(
1619 "{e}. Branch `{branch}` was pushed and its worktree was kept. Compare it with \
1620 the new parent head. If it is still valid, open its pull request by hand and \
1621 record it on #{number}. To start over, remove every retained local worktree and \
1622 branch, child pull request, and remote split branch first."
1623 ),
1624 disposable_head: None,
1625 });
1626 }
1627
1628 let title = format!("{} (part {index} of #{number})", part.title.trim());
1629 let body = part_body(number, index, total, part, &work, &repo.style);
1630 let files = repo.changed_files(dir, against);
1634 match repo.create_pr(dir, branch, against, &title, &body) {
1635 Ok(pr) => Ok(BuildOne::Made(Built { pr, files })),
1636 Err(e) => Ok(BuildOne::Halted {
1637 reason: format!(
1638 "branch `{branch}` was pushed, but its pull request could not be opened: {e}. Its \
1639 worktree and branch record were kept. Compare it with the current parent. If it \
1640 is still valid, open `{branch}` against `{against}` by hand with title `{title}`, \
1641 then record the new pull request on #{number}. To start over, remove every \
1642 retained local worktree and branch, child pull request, and remote split branch \
1643 first."
1644 ),
1645 disposable_head: None,
1646 }),
1647 }
1648}
1649
1650fn failed_part_edit(
1651 repo: &Repo,
1652 dir: &Path,
1653 slice_head: &str,
1654 error: crate::error::SparError,
1655) -> Result<BuildOne> {
1656 if error.kind() == crate::error::ErrorKind::UncertainWrite {
1657 return Ok(BuildOne::Halted {
1658 reason: format!(
1659 "{error}. Git state could not be restored safely, so the worktree was kept at {} \
1660 for recovery.",
1661 dir.display()
1662 ),
1663 disposable_head: None,
1664 });
1665 }
1666 match work_since_slice(repo, dir, slice_head) {
1667 Ok(false) => Ok(BuildOne::Halted {
1668 reason: error.to_string(),
1669 disposable_head: Some(slice_head.to_string()),
1670 }),
1671 Ok(true) => Ok(BuildOne::Halted {
1672 reason: format!(
1673 "{error}. The editing worktree was kept at {} for recovery.",
1674 dir.display()
1675 ),
1676 disposable_head: None,
1677 }),
1678 Err(probe) => Ok(BuildOne::Halted {
1679 reason: format!(
1680 "{error}. The editing worktree could not be verified: {probe}. It was kept at {} \
1681 for recovery.",
1682 dir.display()
1683 ),
1684 disposable_head: None,
1685 }),
1686 }
1687}
1688
1689fn work_since_slice(repo: &Repo, dir: &Path, slice_head: &str) -> Result<bool> {
1690 if repo.has_uncommitted_changes(dir)? {
1691 return Ok(true);
1692 }
1693 Ok(repo.head_oid_checked(dir)? != slice_head)
1694}
1695
1696pub fn uncommitted(repo: &Repo, dir: &Path) -> bool {
1705 repo.has_uncommitted_changes(dir).unwrap_or(true)
1706}
1707
1708pub fn apply_slice(
1722 repo: &Repo,
1723 dir: &Path,
1724 base: &str,
1725 head_ref: &str,
1726 files: &[String],
1727) -> Result<bool> {
1728 let from = repo.merge_base(dir, base, head_ref)?;
1729 let patch = patch_path(dir);
1730 let written = write_patch(repo, dir, &from, head_ref, files, &patch);
1731 let outcome = written.and_then(|carries| {
1732 if !carries {
1733 return Ok(false);
1734 }
1735 repo.git_at(
1740 Some(dir),
1741 &["apply", "--index", "--3way", &patch.display().to_string()],
1742 )
1743 .map_err(|e| spar_err!("could not apply its files onto {base}: {}", e.last_line()))?;
1744 Ok(!repo
1745 .git_try_at(Some(dir), &["status", "--porcelain"])
1746 .trim()
1747 .is_empty())
1748 });
1749 let _ = std::fs::remove_file(&patch);
1750 outcome
1751}
1752
1753fn write_patch(
1756 repo: &Repo,
1757 dir: &Path,
1758 from: &str,
1759 head_ref: &str,
1760 files: &[String],
1761 patch: &Path,
1762) -> Result<bool> {
1763 let mut args: Vec<String> = vec![
1764 "diff".into(),
1765 "--binary".into(),
1770 "--no-renames".into(),
1771 format!("--output={}", patch.display()),
1774 from.to_string(),
1775 head_ref.to_string(),
1776 "--".into(),
1777 ];
1778 args.extend(files.iter().map(|path| format!(":(literal){path}")));
1782
1783 let argv: Vec<&str> = args.iter().map(String::as_str).collect();
1784 repo.git_at(Some(dir), &argv).map_err(|e| {
1785 spar_err!(
1786 "could not read its files out of the head: {}",
1787 e.last_line()
1788 )
1789 })?;
1790 Ok(std::fs::metadata(patch)
1791 .map(|m| m.len() > 0)
1792 .unwrap_or(false))
1793}
1794
1795fn patch_path(dir: &Path) -> std::path::PathBuf {
1800 let name = dir
1801 .file_name()
1802 .map(|n| n.to_string_lossy().into_owned())
1803 .unwrap_or_else(|| "part".to_string());
1804 let holder = dir.parent().unwrap_or_else(|| Path::new("."));
1805 holder.join(format!("{name}.patch"))
1806}
1807
1808#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1809enum PriorSplit {
1810 None,
1811 Recorded,
1812 RetainedBranches,
1813}
1814
1815fn prior_split(repo: &Repo, number: i64) -> Result<PriorSplit> {
1821 let comments = repo.try_issue_comments(number).map_err(|e| {
1822 spar_err!(
1823 "could not read the comments on #{number}, so whether it has already been split is \
1824 unknown: {}",
1825 e.last_line()
1826 )
1827 })?;
1828 if comments.iter().any(|c| {
1829 c.get("body")
1830 .and_then(serde_json::Value::as_str)
1831 .is_some_and(already_split)
1832 }) {
1833 return Ok(PriorSplit::Recorded);
1834 }
1835 let retained = repo.has_remote_split_branch(number).map_err(|e| {
1836 spar_err!(
1837 "could not check whether branches for #{number} already exist, so whether it has \
1838 already been split is unknown: {}",
1839 e.last_line()
1840 )
1841 })?;
1842 Ok(if retained {
1843 PriorSplit::RetainedBranches
1844 } else {
1845 PriorSplit::None
1846 })
1847}
1848
1849fn retained_branches_note(number: i64) -> String {
1850 format!(
1851 "remote split branches for PR #{number} show that an earlier split did not finish \
1852 cleanly. Inspect the retained branches and worktrees and compare each branch with the \
1853 current parent. Open any missing child pull request only when its branch is still valid, \
1854 then post the parts summary on #{number} by hand. To start over, remove every retained \
1855 local worktree and branch, child pull request, and remote split branch first. --again \
1856 starts a separate split and does not resume these branches."
1857 )
1858}
1859
1860fn propose_and_check(
1866 agents: &[Agent],
1867 cfg: &Config,
1868 work_dir: &Path,
1869 label: &str,
1870 propose_prompt: &str,
1871 what: &str,
1872) -> Result<Decision> {
1873 let proposer_name = cfg.first_implementor.clone();
1874 let proposer = agent::find(agents, &proposer_name)?;
1875 let checker_name = cfg.other(&proposer_name);
1876 let checker = agent::find(agents, &checker_name)?;
1877
1878 log!("{label}: {proposer_name} proposing a split");
1879 let proposal: SplitProposal = proposer.ask_json(
1880 propose_prompt,
1881 &schema::split_proposal(),
1882 work_dir,
1883 cfg.effort_for_round(&proposer.spec, 1).as_deref(),
1884 )?;
1885 if !proposal.should_split {
1886 return Ok(decide(
1887 &proposal,
1888 &SplitCheck::default(),
1889 cfg.loop_cfg.max_split_parts,
1890 ));
1891 }
1892
1893 log!(
1894 "{label}: {checker_name} checking {} proposed part(s)",
1895 proposal.parts.len()
1896 );
1897 let check: SplitCheck = checker.ask_json(
1898 &CHECK_PROMPT
1899 .replace("{what}", what)
1900 .replace("{reason}", proposal.reason.trim())
1901 .replace(
1902 "{shape}",
1903 if proposal.stacked {
1904 "stacked, each needing the one before it"
1905 } else {
1906 "independent of each other"
1907 },
1908 )
1909 .replace("{parts}", &render_parts(&proposal.parts)),
1910 &schema::split_check(),
1911 work_dir,
1912 cfg.effort_for_round(&checker.spec, 2).as_deref(),
1913 )?;
1914
1915 Ok(decide(&proposal, &check, cfg.loop_cfg.max_split_parts))
1916}
1917
1918fn render_parts(parts: &[SplitPart]) -> String {
1919 parts
1920 .iter()
1921 .enumerate()
1922 .map(|(i, p)| {
1923 let files = if p.files.is_empty() {
1924 String::new()
1925 } else {
1926 format!("\n files: {}", p.files.join(", "))
1927 };
1928 format!("{}. {}\n {}{files}", i + 1, p.title.trim(), p.body.trim())
1929 })
1930 .collect::<Vec<_>>()
1931 .join("\n\n")
1932}
1933
1934fn listed(paths: &[String]) -> String {
1939 paths
1940 .iter()
1941 .map(|p| format!("- {p}"))
1942 .collect::<Vec<_>>()
1943 .join("\n")
1944}
1945
1946fn bullets(lines: &[String]) -> String {
1947 lines
1948 .iter()
1949 .map(|line| format!("- {line}"))
1950 .collect::<Vec<_>>()
1951 .join("\n")
1952}
1953
1954fn part_body(
1960 parent: i64,
1961 index: usize,
1962 total: usize,
1963 part: &SplitPart,
1964 work: &Implementation,
1965 style: &Style,
1966) -> String {
1967 let mut out = vec![format!("Part {index} of {total}, split out of #{parent}.")];
1968 for lead in [&work.summary, &work.problem] {
1969 let text = style::sentence(lead, style);
1970 if !text.is_empty() {
1971 out.push(text);
1972 }
1973 }
1974 if out.len() == 1 {
1975 let text = style::sentence(&part.body, style);
1977 if !text.is_empty() {
1978 out.push(text);
1979 }
1980 }
1981 for (heading, lines) in [
1982 ("What changed", &work.changes),
1983 ("How to test", &work.testing),
1984 ] {
1985 let items: Vec<String> = lines
1986 .iter()
1987 .map(|line| style::summary(line, style))
1988 .filter(|line| !line.is_empty())
1989 .collect();
1990 if !items.is_empty() {
1991 out.push(format!("## {heading}\n\n{}", bullets(&items)));
1992 }
1993 }
1994 let notes = style::sentence(work.notes.as_deref().unwrap_or_default(), style);
1995 if !notes.is_empty() {
1996 out.push(format!("## Notes\n\n{notes}"));
1997 }
1998 style::body(&out.join("\n\n"), style)
1999}
2000
2001fn parts_comment(made: &[Made], left: &[String], style: &Style) -> String {
2012 let listed: Vec<String> = made
2013 .iter()
2014 .map(|m| format!("part {}: {} {}", m.index, m.url, m.title.trim()))
2015 .collect();
2016 let lead = if made.len() < 2 {
2017 "Only one part of this stood on its own, so it has not been split. That part was already \
2018 opened as its own pull request, and it carries files this one still carries:"
2019 .to_string()
2020 } else {
2021 format!("Split into {} pull request(s):", made.len())
2022 };
2023 let mut out = vec![SPLIT_MARKER.to_string(), lead, bullets(&listed)];
2024 if !left.is_empty() {
2025 out.push(format!(
2026 "{} file(s) are in no part, and are still only here:\n{}",
2027 left.len(),
2028 bullets(left)
2029 ));
2030 }
2031 out.push(
2032 "This pull request has not been changed. Its branch, its commits, and its own review are \
2033 untouched, and it is still open."
2034 .to_string(),
2035 );
2036 style::body(&out.join("\n\n"), style)
2037}
2038
2039fn proposal_comment(number: i64, decision: &Decision, left: &[String], style: &Style) -> String {
2041 let listed: Vec<String> = decision
2042 .parts
2043 .iter()
2044 .map(|p| {
2045 if p.files.is_empty() {
2046 p.title.trim().to_string()
2047 } else {
2048 format!("{} ({})", p.title.trim(), p.files.join(", "))
2049 }
2050 })
2051 .collect();
2052 let mut out = vec![
2053 SPLIT_MARKER.to_string(),
2054 format!(
2055 "#{number} comes from a fork, so this is a proposal rather than a change. Two agents \
2056 read it and agreed it would review better as {} pieces:",
2057 decision.parts.len()
2058 ),
2059 bullets(&listed),
2060 ];
2061 if decision.stacked {
2062 out.push("They have to land in that order.".to_string());
2063 }
2064 if !left.is_empty() {
2065 out.push(format!(
2066 "{} file(s) are in none of them:\n{}",
2067 left.len(),
2068 bullets(left)
2069 ));
2070 }
2071 out.push("Nothing has been changed here.".to_string());
2072 style::body(&out.join("\n\n"), style)
2073}
2074
2075fn print_proposal(number: i64, kind: &str, decision: &Decision, left: &[String]) {
2078 println!(
2079 "\n{kind} #{number} would be split into {} part(s):",
2080 decision.parts.len()
2081 );
2082 for (i, part) in decision.parts.iter().enumerate() {
2083 println!(" {}. {}", i + 1, style::clip(part.title.trim(), 90));
2084 if !part.files.is_empty() {
2085 println!(" {}", part.files.join(", "));
2086 }
2087 }
2088 if decision.stacked {
2089 println!(" each part is based on the one before it");
2090 }
2091 if !left.is_empty() {
2092 println!(" left over: {}", left.join(", "));
2093 }
2094 for note in &decision.dropped {
2095 println!(" dropped {note}");
2096 }
2097 println!("Nothing was written.");
2098}
2099
2100#[cfg(test)]
2101mod tests {
2102 use super::*;
2103
2104 fn part(title: &str, files: &[&str]) -> SplitPart {
2105 SplitPart {
2106 title: title.into(),
2107 body: format!("what {title} is"),
2108 files: files.iter().map(|f| f.to_string()).collect(),
2109 }
2110 }
2111
2112 fn proposal(parts: Vec<SplitPart>) -> SplitProposal {
2113 SplitProposal {
2114 should_split: true,
2115 reason: "three things".into(),
2116 stacked: false,
2117 parts,
2118 }
2119 }
2120
2121 fn accept() -> SplitCheck {
2122 SplitCheck {
2123 accept: true,
2124 stacked: false,
2125 strike: vec![],
2126 reasoning: "read it".into(),
2127 }
2128 }
2129
2130 #[test]
2134 fn the_cap_holds_and_says_what_it_held_back() {
2135 let parts = vec![
2136 part("one", &[]),
2137 part("two", &[]),
2138 part("three", &[]),
2139 part("four", &[]),
2140 ];
2141 let out = decide(&proposal(parts), &accept(), 2);
2142 assert_eq!(2, out.parts.len());
2143 assert!(out.splits());
2144 assert_eq!(2, out.dropped.len(), "{:?}", out.dropped);
2145 assert!(out.dropped[0].contains("three"), "{:?}", out.dropped);
2146 assert!(out.dropped[0].contains("cap"), "{:?}", out.dropped);
2147 }
2148
2149 #[test]
2152 fn a_proposal_that_declines_splits_nothing() {
2153 let mut p = proposal(vec![part("one", &[]), part("two", &[])]);
2154 p.should_split = false;
2155 p.reason = "it is one change".into();
2156 let out = decide(&p, &accept(), 4);
2157 assert!(!out.splits());
2158 assert_eq!(Some("it is one change".to_string()), out.declined);
2159 assert!(out.parts.is_empty());
2160 }
2161
2162 #[test]
2164 fn a_rejected_proposal_splits_nothing() {
2165 let check = SplitCheck {
2166 accept: false,
2167 reasoning: "these are the same change".into(),
2168 ..accept()
2169 };
2170 let out = decide(
2171 &proposal(vec![part("one", &[]), part("two", &[])]),
2172 &check,
2173 4,
2174 );
2175 assert!(!out.splits());
2176 assert!(out.declined.unwrap().contains("same change"));
2177 }
2178
2179 #[test]
2180 fn a_struck_part_is_not_made() {
2181 let parts = vec![part("one", &[]), part("two", &[]), part("three", &[])];
2182 let check = SplitCheck {
2183 strike: vec![2],
2184 ..accept()
2185 };
2186 let out = decide(&proposal(parts), &check, 4);
2187 assert_eq!(2, out.parts.len());
2188 assert_eq!(vec!["one", "three"], titles(&out));
2189 assert!(out.dropped[0].contains("two"), "{:?}", out.dropped);
2190 }
2191
2192 #[test]
2195 fn striking_all_but_one_part_leaves_it_whole() {
2196 let parts = vec![part("one", &[]), part("two", &[])];
2197 let check = SplitCheck {
2198 strike: vec![1],
2199 reasoning: "only one of these stands alone".into(),
2200 ..accept()
2201 };
2202 let out = decide(&proposal(parts), &check, 4);
2203 assert!(!out.splits());
2204 assert!(out.parts.is_empty());
2205 assert!(out.declined.unwrap().contains("stands alone"));
2206 }
2207
2208 #[test]
2213 fn either_agent_calling_it_stacked_makes_it_stacked() {
2214 let mut p = proposal(vec![part("one", &[]), part("two", &[])]);
2215 assert!(!decide(&p, &accept(), 4).stacked);
2216
2217 p.stacked = true;
2218 assert!(decide(&p, &accept(), 4).stacked);
2219
2220 p.stacked = false;
2221 let check = SplitCheck {
2222 stacked: true,
2223 ..accept()
2224 };
2225 assert!(decide(&p, &check, 4).stacked);
2226 }
2227
2228 fn titles(d: &Decision) -> Vec<String> {
2229 d.parts.iter().map(|p| p.title.clone()).collect()
2230 }
2231
2232 #[test]
2235 fn a_tracker_body_keeps_every_byte_of_the_original() {
2236 let original =
2237 "The retry loop spins.\n\n```rust\nfn go() {}\n```\n\n## Impact\n\nBad. \n\n";
2238 let out = tracker_body(original, &[("First".into(), 101), ("Second".into(), 102)]);
2239 assert!(out.starts_with(original), "{out}");
2240 assert_eq!(original.as_bytes(), &out.as_bytes()[..original.len()]);
2241 assert!(out.contains("- [ ] #101 First"), "{out}");
2242 assert!(out.contains("- [ ] #102 Second"), "{out}");
2243 }
2244
2245 #[test]
2246 fn a_parent_head_must_still_be_the_one_the_agents_read() {
2247 assert!(same_parent_head(34, "abc123", "abc123").is_ok());
2248 let error = same_parent_head(34, "abc123", "def456").unwrap_err();
2249 assert!(error.to_string().contains("unread head"), "{error}");
2250 }
2251
2252 #[test]
2253 fn filed_child_issues_require_manual_tracker_recovery() {
2254 let mut state = IssueRun::new(34, "split this");
2255 let parts = vec![("first".to_string(), 101), ("second".to_string(), 102)];
2256 let error = SparError::new("parent changed");
2257 record_issue_tracker_failure(&mut state, 34, &parts, &error);
2258
2259 assert_eq!(Status::Error, state.status);
2260 let note = state.notes.join("\n");
2261 assert!(note.contains("Do not rerun this split"), "{note}");
2262 assert!(note.contains("Add them to #34 by hand"), "{note}");
2263 assert!(note.contains("#101 first"), "{note}");
2264 assert!(note.contains("#102 second"), "{note}");
2265 }
2266
2267 #[test]
2268 fn an_uncertain_tracker_write_requires_inspection_before_editing() {
2269 let mut state = IssueRun::new(34, "split this");
2270 let parts = vec![("first".to_string(), 101), ("second".to_string(), 102)];
2271 let error = SparError::uncertain_write("the parent could not be reread");
2272 record_issue_tracker_failure(&mut state, 34, &parts, &error);
2273
2274 assert_eq!(Status::Error, state.status);
2275 let note = state.notes.join("\n");
2276 assert!(note.contains("write may already have landed"), "{note}");
2277 assert!(note.contains("current body of #34"), "{note}");
2278 assert!(note.contains(SPLIT_MARKER), "{note}");
2279 assert!(note.contains("do not add them again"), "{note}");
2280 assert!(
2281 note.contains("only the missing marker or child links"),
2282 "{note}"
2283 );
2284 assert!(!note.contains("Add them to #34 by hand"), "{note}");
2285 assert!(note.contains("#101 first"), "{note}");
2286 assert!(note.contains("#102 second"), "{note}");
2287 }
2288
2289 #[test]
2290 fn one_confirmed_child_is_an_error_until_it_is_recorded_or_closed() {
2291 let mut state = IssueRun::new(34, "split this");
2292 let parts = vec![("first".to_string(), 101)];
2293 record_partial_issue_split(&mut state, 34, &parts);
2294
2295 assert_eq!(Status::Error, state.status);
2296 let note = state.notes.join("\n");
2297 assert!(note.contains("Do not rerun this split"), "{note}");
2298 assert!(note.contains("Link it from #34 by hand"), "{note}");
2299 assert!(note.contains("close it first"), "{note}");
2300 assert!(note.contains("#101 first"), "{note}");
2301 }
2302
2303 #[test]
2304 fn an_uncertain_child_write_stops_before_rewriting_the_parent() {
2305 let mut state = IssueRun::new(34, "split this");
2306 let listed = vec![("first".to_string(), 101)];
2307 record_uncertain_issue_part(
2308 &mut state,
2309 34,
2310 "second",
2311 &listed,
2312 "the result could not be verified",
2313 );
2314
2315 assert_eq!(Status::Error, state.status);
2316 let note = state.notes.join("\n");
2317 assert!(note.contains("write landed is unknown"), "{note}");
2318 assert!(note.contains("#101 first"), "{note}");
2319 assert!(
2320 note.contains("complete the tracker on #34 by hand"),
2321 "{note}"
2322 );
2323 assert!(note.contains("Do not rerun this split"), "{note}");
2324 }
2325
2326 #[test]
2327 fn a_later_allocation_failure_preserves_partial_recovery_guidance() {
2328 let error = crate::error::SparError::new("no free branch name");
2329 let note = worktree_allocation_failure(34, 3, 2, &error);
2330 assert!(note.contains("2 child pull request"), "{note}");
2331 assert!(note.contains("worktrees were kept"), "{note}");
2332 assert!(note.contains("record the partial result on #34"), "{note}");
2333 assert!(note.contains("remove every retained"), "{note}");
2334 }
2335
2336 #[test]
2337 fn successful_stacked_worktrees_are_all_released_unless_kept() {
2338 let worktrees = vec![
2339 (PathBuf::from("one"), "split-34-1".to_string()),
2340 (PathBuf::from("two"), "split-34-2".to_string()),
2341 ];
2342 let mut released = Vec::new();
2343 release_part_worktrees_with(false, Status::Split, worktrees.clone(), |dir, branch| {
2344 released.push((dir.to_path_buf(), branch.to_string()))
2345 });
2346 assert_eq!(worktrees, released);
2347
2348 for (configured, status) in [(true, Status::Split), (false, Status::Error)] {
2349 let mut released = Vec::new();
2350 release_part_worktrees_with(configured, status, worktrees.clone(), |dir, branch| {
2351 released.push((dir.to_path_buf(), branch.to_string()))
2352 });
2353 assert!(released.is_empty(), "worktrees were not retained");
2354 }
2355 }
2356
2357 #[test]
2358 fn a_missing_parent_comment_is_an_error_with_recovery_text() {
2359 let mut state = IssueRun::new(34, "split this");
2360 let error = SparError::new("offline");
2361 record_parent_comment_failure(&mut state, 34, "the summary", &error);
2362
2363 assert_eq!(Status::Error, state.status);
2364 let note = state.notes.join("\n");
2365 assert!(note.contains("branches stop an automatic retry"), "{note}");
2366 assert!(note.contains("finish recording the split"), "{note}");
2367 assert!(note.contains("the summary"), "{note}");
2368 }
2369
2370 #[test]
2371 fn an_uncertain_parent_comment_requires_inspection_before_posting() {
2372 let mut state = IssueRun::new(34, "split this");
2373 let error = SparError::uncertain_write("the comments could not be reread");
2374 record_parent_comment_failure(&mut state, 34, "the summary", &error);
2375
2376 assert_eq!(Status::Error, state.status);
2377 let note = state.notes.join("\n");
2378 assert!(note.contains("comment may already have landed"), "{note}");
2379 assert!(
2380 note.contains("Inspect every top-level comment on #34"),
2381 "{note}"
2382 );
2383 assert!(note.contains("do not post it again"), "{note}");
2384 assert!(note.contains("If it is absent, post it once"), "{note}");
2385 assert!(!note.contains("Post this comment by hand"), "{note}");
2386 assert!(note.contains("the summary"), "{note}");
2387 }
2388
2389 #[test]
2390 fn retained_branches_explain_manual_recovery_and_again() {
2391 let note = retained_branches_note(34);
2392 assert!(
2393 note.contains("Open any missing child pull request"),
2394 "{note}"
2395 );
2396 assert!(note.contains("current parent"), "{note}");
2397 assert!(note.contains("local worktree and branch"), "{note}");
2398 assert!(note.contains("remove every retained"), "{note}");
2399 assert!(note.contains("does not resume"), "{note}");
2400 }
2401
2402 #[test]
2403 fn a_push_collision_halts_instead_of_dropping_one_part() {
2404 let error = SplitPushError::new("the create-only lease was rejected", false);
2405 match BuildOne::push_failed("split-34-1", &error, Some("slice".into())) {
2406 BuildOne::Halted {
2407 reason,
2408 disposable_head,
2409 } => {
2410 assert_eq!(Some("slice".to_string()), disposable_head);
2411 assert!(reason.contains("stopped"), "{reason}");
2412 assert!(reason.contains("competing pull requests"), "{reason}");
2413 }
2414 _ => panic!("a push collision did not halt the split"),
2415 }
2416 }
2417
2418 #[test]
2419 fn an_unverified_push_halts_and_keeps_the_worktree() {
2420 let error = SplitPushError::new("origin could not be read", true);
2421 match BuildOne::push_failed("split-34-1", &error, Some("slice".into())) {
2422 BuildOne::Halted {
2423 reason,
2424 disposable_head,
2425 } => {
2426 assert!(disposable_head.is_none());
2427 assert!(reason.contains("may now exist on origin"), "{reason}");
2428 assert!(
2429 reason.contains("worktree and branch record were kept"),
2430 "{reason}"
2431 );
2432 }
2433 _ => panic!("an unverified push did not halt the split"),
2434 }
2435 }
2436
2437 #[test]
2438 fn a_definite_push_refusal_keeps_stand_alone_edit_work() {
2439 let error = SplitPushError::new("origin is known not to contain the branch", false);
2440 match BuildOne::push_failed("split-34-1", &error, None) {
2441 BuildOne::Halted {
2442 reason,
2443 disposable_head,
2444 } => {
2445 assert!(disposable_head.is_none());
2446 assert!(
2447 reason.contains("not confirmed to match its mechanical slice"),
2448 "{reason}"
2449 );
2450 assert!(reason.contains("kept for recovery"), "{reason}");
2451 }
2452 _ => panic!("a local edit was dropped after the push refusal"),
2453 }
2454 }
2455
2456 #[test]
2457 fn a_parent_move_keeps_stand_alone_edit_work() {
2458 let error = SparError::new("the parent head moved before the push");
2459 match BuildOne::parent_moved(&error, Path::new("/tmp/split-part"), None) {
2460 BuildOne::Halted {
2461 reason,
2462 disposable_head,
2463 } => {
2464 assert!(disposable_head.is_none());
2465 assert!(reason.contains("parent head moved"), "{reason}");
2466 assert!(reason.contains("/tmp/split-part"), "{reason}");
2467 assert!(reason.contains("kept"), "{reason}");
2468 }
2469 _ => panic!("a local edit was dropped after the parent moved"),
2470 }
2471 }
2472
2473 #[test]
2476 fn a_body_this_wrote_reads_back_as_already_split() {
2477 assert!(!already_split("just an issue"));
2478 let out = tracker_body("just an issue", &[("a".into(), 1), ("b".into(), 2)]);
2479 assert!(already_split(&out), "{out}");
2480 }
2481
2482 #[test]
2486 fn a_fence_left_open_is_closed_before_the_checklist() {
2487 let out = tracker_body("Here:\n\n```rust\nfn unfinished() {}", &[("a".into(), 1)]);
2488 assert!(out.contains("fn unfinished"), "{out}");
2489 let after = out.split("fn unfinished() {}").nth(1).unwrap();
2490 assert!(after.trim_start().starts_with("```"), "{out}");
2491 assert!(already_split(&out), "{out}");
2492 }
2493
2494 #[test]
2498 fn an_open_fence_is_closed_by_one_that_actually_closes_it() {
2499 for original in [
2500 "Here:\n\n~~~\nfn unfinished() {}",
2502 "Here:\n\n````\n```\nfn unfinished() {}",
2504 ] {
2505 let out = tracker_body(original, &[("a".into(), 1)]);
2506 let marker = out.split(SPLIT_MARKER).next().unwrap();
2507 assert!(
2508 unclosed_fence(marker).is_none(),
2509 "the checklist is inside a code block: {out}"
2510 );
2511 assert!(already_split(&out), "{out}");
2512 }
2513 }
2514
2515 #[test]
2519 fn a_closed_fence_is_left_alone() {
2520 for original in [
2521 "Here:\n\n```rust\nfn done() {}\n```",
2522 "Here:\n\n```\n~~~\n```",
2525 "Here:\n\n````\n```\n````",
2527 "no fences at all",
2528 ] {
2529 let out = tracker_body(original, &[("a".into(), 1)]);
2530 assert!(
2531 out.starts_with(&format!("{original}\n\n{SPLIT_MARKER}")),
2532 "{out}"
2533 );
2534 }
2535 }
2536
2537 #[test]
2540 fn a_tracker_body_from_nothing_is_still_a_tracker() {
2541 let out = tracker_body("", &[("a".into(), 1)]);
2542 assert!(already_split(&out), "{out}");
2543 assert!(out.starts_with(SPLIT_MARKER), "{out}");
2544 }
2545
2546 #[test]
2550 fn only_a_branch_the_split_made_may_be_pushed_to() {
2551 assert!(additive("split-12-1", "pr-12", "").is_ok());
2552 assert!(additive("spar/split-12-1", "spar/pr-12", "spar/").is_ok());
2553
2554 for branch in ["main", "pr-12", "issue-12", "their-feature", "split-12-1"] {
2555 assert!(
2556 additive(branch, "main", "spar/").is_err(),
2557 "{branch} was allowed outside the split namespace"
2558 );
2559 }
2560 assert!(additive("split-12-1", "split-12-1", "").is_err());
2562 }
2563
2564 fn carried(parts: &[SplitPart]) -> Vec<&[String]> {
2565 parts.iter().map(|p| p.files.as_slice()).collect()
2566 }
2567
2568 #[test]
2569 fn what_no_part_carries_is_reported_as_left_over() {
2570 let changed = vec!["a.rs".to_string(), "b.rs".into(), "c.rs".into()];
2571 let parts = vec![part("one", &["a.rs"]), part("two", &["c.rs"])];
2572 assert_eq!(
2573 vec!["b.rs".to_string()],
2574 leftover(&changed, carried(&parts))
2575 );
2576 let all = [part("all", &["a.rs", "b.rs", "c.rs"])];
2577 assert!(leftover(&changed, carried(&all)).is_empty());
2578 }
2579
2580 #[test]
2584 fn a_dropped_part_leaves_its_files_in_the_leftover_report() {
2585 let changed = vec!["a.rs".to_string(), "b.rs".into()];
2586 let made = vec![Made {
2587 index: 1,
2588 title: "one".into(),
2589 url: "https://example.invalid/pull/1".into(),
2590 files: vec!["a.rs".to_string()],
2591 }];
2592 let left = leftover(&changed, made.iter().map(|m| m.files.as_slice()));
2593 assert_eq!(vec!["b.rs".to_string()], left);
2594 assert!(
2595 parts_comment(&made, &left, &Style::default()).contains("b.rs"),
2596 "the file of the dropped part went unsaid"
2597 );
2598 }
2599
2600 #[test]
2603 fn a_part_may_only_carry_paths_the_change_actually_touches() {
2604 let changed = vec!["a.rs".to_string(), "b.rs".into()];
2605 let mut parts = vec![part("one", &["a.rs", "../../etc/passwd", "invented.rs"])];
2606 let unknown = confine(&mut parts, &changed);
2607 assert_eq!(vec!["a.rs".to_string()], parts[0].files);
2608 assert_eq!(2, unknown.len(), "{unknown:?}");
2609 }
2610
2611 #[test]
2614 fn a_path_claimed_twice_stays_with_the_first_part() {
2615 let changed = vec!["a.rs".to_string(), "b.rs".into()];
2616 let mut parts = vec![part("one", &["a.rs", "b.rs"]), part("two", &["b.rs"])];
2617 confine(&mut parts, &changed);
2618 assert_eq!(vec!["a.rs".to_string(), "b.rs".to_string()], parts[0].files);
2619 assert!(parts[1].files.is_empty(), "{:?}", parts[1].files);
2620 }
2621
2622 #[test]
2626 fn a_path_two_parts_ended_up_carrying_is_reported() {
2627 let made = vec![Made {
2628 index: 1,
2629 title: "one".into(),
2630 url: "https://example.invalid/pull/1".into(),
2631 files: vec!["a.rs".to_string(), "lib.rs".into()],
2632 }];
2633 let second = vec!["b.rs".to_string(), "lib.rs".into()];
2634 assert_eq!(
2635 vec!["lib.rs".to_string()],
2636 overlapping(&made, &second, false)
2637 );
2638 assert!(overlapping(&made, &second, true).is_empty());
2641 }
2642
2643 #[test]
2647 fn a_comment_this_wrote_reads_back_as_already_split() {
2648 let style = Style::default();
2649 let made = vec![Made {
2650 index: 1,
2651 title: "First".into(),
2652 url: "https://example.invalid/pull/201".into(),
2653 files: vec!["first.rs".to_string()],
2654 }];
2655 let comment = parts_comment(&made, &["left.rs".to_string()], &style);
2656 assert!(already_split(&comment), "{comment}");
2657 assert!(comment.contains("/pull/201"), "{comment}");
2658 assert!(comment.contains("left.rs"), "{comment}");
2659 assert!(comment.contains("has not been changed"), "{comment}");
2660
2661 let decision = decide(
2662 &proposal(vec![part("one", &["a.rs"]), part("two", &["b.rs"])]),
2663 &accept(),
2664 4,
2665 );
2666 let proposed = proposal_comment(12, &decision, &[], &style);
2667 assert!(already_split(&proposed), "{proposed}");
2668 assert!(proposed.contains("Nothing has been changed"), "{proposed}");
2669 }
2670
2671 #[test]
2675 fn one_surviving_part_is_not_announced_as_a_split() {
2676 let made = vec![Made {
2677 index: 1,
2678 title: "First".into(),
2679 url: "https://example.invalid/pull/201".into(),
2680 files: vec!["first.rs".to_string()],
2681 }];
2682 let comment = parts_comment(&made, &[], &Style::default());
2683 assert!(!comment.contains("Split into"), "{comment}");
2684 assert!(comment.contains("has not been split"), "{comment}");
2685 assert!(comment.contains("/pull/201"), "{comment}");
2686 assert!(already_split(&comment), "{comment}");
2688 }
2689
2690 #[test]
2693 fn a_part_with_no_title_is_dropped_rather_than_filed() {
2694 let parts = vec![part("one", &[]), part("", &[]), part("three", &[])];
2695 let out = decide(&proposal(parts), &accept(), 4);
2696 assert_eq!(vec!["one", "three"], titles(&out));
2697 assert!(out.dropped[0].contains("no title"), "{:?}", out.dropped);
2698 }
2699}