1use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6use std::sync::OnceLock;
7
8use serde::Deserialize;
9use serde_json::Value;
10
11use crate::config::{Config, Drafts, Followups, StateStore};
12use crate::error::Result;
13use crate::model::{Followup, Issue, IssueRef, ItemKind, PersistedState, PrRef, PrView};
14use crate::proc::{self, ExecOpts};
15use crate::style::{self, Style};
16use crate::textsim;
17use crate::{bail, logdim, spar_err};
18
19pub const FETCH_CEILING: usize = 500;
23
24pub const STATE_MARKER: &str = "<!-- spar:state";
27
28pub const FOLLOWUP_MARKER: &str = "<!-- spar:followup -->";
36
37const WORKTREE_DIR: &str = ".spar-worktrees";
38const STATE_DIR: &str = ".spar";
39
40#[derive(Debug)]
41pub struct Repo {
42 root: PathBuf,
43 pub style: Style,
44 pub branch_prefix: String,
45 pub state_store: StateStore,
46 pub followups: Followups,
47 pub drafts: Drafts,
48 viewer: OnceLock<String>,
54}
55
56impl Repo {
57 pub fn open(root: impl AsRef<Path>, cfg: &Config) -> Result<Self> {
58 let root =
59 std::fs::canonicalize(root.as_ref()).unwrap_or_else(|_| root.as_ref().to_path_buf());
60 let inside = proc::run_str(
63 &["git", "rev-parse", "--is-inside-work-tree"],
64 &ExecOpts::new().cwd(&root).check(false).timeout_secs(30),
65 )
66 .unwrap_or_default();
67 if inside.trim() != "true" {
68 bail!("not a git repository: {}", root.display());
69 }
70 let repo = Self {
71 root,
72 style: cfg.style.clone(),
73 branch_prefix: cfg.loop_cfg.branch_prefix.clone(),
74 state_store: cfg.loop_cfg.state_store,
75 followups: cfg.loop_cfg.followups,
76 drafts: cfg.loop_cfg.drafts,
77 viewer: OnceLock::new(),
78 };
79 repo.self_exclude();
80 Ok(repo)
81 }
82
83 fn self_exclude(&self) {
91 let git_dir = self.git_try(&["rev-parse", "--path-format=absolute", "--git-common-dir"]);
92 let git_dir = git_dir.trim();
93 if git_dir.is_empty() {
94 return;
95 }
96 let path = Path::new(git_dir).join("info").join("exclude");
97 let existing = std::fs::read_to_string(&path).unwrap_or_default();
98
99 let wanted = [format!("/{WORKTREE_DIR}/"), format!("/{STATE_DIR}/")];
100 let missing: Vec<&String> = wanted
101 .iter()
102 .filter(|line| !existing.lines().any(|l| l.trim() == line.as_str()))
103 .collect();
104 if missing.is_empty() {
105 return;
106 }
107
108 use std::io::Write;
109 if let Some(parent) = path.parent() {
110 let _ = std::fs::create_dir_all(parent);
111 }
112 let mut block = String::new();
113 if !existing.is_empty() && !existing.ends_with('\n') {
114 block.push('\n');
115 }
116 block.push_str("\n# added by spar: its worktrees and run state\n");
117 for line in missing {
118 block.push_str(line);
119 block.push('\n');
120 }
121 if let Ok(mut file) = std::fs::OpenOptions::new()
122 .create(true)
123 .append(true)
124 .open(&path)
125 {
126 let _ = file.write_all(block.as_bytes());
127 }
128 }
129
130 pub fn root(&self) -> &Path {
131 &self.root
132 }
133
134 pub fn clean(&self, text: &str) -> Result<String> {
140 let out = style::scrub(text, &self.style);
141 let bad = style::violations(&out, &self.style);
142 if !bad.is_empty() {
143 bail!(
144 "style gate could not clean text ({}): {}",
145 bad.join(", "),
146 style::clip(&out, 300)
147 );
148 }
149 Ok(out)
150 }
151
152 pub fn clean_body(&self, text: &str) -> Result<String> {
154 self.clean(&style::body(text, &self.style))
155 }
156
157 pub fn clean_issue_body(&self, text: &str) -> Result<String> {
159 self.clean(&style::issue_body(text, &self.style))
160 }
161
162 pub fn clean_title(&self, text: &str) -> Result<String> {
173 Ok(style::title(&self.clean(text)?, &self.style))
174 }
175
176 fn git_opts(&self, cwd: Option<&Path>, check: bool) -> ExecOpts {
179 ExecOpts::new()
180 .cwd(cwd.unwrap_or(&self.root))
181 .check(check)
182 .timeout_secs(600)
183 }
184
185 pub fn git(&self, args: &[&str]) -> Result<String> {
186 self.git_at(None, args)
187 }
188
189 pub fn git_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
190 let mut argv = vec!["git".to_string()];
191 argv.extend(args.iter().map(|s| s.to_string()));
192 proc::run(&argv, &self.git_opts(cwd, true))
193 }
194
195 pub fn git_try(&self, args: &[&str]) -> String {
197 self.git_try_at(None, args)
198 }
199
200 pub fn git_try_at(&self, cwd: Option<&Path>, args: &[&str]) -> String {
201 let mut argv = vec!["git".to_string()];
202 argv.extend(args.iter().map(|s| s.to_string()));
203 proc::run(&argv, &self.git_opts(cwd, false)).unwrap_or_default()
204 }
205
206 pub fn default_branch(&self, configured: &str) -> String {
209 let refname = self.git_try(&["symbolic-ref", "refs/remotes/origin/HEAD"]);
210 match refname.trim().rsplit('/').next() {
211 Some(name) if !name.is_empty() => name.to_string(),
212 _ => configured.to_string(),
213 }
214 }
215
216 pub fn branch_for_issue(&self, issue: i64) -> String {
224 format!("{}issue-{issue}", self.branch_prefix)
225 }
226
227 pub fn branch_for_pr(&self, number: i64) -> String {
228 format!("{}pr-{number}", self.branch_prefix)
229 }
230
231 fn ledger_path(&self) -> PathBuf {
232 self.root.join(STATE_DIR).join("branches.json")
233 }
234
235 pub fn known_branches(&self) -> BTreeMap<String, BranchRecord> {
236 std::fs::read_to_string(self.ledger_path())
237 .ok()
238 .and_then(|text| serde_json::from_str(&text).ok())
239 .unwrap_or_default()
240 }
241
242 pub fn record_branch(&self, branch: &str, kind: &str, number: i64) {
243 let mut data = self.known_branches();
244 data.insert(
245 branch.to_string(),
246 BranchRecord {
247 kind: kind.to_string(),
248 number,
249 },
250 );
251 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
252 logdim!("could not record branch {branch}: {e}");
253 }
254 }
255
256 pub fn forget_branch(&self, branch: &str) {
257 let mut data = self.known_branches();
258 if data.remove(branch).is_none() {
259 return;
260 }
261 if let Err(e) = write_json_atomic(&self.ledger_path(), &data) {
262 logdim!("could not update the branch record: {e}");
263 }
264 }
265
266 fn worktree_path(&self, name: &str) -> PathBuf {
269 self.root.join(WORKTREE_DIR).join(name)
270 }
271
272 pub fn worktree_add(&self, issue: i64, base: &str) -> Result<(PathBuf, String)> {
274 let branch = self.branch_for_issue(issue);
275 let path = self.worktree_path(&format!("issue-{issue}"));
276
277 self.git_try(&["fetch", "origin", base]);
278
279 self.git_try(&["fetch", "origin", &branch]);
288 let remote_branch = format!("origin/{branch}");
289 if self.rev_exists(&self.root, &remote_branch) {
290 let ahead = self.commit_count(&self.root, &remote_branch, base);
291 if ahead > 0 {
292 bail!(
293 "origin/{branch} already has {ahead} commit(s) that are not on {base}, and no \
294 open pull request accounts for them. Rebuilding it would force push over \
295 that work.\nOpen a pull request for the branch and run `spar resume <pr>` to \
296 continue it, or delete it with `git push origin --delete {branch}` if it is \
297 stale."
298 );
299 }
300 }
301
302 if self.rev_exists(&self.root, &branch) {
311 let ahead = self.commit_count(&self.root, &branch, base);
312 if ahead > 0 && !self.pull_request_holds(&branch, base) {
313 let listed = self
314 .commit_lines(&self.root, &branch, base)
315 .iter()
316 .map(|line| format!(" {line}"))
317 .collect::<Vec<_>>()
318 .join("\n");
319 bail!(
320 "the local branch {branch} has {ahead} commit(s) that are not on {base}, and \
321 nothing accounts for them: no branch on origin and no pull request that \
322 holds them. Rebuilding it would delete the only copy.\n{listed}\nPush it \
323 and run `spar \
324 resume <pr>` on the pull request to continue it, or delete it with `git \
325 branch -D {branch}` if it is stale."
326 );
327 }
328 }
329
330 self.worktree_remove(issue);
331 self.git_try(&["branch", "-D", &branch]);
332
333 if let Some(parent) = path.parent() {
334 std::fs::create_dir_all(parent)
335 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
336 }
337
338 let path_str = path.display().to_string();
339 let remote_start = format!("origin/{base}");
340 let created = self
341 .git(&["worktree", "add", "-b", &branch, &path_str, &remote_start])
342 .or_else(|_| self.git(&["worktree", "add", "-b", &branch, &path_str, base]));
343
344 created.map_err(|e| {
347 spar_err!(
348 "could not create a worktree for issue #{issue}. {}\nIs `{base}` a real branch, \
349 and does `origin` exist?",
350 e.last_line()
351 )
352 })?;
353 self.record_branch(&branch, "issue", issue);
354 Ok((path, branch))
355 }
356
357 fn pull_request_holds(&self, branch: &str, base: &str) -> bool {
366 self.prs_for_branch(branch)
367 .iter()
368 .any(|pr| self.pr_head_holds(pr.number, branch, base))
369 }
370
371 fn pr_head_holds(&self, number: i64, branch: &str, base: &str) -> bool {
372 let head = format!("refs/spar/pr-head/{number}");
373 let refspec = format!("+refs/pull/{number}/head:{head}");
374 if self.git(&["fetch", "origin", &refspec]).is_err() {
375 return false;
376 }
377 let held = self.commits_held_by(branch, base, &head);
378 self.git_try(&["update-ref", "-d", &head]);
379 held
380 }
381
382 pub fn commits_held_by(&self, branch: &str, base: &str, other: &str) -> bool {
386 let range = format!("{}..{branch}", self.base_ref(&self.root, base));
387 self.git_try(&["rev-list", "--count", &range, "--not", other])
388 .trim()
389 == "0"
390 }
391
392 pub fn worktree_remove(&self, issue: i64) {
393 self.remove_worktree_at(&self.worktree_path(&format!("issue-{issue}")));
394 }
395
396 fn remove_worktree_at(&self, path: &Path) {
397 let path_str = path.display().to_string();
398 self.git_try(&["worktree", "remove", "--force", &path_str]);
399 if path.is_dir() {
400 let _ = std::fs::remove_dir_all(path);
401 }
402 self.git_try(&["worktree", "prune"]);
403 }
404
405 pub fn worktree_for_pr(&self, pr: &PrView) -> Result<(PathBuf, String)> {
407 let head = pr.head_ref_name.clone();
408 if head.trim().is_empty() {
409 bail!("PR #{} has no head branch to check out", pr.number);
410 }
411 let path = self.worktree_path(&format!("pr-{}", pr.number));
412 let local = self.branch_for_pr(pr.number);
413
414 self.git(&["fetch", "origin", &head]).map_err(|e| {
415 spar_err!(
416 "could not fetch the branch behind PR #{}: {}",
417 pr.number,
418 e.last_line()
419 )
420 })?;
421 self.remove_worktree_at(&path);
422 self.git_try(&["branch", "-D", &local]);
423
424 let path_str = path.display().to_string();
425 let start = format!("origin/{head}");
426 self.git(&["worktree", "add", "-B", &local, &path_str, &start])?;
427 self.record_branch(&local, "pr", pr.number);
428 Ok((path, head))
429 }
430
431 pub fn worktree_for_pr_head(&self, number: i64) -> Result<PathBuf> {
441 let path = self.worktree_path(&format!("review-{number}"));
442 let local_ref = review_ref(number);
443 let refspec = format!("+refs/pull/{number}/head:{local_ref}");
444
445 self.git(&["fetch", "origin", &refspec]).map_err(|e| {
446 spar_err!(
447 "could not fetch the head of PR #{number}. {}\nGitHub serves refs/pull/N/head for \
448 every pull request, so this usually means the number is wrong or `origin` does \
449 not point at the repository the PR is on.",
450 e.last_line()
451 )
452 })?;
453
454 if let Some(parent) = path.parent() {
455 std::fs::create_dir_all(parent)
456 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
457 }
458 self.remove_worktree_at(&path);
459 let path_str = path.display().to_string();
460 self.git(&["worktree", "add", "--detach", &path_str, &local_ref])?;
461 Ok(path)
462 }
463
464 pub fn release_review_worktree(&self, number: i64) {
465 self.remove_worktree_at(&self.worktree_path(&format!("review-{number}")));
466 self.git_try(&["update-ref", "-d", &review_ref(number)]);
467 }
468
469 pub fn release_pr_worktree(&self, number: i64) {
470 let path = self.worktree_path(&format!("pr-{number}"));
471 self.remove_worktree_at(&path);
472 let local = self.branch_for_pr(number);
473 self.git_try(&["branch", "-D", &local]);
474 self.forget_branch(&local);
475 }
476
477 pub fn base_ref(&self, cwd: &Path, base: &str) -> String {
488 let remote = format!("origin/{base}");
489 if self.rev_exists(cwd, &remote) {
490 return remote;
491 }
492 if self.rev_exists(cwd, base) {
493 logdim!("origin/{base} does not resolve, comparing against local {base}");
494 return base.to_string();
495 }
496 logdim!("neither origin/{base} nor {base} resolves; results will be unreliable");
497 remote
498 }
499
500 fn rev_exists(&self, cwd: &Path, refname: &str) -> bool {
501 let spec = format!("{refname}^{{commit}}");
502 !self
503 .git_try_at(Some(cwd), &["rev-parse", "--verify", "--quiet", &spec])
504 .trim()
505 .is_empty()
506 }
507
508 pub fn has_changes(&self, cwd: &Path, base: &str) -> bool {
509 let range = format!("{}..HEAD", self.base_ref(cwd, base));
510 !self
511 .git_try_at(Some(cwd), &["log", &range, "--oneline"])
512 .trim()
513 .is_empty()
514 }
515
516 pub fn commit_count(&self, cwd: &Path, refname: &str, base: &str) -> usize {
523 let range = format!("{}..{refname}", self.base_ref(cwd, base));
524 self.git_try_at(Some(cwd), &["rev-list", "--count", &range])
525 .trim()
526 .parse()
527 .unwrap_or(0)
528 }
529
530 pub fn commit_lines(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
534 let range = format!("{}..{refname}", self.base_ref(cwd, base));
535 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%h %s"])
536 .lines()
537 .map(str::to_string)
538 .collect()
539 }
540
541 pub fn commit_subjects(&self, cwd: &Path, refname: &str, base: &str) -> Vec<String> {
544 let range = format!("{}..{refname}", self.base_ref(cwd, base));
545 self.git_try_at(Some(cwd), &["log", &range, "--reverse", "--format=%s"])
546 .lines()
547 .map(str::trim)
548 .filter(|line| !line.is_empty())
549 .map(str::to_string)
550 .collect()
551 }
552
553 pub fn diff_stat(&self, cwd: &Path, base: &str) -> String {
554 let range = format!("{}...HEAD", self.base_ref(cwd, base));
555 let full = self.git_try_at(Some(cwd), &["diff", &range, "--shortstat"]);
556 full.trim().to_string()
557 }
558
559 pub fn rewrite_commits_if_needed(&self, cwd: &Path, base: &str) -> Result<()> {
564 let range = format!("{}..HEAD", self.base_ref(cwd, base));
565 let raw = self.git_try_at(Some(cwd), &["log", &range, "--format=%H%x00%B%x1e"]);
566
567 let offenders = raw
568 .split('\x1e')
569 .filter_map(|entry| entry.split_once('\0'))
570 .filter(|(_, body)| !style::violations(body, &self.style).is_empty())
571 .count();
572 if offenders == 0 {
573 return Ok(());
574 }
575 logdim!("{offenders} commit message(s) violated style rules, rewriting");
576
577 let exe = self_binary()?;
578 let filter = format!("{} scrub-filter", sh_quote(&exe.display().to_string()));
579
580 let argv: Vec<String> = [
581 "git",
582 "filter-branch",
583 "-f",
584 "--msg-filter",
585 &filter,
586 &range,
587 ]
588 .iter()
589 .map(|s| s.to_string())
590 .collect();
591 let opts = ExecOpts::new()
592 .cwd(cwd)
593 .check(false)
594 .timeout_secs(600)
595 .env("FILTER_BRANCH_SQUELCH_WARNING", "1")
596 .env("SPAR_BAN_EM_DASH", bool_env(self.style.ban_em_dash))
597 .env(
598 "SPAR_BAN_AI_ATTRIBUTION",
599 bool_env(self.style.ban_ai_attribution),
600 );
601 let _ = proc::run(&argv, &opts);
602
603 let after = self.git_try_at(Some(cwd), &["log", &range, "--format=%B"]);
604 if !style::violations(&after, &self.style).is_empty() {
605 bail!(
606 "commit messages still violate style rules after a rewrite. Fix them by hand in \
607 {} and rerun.",
608 cwd.display()
609 );
610 }
611 Ok(())
612 }
613
614 pub fn push(&self, cwd: &Path, branch: &str) -> Result<()> {
620 let refspec = format!("HEAD:{branch}");
621 self.git_at(
622 Some(cwd),
623 &["push", "--force-with-lease", "origin", &refspec],
624 )
625 .map(|_| ())
626 .map_err(|e| {
627 spar_err!(
628 "could not push to origin/{branch}. {}\nCheck push access and whether the \
629 branch moved under you.",
630 e.last_line()
631 )
632 })
633 }
634
635 pub fn gh(&self, args: &[&str]) -> Result<String> {
638 self.gh_at(None, args)
639 }
640
641 pub fn gh_at(&self, cwd: Option<&Path>, args: &[&str]) -> Result<String> {
642 let mut argv = vec!["gh".to_string()];
643 argv.extend(args.iter().map(|s| s.to_string()));
644 proc::run(
645 &argv,
646 &ExecOpts::new()
647 .cwd(cwd.unwrap_or(&self.root))
648 .timeout_secs(300),
649 )
650 }
651
652 pub fn gh_try(&self, args: &[&str]) -> String {
653 let mut argv = vec!["gh".to_string()];
654 argv.extend(args.iter().map(|s| s.to_string()));
655 proc::run(
656 &argv,
657 &ExecOpts::new()
658 .cwd(&self.root)
659 .check(false)
660 .timeout_secs(300),
661 )
662 .unwrap_or_default()
663 }
664
665 pub fn viewer_login(&self) -> Result<&str> {
676 if let Some(login) = self.viewer.get() {
677 return Ok(login);
678 }
679 let rest = self.gh_try(&["api", "user", "--jq", ".login"]);
680 let login = if !rest.trim().is_empty() {
681 rest.trim().to_string()
682 } else {
683 self.gh(&[
686 "api",
687 "graphql",
688 "-f",
689 "query={ viewer { login } }",
690 "--jq",
691 ".data.viewer.login",
692 ])
693 .map_err(|e| {
694 spar_err!(
695 "could not find out who `gh` is authenticated as, so spar cannot tell its \
696 own comments from anybody else's. {}\nRun `gh auth status`.",
697 e.last_line()
698 )
699 })?
700 .trim()
701 .to_string()
702 };
703 if login.is_empty() {
704 bail!("`gh` reported an empty login. Run `gh auth status`.");
705 }
706 Ok(self.viewer.get_or_init(|| login))
707 }
708
709 pub fn fetch_issues(&self, numbers: &[i64]) -> Result<Vec<Issue>> {
710 let mut issues = Vec::new();
711 for number in numbers {
712 let text = self
713 .gh(&[
714 "issue",
715 "view",
716 &number.to_string(),
717 "--json",
718 "number,title,body,labels,state,url",
719 ])
720 .map_err(|e| spar_err!("could not read issue #{number}: {}", e.last_line()))?;
721 let issue: Issue = serde_json::from_str(&text)
722 .map_err(|e| spar_err!("unexpected shape for issue #{number}: {e}"))?;
723 if issue.is_closed() {
724 crate::log!("issue #{number} is closed, skipping");
725 continue;
726 }
727 issues.push(issue);
728 }
729 if issues.is_empty() {
730 bail!("no open issues to work on");
731 }
732 Ok(issues)
733 }
734
735 fn open_numbers(&self, kind: &str, limit: usize, min_number: i64) -> Result<Vec<i64>> {
741 #[derive(Deserialize)]
742 struct Row {
743 number: i64,
744 }
745 let text = self.gh(&[
746 kind,
747 "list",
748 "--state",
749 "open",
750 "--limit",
751 &FETCH_CEILING.to_string(),
752 "--json",
753 "number",
754 ])?;
755 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
756 let mut numbers: Vec<i64> = rows.into_iter().map(|r| r.number).collect();
757 numbers.sort_unstable();
758
759 let noun = if kind == "issue" { "issues" } else { "PRs" };
760 let found = numbers.len();
761 if min_number > 0 {
762 numbers.retain(|n| *n >= min_number);
763 let skipped = found - numbers.len();
764 if skipped > 0 {
765 crate::log!("{skipped} open {noun} below #{min_number} skipped");
766 }
767 }
768 if found >= FETCH_CEILING {
769 crate::log!(
770 "more than {FETCH_CEILING} open {noun}; only the first {FETCH_CEILING} were \
771 considered."
772 );
773 }
774 if numbers.len() > limit {
775 crate::log!(
776 "{} open {noun}, taking the {limit} lowest numbered. Raise --limit or name them \
777 explicitly.",
778 numbers.len()
779 );
780 numbers.truncate(limit);
781 }
782 Ok(numbers)
783 }
784
785 pub fn list_open_issues(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
787 self.open_numbers("issue", limit, min_number)
788 }
789
790 pub fn list_open_prs(&self, limit: usize, min_number: i64) -> Result<Vec<i64>> {
791 self.open_numbers("pr", limit, min_number)
792 }
793
794 pub fn pr_for_branch(&self, branch: &str) -> Option<PrRef> {
795 self.branch_prs(branch, "open").into_iter().next()
796 }
797
798 fn prs_for_branch(&self, branch: &str) -> Vec<PrRef> {
802 self.branch_prs(branch, "all")
803 }
804
805 fn branch_prs(&self, branch: &str, state: &str) -> Vec<PrRef> {
806 let text = self.gh_try(&[
807 "pr",
808 "list",
809 "--head",
810 branch,
811 "--state",
812 state,
813 "--json",
814 "number,url,title",
815 ]);
816 serde_json::from_str::<Vec<PrRef>>(text.trim()).unwrap_or_default()
817 }
818
819 pub fn item_kind(&self, number: i64) -> Result<ItemKind> {
825 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}");
826 let text = self
827 .gh(&[
828 "api",
829 &path,
830 "--jq",
831 "if .pull_request then \"pr\" else \"issue\" end",
832 ])
833 .map_err(|e| {
834 spar_err!(
835 "no issue or pull request #{number} in this repository. {}",
836 e.last_line()
837 )
838 })?;
839 match text.trim() {
840 "pr" => Ok(ItemKind::Pr),
841 "issue" => Ok(ItemKind::Issue),
842 other => Err(spar_err!(
843 "could not tell whether #{number} is an issue or a pull request (got {other:?})"
844 )),
845 }
846 }
847
848 pub fn open_pr_for_issue(&self, issue: i64) -> Option<PrRef> {
854 if let Some(pr) = self.pr_for_branch(&self.branch_for_issue(issue)) {
855 return Some(pr);
856 }
857 let text = self.gh_try(&[
858 "pr",
859 "list",
860 "--state",
861 "open",
862 "--limit",
863 &FETCH_CEILING.to_string(),
864 "--json",
865 "number,url,title,closingIssuesReferences",
866 ]);
867 find_linked_pr(&text, issue)
868 }
869
870 pub fn pr_view(&self, number: i64) -> Result<PrView> {
871 let text = self.gh(&[
872 "pr",
873 "view",
874 &number.to_string(),
875 "--json",
876 "number,url,title,headRefName,baseRefName,state,closingIssuesReferences,isCrossRepository",
877 ])?;
878 serde_json::from_str(&text).map_err(|e| spar_err!("unexpected shape for PR #{number}: {e}"))
879 }
880
881 pub fn pr_state(&self, number: i64) -> String {
882 let text = self.gh_try(&["pr", "view", &number.to_string(), "--json", "state"]);
883 serde_json::from_str::<Value>(text.trim())
884 .ok()
885 .and_then(|v| v.get("state").and_then(Value::as_str).map(str::to_string))
886 .unwrap_or_default()
887 }
888
889 pub fn create_pr(
890 &self,
891 cwd: &Path,
892 branch: &str,
893 base: &str,
894 title: &str,
895 body: &str,
896 ) -> Result<PrRef> {
897 let title = self.clean_title(title)?;
898 let body = self.clean(body)?;
899 let mut argv = vec![
900 "pr", "create", "--base", base, "--head", branch, "--title", &title, "--body", &body,
901 ];
902 if self.drafts != Drafts::Never {
903 argv.push("--draft");
904 }
905 self.gh_at(Some(cwd), &argv)
906 .map_err(|e| spar_err!("could not open a PR for {branch}. {}", e.last_line()))?;
907 self.pr_for_branch(branch).ok_or_else(|| {
908 spar_err!("PR creation reported success but none was found for {branch}")
909 })
910 }
911
912 pub fn comment_pr(&self, number: i64, body: &str) -> Result<()> {
913 let body = self.clean(body)?;
914 self.gh(&["pr", "comment", &number.to_string(), "--body", &body])
915 .map(|_| ())
916 }
917
918 pub fn comment_issue(&self, number: i64, body: &str) -> Result<()> {
919 let body = self.clean(body)?;
920 self.gh(&["issue", "comment", &number.to_string(), "--body", &body])
921 .map(|_| ())
922 }
923
924 pub fn close_issue(&self, number: i64, body: &str) -> Result<()> {
929 self.comment_issue(number, body)?;
930 let n = number.to_string();
931 if self
932 .gh(&["issue", "close", &n, "--reason", "not planned"])
933 .is_ok()
934 {
935 return Ok(());
936 }
937 self.gh(&["issue", "close", &n]).map(|_| ()).map_err(|e| {
939 spar_err!(
940 "commented on #{number} but could not close it: {}",
941 e.last_line()
942 )
943 })
944 }
945
946 pub fn create_issue(&self, title: &str, body: &str) -> Result<String> {
947 let title = self.clean_title(title)?;
948 let body = self.clean_issue_body(body)?;
949 Ok(self
950 .gh(&["issue", "create", "--title", &title, "--body", &body])?
951 .trim()
952 .to_string())
953 }
954}
955
956#[derive(Debug, Clone)]
958pub struct ExistingIssue {
959 pub number: i64,
960 pub url: String,
961 pub title: String,
962 pub body: String,
963 pub open: bool,
964}
965
966impl Repo {
967 pub fn find_similar_issue(&self, title: &str, body: &str) -> Option<ExistingIssue> {
975 #[derive(Deserialize)]
976 #[serde(rename_all = "camelCase")]
977 struct Row {
978 number: i64,
979 #[serde(default)]
980 title: String,
981 #[serde(default)]
982 url: String,
983 #[serde(default)]
984 body: String,
985 #[serde(default)]
986 state: String,
987 }
988 if title.trim().is_empty() {
989 return None;
990 }
991 let query: String = title
994 .chars()
995 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
996 .take(120)
997 .collect();
998 let text = self.gh_try(&[
999 "issue",
1000 "list",
1001 "--state",
1002 "all",
1003 "--limit",
1004 "100",
1005 "--search",
1006 query.trim(),
1007 "--json",
1008 "number,title,url,body,state",
1009 ]);
1010 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
1011 let wanted = format!("{title} {body}");
1012
1013 rows.into_iter()
1014 .find(|row| {
1015 let theirs = format!("{} {}", row.title, row.body);
1016 row.title.trim().eq_ignore_ascii_case(title.trim())
1017 || textsim::same_subject(&wanted, &theirs)
1018 })
1019 .map(|row| ExistingIssue {
1020 number: row.number,
1021 url: row.url,
1022 title: row.title,
1023 open: row.state.eq_ignore_ascii_case("open"),
1024 body: row.body,
1025 })
1026 }
1027
1028 pub fn find_issue_by_title(&self, title: &str) -> Option<String> {
1030 #[derive(Deserialize)]
1031 struct Row {
1032 title: String,
1033 url: String,
1034 }
1035 let needle = title.trim().to_lowercase();
1036 if needle.is_empty() {
1037 return None;
1038 }
1039 let query: String = title
1041 .chars()
1042 .filter(|c| !matches!(c, '"' | '\'' | '\n' | '\r'))
1043 .take(120)
1044 .collect();
1045 let text = self.gh_try(&[
1046 "issue",
1047 "list",
1048 "--state",
1049 "all",
1050 "--limit",
1051 "100",
1052 "--search",
1053 query.trim(),
1054 "--json",
1055 "number,title,url",
1056 ]);
1057 serde_json::from_str::<Vec<Row>>(text.trim())
1058 .ok()?
1059 .into_iter()
1060 .find(|row| row.title.trim().to_lowercase() == needle)
1061 .map(|row| row.url)
1062 }
1063
1064 pub fn mark_ready(&self, number: i64) -> bool {
1072 match self.gh(&["pr", "ready", &number.to_string()]) {
1073 Ok(_) => true,
1074 Err(e) => {
1075 logdim!(
1076 "PR #{number} is approved but could not be taken out of draft: {}",
1077 e.last_line()
1078 );
1079 false
1080 }
1081 }
1082 }
1083
1084 pub fn merge_pr(&self, number: i64) -> Result<()> {
1088 let n = number.to_string();
1089 match self.gh(&["pr", "merge", &n, "--squash", "--delete-branch"]) {
1090 Ok(_) => Ok(()),
1091 Err(e) => {
1092 if self.pr_state(number) == "MERGED" {
1093 logdim!(
1094 "PR #{number} merged; branch cleanup did not finish: {}",
1095 e.last_line()
1096 );
1097 Ok(())
1098 } else {
1099 Err(spar_err!("could not merge PR #{number}. {}", e.last_line()))
1100 }
1101 }
1102 }
1103 }
1104
1105 pub fn followups_path(&self) -> PathBuf {
1110 self.root.join(STATE_DIR).join("followups.md")
1111 }
1112
1113 pub fn worked_followups_path(&self) -> PathBuf {
1121 self.root.join(STATE_DIR).join("followups.done.md")
1122 }
1123
1124 pub fn checkin_state_path(&self, number: i64) -> PathBuf {
1126 self.root
1127 .join(STATE_DIR)
1128 .join("state")
1129 .join(format!("checkin-{number}.json"))
1130 }
1131
1132 pub fn append_local_followup(&self, title: &str, body: &str) -> Followup {
1147 let path = self.followups_path();
1148 let heading = format!("## {}", title.trim());
1149 for seen in [&path, &self.worked_followups_path()] {
1150 if let Ok(existing) = std::fs::read_to_string(seen) {
1151 if existing.contains(&heading) {
1152 logdim!("follow-up already noted: {title}");
1153 return Followup::Covered(format!("note: {}", title.trim()));
1154 }
1155 }
1156 }
1157 if let Some(parent) = path.parent() {
1158 let _ = std::fs::create_dir_all(parent);
1159 }
1160 use std::io::Write;
1161 let entry = format!("{FOLLOWUP_MARKER}\n{heading}\n\n{}\n\n", body.trim());
1168 match std::fs::OpenOptions::new()
1169 .create(true)
1170 .append(true)
1171 .open(&path)
1172 {
1173 Ok(mut file) => match file.write_all(entry.as_bytes()) {
1174 Ok(()) => Followup::Recorded(format!("note: {}", title.trim())),
1175 Err(e) => {
1176 logdim!("could not write {}: {e}", path.display());
1177 Followup::Failed
1178 }
1179 },
1180 Err(e) => {
1181 logdim!("could not write {}: {e}", path.display());
1182 Followup::Failed
1183 }
1184 }
1185 }
1186
1187 pub fn archive_followup(&self, title: &str, body: &str, verdict: &str) {
1192 let path = self.worked_followups_path();
1193 if let Some(parent) = path.parent() {
1194 let _ = std::fs::create_dir_all(parent);
1195 }
1196 use std::io::Write;
1197 let entry = format!(
1198 "{FOLLOWUP_MARKER}\n## {}\n\n{verdict}\n\n{}\n\n",
1199 title.trim(),
1200 body.trim()
1201 );
1202 if let Ok(mut file) = std::fs::OpenOptions::new()
1203 .create(true)
1204 .append(true)
1205 .open(&path)
1206 {
1207 let _ = file.write_all(entry.as_bytes());
1208 }
1209 }
1210
1211 pub fn pending_comment_path(&self, number: i64) -> PathBuf {
1220 self.root
1221 .join(STATE_DIR)
1222 .join("reviews")
1223 .join(format!("pr-{number}.md"))
1224 }
1225
1226 pub fn save_pending_comment(&self, number: i64, text: &str) -> Result<PathBuf> {
1232 let path = self.pending_comment_path(number);
1233 if let Some(parent) = path.parent() {
1234 std::fs::create_dir_all(parent)
1235 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1236 }
1237 std::fs::write(&path, text)
1238 .map_err(|e| spar_err!("could not write {}: {e}", path.display()))?;
1239 Ok(path)
1240 }
1241
1242 pub fn read_pending_comment(&self, number: i64) -> Option<String> {
1243 std::fs::read_to_string(self.pending_comment_path(number)).ok()
1244 }
1245
1246 pub fn state_path(&self, number: i64) -> PathBuf {
1247 self.root
1248 .join(STATE_DIR)
1249 .join("state")
1250 .join(format!("pr-{number}.json"))
1251 }
1252
1253 fn read_local_state(&self, number: i64) -> Option<PersistedState> {
1254 let path = self.state_path(number);
1255 let text = std::fs::read_to_string(&path).ok()?;
1256 match serde_json::from_str(&text) {
1257 Ok(state) => Some(state),
1258 Err(_) => {
1259 logdim!("could not read {}, starting fresh", path.display());
1260 None
1261 }
1262 }
1263 }
1264
1265 pub fn read_state(&self, pr: &PrView) -> Option<PersistedState> {
1266 if let Some(local) = self.read_local_state(pr.number) {
1267 return Some(local);
1268 }
1269 if self.state_store.writes_pr() {
1270 return self.read_pr_state(pr.number);
1271 }
1272 None
1273 }
1274
1275 fn read_pr_state(&self, number: i64) -> Option<PersistedState> {
1276 for body in self.state_comment_bodies(number).into_iter().rev() {
1277 if let Some(state) = parse_state_comment(&body) {
1278 return Some(state);
1279 }
1280 }
1281 None
1282 }
1283
1284 pub fn write_state(&self, number: i64, state: &PersistedState) -> Result<()> {
1285 if self.state_store.writes_local() {
1286 write_json_atomic(&self.state_path(number), state)?;
1287 }
1288 if self.state_store.writes_pr() {
1289 self.write_pr_state(number, state)?;
1290 }
1291 Ok(())
1292 }
1293
1294 fn write_pr_state(&self, number: i64, state: &PersistedState) -> Result<()> {
1295 let body = format!(
1299 "{STATE_MARKER}\n{}\n-->",
1300 serde_json::to_string_pretty(state)?
1301 );
1302 if let Some(id) = self.state_comment_id(number) {
1303 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
1304 let field = format!("body={body}");
1305 self.gh_try(&["api", "-X", "PATCH", &path, "-f", &field, "--silent"]);
1306 return Ok(());
1307 }
1308 self.gh(&["pr", "comment", &number.to_string(), "--body", &body])
1309 .map(|_| ())
1310 }
1311
1312 pub fn issue_comments(&self, number: i64) -> Vec<Value> {
1315 let path = format!("repos/{{owner}}/{{repo}}/issues/{number}/comments");
1316 parse_comment_pages(&self.gh_try(&["api", "--paginate", &path]))
1317 }
1318
1319 fn state_comments(&self, number: i64) -> Vec<(i64, String)> {
1320 self.issue_comments(number)
1321 .into_iter()
1322 .filter_map(|c| {
1323 let body = c.get("body").and_then(Value::as_str)?.to_string();
1324 if !body.contains("spar:state") {
1325 return None;
1326 }
1327 let id = c.get("id").and_then(Value::as_i64)?;
1328 Some((id, body))
1329 })
1330 .collect()
1331 }
1332
1333 fn state_comment_bodies(&self, number: i64) -> Vec<String> {
1334 self.state_comments(number)
1335 .into_iter()
1336 .map(|(_, b)| b)
1337 .collect()
1338 }
1339
1340 fn state_comment_id(&self, number: i64) -> Option<i64> {
1341 self.state_comments(number).last().map(|(id, _)| *id)
1342 }
1343
1344 pub fn clear_state(&self, number: i64) {
1346 let path = self.state_path(number);
1347 let _ = std::fs::remove_file(&path);
1348 let _ = std::fs::remove_file(path.with_extension("json.tmp"));
1349 }
1350
1351 pub fn prune_state(&self) -> Vec<String> {
1355 let base = self.root.join(STATE_DIR).join("state");
1356 let Ok(entries) = std::fs::read_dir(&base) else {
1357 return Vec::new();
1358 };
1359 let mut names: Vec<String> = entries
1360 .flatten()
1361 .filter_map(|e| e.file_name().to_str().map(str::to_string))
1362 .filter(|n| n.starts_with("pr-") && n.ends_with(".json"))
1363 .collect();
1364 names.sort();
1365
1366 let mut removed = Vec::new();
1367 for name in names {
1368 let Ok(number) = name[3..name.len() - 5].parse::<i64>() else {
1369 continue;
1370 };
1371 if is_finished(&self.pr_state(number)) {
1372 let _ = std::fs::remove_file(base.join(&name));
1373 removed.push(format!("state {name}"));
1374 }
1375 }
1376 removed
1377 }
1378
1379 pub fn prune_pr_state(&self, numbers: Option<Vec<i64>>) -> Vec<String> {
1383 #[derive(Deserialize)]
1384 struct Row {
1385 number: i64,
1386 }
1387 let numbers = numbers.unwrap_or_else(|| {
1388 let text = self.gh_try(&[
1389 "pr", "list", "--state", "all", "--limit", "200", "--json", "number",
1390 ]);
1391 serde_json::from_str::<Vec<Row>>(text.trim())
1392 .unwrap_or_default()
1393 .into_iter()
1394 .map(|r| r.number)
1395 .collect()
1396 });
1397
1398 let mut removed = Vec::new();
1399 for number in numbers {
1400 if !is_finished(&self.pr_state(number)) {
1401 continue;
1402 }
1403 for (id, _) in self.state_comments(number) {
1404 let path = format!("repos/{{owner}}/{{repo}}/issues/comments/{id}");
1405 self.gh_try(&["api", "-X", "DELETE", &path, "--silent"]);
1406 removed.push(format!("state comment on PR #{number}"));
1407 }
1408 }
1409 removed
1410 }
1411
1412 pub fn prune_worktrees(&self, force_all: bool) -> Vec<String> {
1419 let base = self.root.join(WORKTREE_DIR);
1420 let mut removed = Vec::new();
1421
1422 if let Ok(entries) = std::fs::read_dir(&base) {
1423 let mut names: Vec<String> = entries
1424 .flatten()
1425 .filter(|e| e.path().is_dir())
1426 .filter_map(|e| e.file_name().to_str().map(str::to_string))
1427 .collect();
1428 names.sort();
1429
1430 for name in names {
1431 if let Some(rest) = name.strip_prefix("review-") {
1434 let number: i64 = rest.parse().unwrap_or(-1);
1435 if !(force_all || is_finished(&self.pr_state(number))) {
1436 continue;
1437 }
1438 self.release_review_worktree(number);
1439 removed.push(name);
1440 continue;
1441 }
1442 let branch = format!("{}{name}", self.branch_prefix);
1443 if !(force_all || self.worktree_is_done(&branch)) {
1444 continue;
1445 }
1446 self.remove_worktree_at(&base.join(&name));
1447 self.git_try(&["branch", "-D", &branch]);
1448 self.forget_branch(&branch);
1449 removed.push(name);
1450 }
1451 }
1452 if !removed.is_empty() {
1453 self.git_try(&["worktree", "prune"]);
1454 }
1455 removed.extend(self.prune_branches(force_all));
1456 removed
1457 }
1458
1459 pub fn prune_branches(&self, force_all: bool) -> Vec<String> {
1466 let branches: Vec<String> = self.known_branches().keys().cloned().collect();
1467 if branches.is_empty() {
1468 return Vec::new();
1469 }
1470
1471 let checked_out: Vec<String> = self
1472 .git_try(&["worktree", "list", "--porcelain"])
1473 .lines()
1474 .filter_map(|l| l.strip_prefix("branch refs/heads/").map(str::to_string))
1475 .collect();
1476
1477 let existing: Vec<String> = self
1480 .git_try(&["for-each-ref", "refs/heads/", "--format=%(refname)"])
1481 .lines()
1482 .filter_map(|l| l.trim().strip_prefix("refs/heads/").map(str::to_string))
1483 .collect();
1484
1485 let mut removed = Vec::new();
1486 for branch in branches {
1487 if !existing.contains(&branch) {
1488 self.forget_branch(&branch); continue;
1490 }
1491 if checked_out.contains(&branch) {
1492 continue;
1493 }
1494 if !(force_all || self.worktree_is_done(&branch)) {
1495 continue;
1496 }
1497 match self.git(&["branch", "-D", &branch]) {
1498 Ok(_) => {
1499 self.forget_branch(&branch);
1500 removed.push(format!("branch {branch}"));
1501 }
1502 Err(e) => {
1503 logdim!("could not delete {branch}: {}", e.last_line());
1506 }
1507 }
1508 }
1509 removed
1510 }
1511
1512 fn worktree_is_done(&self, branch: &str) -> bool {
1514 #[derive(Deserialize)]
1515 struct Row {
1516 state: String,
1517 }
1518 let entry = branch
1519 .strip_prefix(self.branch_prefix.as_str())
1520 .unwrap_or(branch);
1521 if let Some(rest) = entry.strip_prefix("pr-") {
1522 return is_finished(&self.pr_state(rest.parse().unwrap_or(-1)));
1523 }
1524 if entry.starts_with("issue-") {
1525 let text = self.gh_try(&[
1526 "pr", "list", "--head", branch, "--state", "all", "--json", "state",
1527 ]);
1528 let rows: Vec<Row> = serde_json::from_str(text.trim()).unwrap_or_default();
1529 return !rows.is_empty() && rows.iter().all(|r| is_finished(&r.state));
1530 }
1531 false
1532 }
1533}
1534
1535#[derive(Debug, Clone, serde::Serialize, Deserialize)]
1540pub struct BranchRecord {
1541 pub kind: String,
1542 pub number: i64,
1543}
1544
1545pub fn review_ref(number: i64) -> String {
1548 format!("refs/spar/pr-{number}")
1549}
1550
1551pub fn is_finished(state: &str) -> bool {
1552 matches!(state.trim().to_uppercase().as_str(), "MERGED" | "CLOSED")
1553}
1554
1555pub fn write_text_atomic(path: &Path, text: &str) -> Result<()> {
1562 if let Some(parent) = path.parent() {
1563 std::fs::create_dir_all(parent)
1564 .map_err(|e| spar_err!("could not create {}: {e}", parent.display()))?;
1565 }
1566 let tmp = path.with_extension(format!(
1569 "{}.tmp",
1570 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
1571 ));
1572 std::fs::write(&tmp, text).map_err(|e| spar_err!("could not write {}: {e}", tmp.display()))?;
1573 std::fs::rename(&tmp, path)
1574 .map_err(|e| spar_err!("could not replace {}: {e}", path.display()))?;
1575 Ok(())
1576}
1577
1578pub fn write_json_atomic<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
1581 write_text_atomic(path, &serde_json::to_string_pretty(value)?)
1582}
1583
1584pub fn find_linked_pr(json: &str, issue: i64) -> Option<PrRef> {
1591 #[derive(Deserialize)]
1592 #[serde(rename_all = "camelCase")]
1593 struct Row {
1594 number: i64,
1595 #[serde(default)]
1596 url: String,
1597 #[serde(default)]
1598 title: String,
1599 #[serde(default)]
1600 closing_issues_references: Vec<IssueRef>,
1601 }
1602
1603 serde_json::from_str::<Vec<Row>>(json.trim())
1604 .ok()?
1605 .into_iter()
1606 .find(|row| {
1607 row.closing_issues_references
1608 .iter()
1609 .any(|linked| linked.number == issue)
1610 })
1611 .map(|row| PrRef {
1612 number: row.number,
1613 url: row.url,
1614 title: row.title,
1615 })
1616}
1617
1618pub fn parse_comment_pages(text: &str) -> Vec<Value> {
1625 let mut out = Vec::new();
1626 for value in serde_json::Deserializer::from_str(text.trim()).into_iter::<Value>() {
1627 match value {
1628 Ok(Value::Array(items)) => out.extend(items),
1629 Ok(other) => out.push(other),
1630 Err(_) => break,
1631 }
1632 }
1633 out
1634}
1635
1636pub fn parse_state_comment(body: &str) -> Option<PersistedState> {
1639 let marker = body.find(STATE_MARKER)?;
1640 let start = body[marker..].find('{')? + marker;
1641 let end = body.rfind('}')?;
1642 if end <= start {
1643 return None;
1644 }
1645 match serde_json::from_str(&body[start..=end]) {
1646 Ok(state) => Some(state),
1647 Err(_) => {
1648 logdim!("found a spar state comment but could not parse it");
1649 None
1650 }
1651 }
1652}
1653
1654pub fn self_binary() -> Result<PathBuf> {
1660 if let Some(path) = std::env::var_os("SPAR_SELF_BIN") {
1661 let path = PathBuf::from(path);
1662 if proc::is_executable(&path) {
1663 return Ok(path);
1664 }
1665 bail!(
1666 "SPAR_SELF_BIN is set to {}, which is not executable",
1667 path.display()
1668 );
1669 }
1670 std::env::current_exe()
1671 .map_err(|e| spar_err!("could not locate the spar binary for a commit rewrite: {e}"))
1672}
1673
1674fn bool_env(value: bool) -> &'static str {
1675 if value {
1676 "1"
1677 } else {
1678 "0"
1679 }
1680}
1681
1682pub fn sh_quote(text: &str) -> String {
1685 format!("'{}'", text.replace('\'', r"'\''"))
1686}
1687
1688pub fn style_from_env() -> Style {
1691 let flag = |key: &str| !matches!(std::env::var(key).as_deref(), Ok("0"));
1692 Style {
1693 ban_em_dash: flag("SPAR_BAN_EM_DASH"),
1694 ban_ai_attribution: flag("SPAR_BAN_AI_ATTRIBUTION"),
1695 ..Style::permissive()
1696 }
1697}
1698
1699#[cfg(test)]
1700mod tests {
1701 use super::*;
1702 use crate::config::StateStore;
1703 use crate::model::{Ledger, Status};
1704
1705 fn repo_for_titles() -> Repo {
1706 Repo {
1707 root: PathBuf::from("/nonexistent"),
1708 style: Style::default(),
1709 branch_prefix: String::new(),
1710 state_store: StateStore::Local,
1711 followups: crate::config::Followups::Issues,
1712 drafts: Drafts::Never,
1713 viewer: OnceLock::new(),
1714 }
1715 }
1716
1717 #[test]
1721 fn clean_title_is_idempotent_even_when_the_scrub_lengthens_it() {
1722 let repo = repo_for_titles();
1723 for raw in [
1724 "Retry loop spins \u{2014} Retry-After parses to zero",
1725 "plain title",
1726 " spread over\nlines ",
1727 "\u{1F916} Generated with something",
1728 &format!("a \u{2014} {}", "very long title ".repeat(20)),
1729 &"x".repeat(300),
1730 &format!("{} \u{2014} end", "y".repeat(88)),
1731 &{
1736 let tail = "a\u{2014}b c\u{2014}d";
1737 let pad = Style::default().max_title_chars - tail.chars().count();
1738 format!("{}{tail}", "w".repeat(pad))
1739 },
1740 ] {
1741 let once = repo.clean_title(raw).unwrap();
1742 let twice = repo.clean_title(&once).unwrap();
1743 assert_eq!(once, twice, "not idempotent for {raw:?}");
1744 assert!(
1745 once.chars().count() <= repo.style.max_title_chars,
1746 "over budget: {once:?}"
1747 );
1748 assert!(style::violations(&once, &repo.style).is_empty(), "{once:?}");
1749 }
1750 }
1751
1752 #[test]
1753 fn a_title_with_an_em_dash_survives_as_readable_text() {
1754 let repo = repo_for_titles();
1755 assert_eq!(
1756 "Retry loop spins, Retry-After parses to zero",
1757 repo.clean_title("Retry loop spins \u{2014} Retry-After parses to zero")
1758 .unwrap()
1759 );
1760 }
1761
1762 #[test]
1763 fn sh_quote_survives_a_quote() {
1764 assert_eq!(r"'a'\''b'", sh_quote("a'b"));
1765 }
1766
1767 #[test]
1768 fn sh_quote_wraps_a_space() {
1769 assert_eq!(
1770 "'/Applications/My App/spar'",
1771 sh_quote("/Applications/My App/spar")
1772 );
1773 }
1774
1775 #[test]
1776 fn finished_states_are_recognised_case_insensitively() {
1777 assert!(is_finished("MERGED"));
1778 assert!(is_finished("closed"));
1779 assert!(!is_finished("OPEN"));
1780 assert!(!is_finished(""));
1781 }
1782
1783 fn state() -> PersistedState {
1784 PersistedState {
1785 version: 1,
1786 round: 4,
1787 next_actor: "codex".into(),
1788 status: Status::Pending,
1789 ledger: Ledger::new(),
1790 filed: vec![],
1791 }
1792 }
1793
1794 #[test]
1795 fn a_state_comment_round_trips() {
1796 let body = format!(
1797 "{STATE_MARKER}\n{}\n-->",
1798 serde_json::to_string(&state()).unwrap()
1799 );
1800 let back = parse_state_comment(&body).unwrap();
1801 assert_eq!(4, back.round);
1802 assert_eq!("codex", back.next_actor);
1803 }
1804
1805 #[test]
1807 fn the_state_block_is_an_html_comment() {
1808 let body = format!(
1809 "{STATE_MARKER}\n{}\n-->",
1810 serde_json::to_string(&state()).unwrap()
1811 );
1812 assert!(body.starts_with("<!--"));
1813 assert!(body.trim_end().ends_with("-->"));
1814 assert!(!body[..body.find('{').unwrap()].contains("-->"));
1815 }
1816
1817 #[test]
1818 fn an_unrelated_json_block_is_not_state() {
1819 assert!(parse_state_comment("here is a snippet\n```json\n{\"round\": 99}\n```").is_none());
1820 }
1821
1822 #[test]
1823 fn a_malformed_state_comment_is_none_not_a_panic() {
1824 assert!(parse_state_comment(&format!("{STATE_MARKER}\n{{not json\n-->")).is_none());
1825 }
1826
1827 #[test]
1828 fn atomic_write_leaves_no_temp_file() {
1829 let dir = std::env::temp_dir().join(format!("spar-atomic-{}", std::process::id()));
1830 let _ = std::fs::remove_dir_all(&dir);
1831 let path = dir.join("state").join("pr-7.json");
1832 write_json_atomic(&path, &state()).unwrap();
1833 let files: Vec<String> = std::fs::read_dir(path.parent().unwrap())
1834 .unwrap()
1835 .flatten()
1836 .filter_map(|e| e.file_name().to_str().map(str::to_string))
1837 .collect();
1838 assert_eq!(vec!["pr-7.json".to_string()], files);
1839 let _ = std::fs::remove_dir_all(&dir);
1840 }
1841
1842 #[test]
1843 fn atomic_write_overwrites_rather_than_accumulating() {
1844 let dir = std::env::temp_dir().join(format!("spar-overwrite-{}", std::process::id()));
1845 let _ = std::fs::remove_dir_all(&dir);
1846 let path = dir.join("pr-7.json");
1847 for round in 1..4 {
1848 let mut s = state();
1849 s.round = round;
1850 write_json_atomic(&path, &s).unwrap();
1851 }
1852 let back: PersistedState =
1853 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1854 assert_eq!(3, back.round);
1855 let _ = std::fs::remove_dir_all(&dir);
1856 }
1857
1858 #[test]
1859 fn style_from_env_defaults_to_enforcing() {
1860 std::env::remove_var("SPAR_BAN_EM_DASH");
1861 std::env::remove_var("SPAR_BAN_AI_ATTRIBUTION");
1862 let style = style_from_env();
1863 assert!(style.ban_em_dash && style.ban_ai_attribution);
1864 assert!(
1865 !style.terse,
1866 "the commit filter must not truncate a commit message"
1867 );
1868 }
1869}
1870
1871#[cfg(test)]
1872mod comment_page_tests {
1873 use super::*;
1874
1875 #[test]
1876 fn a_single_merged_array_is_read() {
1877 let pages = parse_comment_pages(r#"[{"id":1,"body":"a"},{"id":2,"body":"b"}]"#);
1878 assert_eq!(2, pages.len());
1879 assert_eq!(Some(2), pages[1]["id"].as_i64());
1880 }
1881
1882 #[test]
1883 fn concatenated_pages_from_an_older_gh_are_read_too() {
1884 let pages = parse_comment_pages(r#"[{"id":1}][{"id":2}]"#);
1885 assert_eq!(2, pages.len());
1886 }
1887
1888 #[test]
1892 fn a_comment_body_containing_a_bracket_pair_is_not_mistaken_for_a_page_break() {
1893 let text = r#"[{"id":1,"body":"see [the docs][ref] for why"},{"id":2,"body":"ok"}]"#;
1894 let pages = parse_comment_pages(text);
1895 assert_eq!(2, pages.len(), "{pages:?}");
1896 assert!(pages[0]["body"].as_str().unwrap().contains("[ref]"));
1897 }
1898
1899 #[test]
1900 fn empty_output_is_no_comments_not_a_panic() {
1901 assert!(parse_comment_pages("").is_empty());
1902 assert!(parse_comment_pages(" ").is_empty());
1903 assert!(parse_comment_pages("[]").is_empty());
1904 }
1905
1906 #[test]
1907 fn a_gh_error_message_on_stdout_yields_nothing_rather_than_garbage() {
1908 assert!(parse_comment_pages("gh: Not Found (HTTP 404)").is_empty());
1909 }
1910
1911 #[test]
1912 fn state_is_found_in_the_last_matching_comment() {
1913 let payload = |round: u32| {
1914 format!(
1915 "{STATE_MARKER}\n{{\"version\":1,\"round\":{round},\"next_actor\":\"a\",\"status\":\"pending\",\"ledger\":{{}},\"filed\":[]}}\n-->"
1916 )
1917 };
1918 let text = serde_json::to_string(&serde_json::json!([
1919 {"id": 1, "body": payload(1)},
1920 {"id": 2, "body": "looks good to me"},
1921 {"id": 3, "body": payload(5)},
1922 ]))
1923 .unwrap();
1924 let pages = parse_comment_pages(&text);
1925 let last = pages
1926 .iter()
1927 .rev()
1928 .find_map(|c| parse_state_comment(c["body"].as_str().unwrap_or("")))
1929 .unwrap();
1930 assert_eq!(5, last.round);
1931 }
1932}
1933
1934#[cfg(test)]
1935mod linked_pr_tests {
1936 use super::*;
1937
1938 const REAL_PAYLOAD: &str = r#"[
1943 {"number":14252,"title":"fix: reject leading-dash branch names",
1944 "url":"https://github.com/cli/cli/pull/14252",
1945 "closingIssuesReferences":[{"id":"I_kwDO","number":14238,
1946 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
1947 "url":"https://github.com/cli/cli/issues/14238"}]},
1948 {"number":14217,"title":"another change",
1949 "url":"https://github.com/cli/cli/pull/14217",
1950 "closingIssuesReferences":[{"id":"I_kwDO","number":9761,
1951 "repository":{"id":"MDEwOlJl","name":"cli","owner":{"id":"MDEy","login":"cli"}},
1952 "url":"https://github.com/cli/cli/issues/9761"}]},
1953 {"number":14200,"title":"unlinked work",
1954 "url":"https://github.com/cli/cli/pull/14200","closingIssuesReferences":[]}
1955 ]"#;
1956
1957 #[test]
1958 fn a_linked_pr_is_found_whatever_its_branch_is_called() {
1959 let pr = find_linked_pr(REAL_PAYLOAD, 14238).expect("should find it");
1960 assert_eq!(14252, pr.number);
1961 assert_eq!("https://github.com/cli/cli/pull/14252", pr.url);
1962 }
1963
1964 #[test]
1965 fn the_right_pr_is_picked_out_of_several() {
1966 assert_eq!(14217, find_linked_pr(REAL_PAYLOAD, 9761).unwrap().number);
1967 }
1968
1969 #[test]
1970 fn an_issue_nobody_is_working_on_finds_nothing() {
1971 assert!(find_linked_pr(REAL_PAYLOAD, 99999).is_none());
1972 }
1973
1974 #[test]
1975 fn an_unlinked_pr_is_never_matched() {
1976 for issue in [14200, 0, 1] {
1978 if let Some(pr) = find_linked_pr(REAL_PAYLOAD, issue) {
1979 assert_ne!(14200, pr.number, "matched a PR that closes nothing");
1980 }
1981 }
1982 }
1983
1984 #[test]
1985 fn empty_or_broken_output_is_none_rather_than_a_panic() {
1986 assert!(find_linked_pr("", 1).is_none());
1987 assert!(find_linked_pr("[]", 1).is_none());
1988 assert!(find_linked_pr("gh: Not Found (HTTP 404)", 1).is_none());
1989 assert!(find_linked_pr("[{\"number\":", 1).is_none());
1990 }
1991
1992 #[test]
1994 fn pr_view_reads_the_cross_repository_flag() {
1995 let json = r#"{"number":7,"url":"u","title":"t","headRefName":"patch-1",
1996 "baseRefName":"main","state":"OPEN",
1997 "closingIssuesReferences":[],"isCrossRepository":true}"#;
1998 let pr: PrView = serde_json::from_str(json).unwrap();
1999 assert!(pr.is_cross_repository);
2000 assert!(pr.is_open());
2001
2002 let same_repo = json.replace("\"isCrossRepository\":true", "\"isCrossRepository\":false");
2003 assert!(
2004 !serde_json::from_str::<PrView>(&same_repo)
2005 .unwrap()
2006 .is_cross_repository
2007 );
2008 }
2009}
2010
2011#[cfg(test)]
2012mod min_number_tests {
2013 fn pick(open: &[i64], limit: usize, min_number: i64) -> Vec<i64> {
2019 let mut numbers: Vec<i64> = open.to_vec();
2020 numbers.sort_unstable();
2021 if min_number > 0 {
2022 numbers.retain(|n| *n >= min_number);
2023 }
2024 numbers.truncate(limit);
2025 numbers
2026 }
2027
2028 #[test]
2029 fn the_floor_is_applied_before_the_cap_not_after() {
2030 let open = [12, 13, 14, 480, 481, 482];
2031 assert_eq!(vec![480, 481], pick(&open, 2, 480));
2032 assert!(!pick(&open, 2, 480).is_empty());
2035 }
2036
2037 #[test]
2038 fn no_floor_keeps_the_old_behaviour() {
2039 assert_eq!(vec![12, 13], pick(&[12, 13, 14, 480], 2, 0));
2040 }
2041
2042 #[test]
2043 fn the_floor_is_inclusive() {
2044 assert_eq!(vec![480, 481], pick(&[479, 480, 481], 10, 480));
2045 }
2046
2047 #[test]
2048 fn a_floor_above_everything_open_yields_nothing() {
2049 assert!(pick(&[1, 2, 3], 10, 9999).is_empty());
2050 }
2051}