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